portail-associations/server/routers.ts

10846 lines
428 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
cancelAuthenticatorSetup,
cancelScheduledAccountDeletion,
clearSessionCookie,
confirmAuthenticatorSetup,
createSessionToken,
getInternalMfaPolicy,
hashLocalPassword,
isInternalRole,
isAccountLockedError,
isMfaCodeRequiredError,
loginLocalUser,
registerLocalUser,
resendLocalUserMfaChallenge,
scheduleAccountDeletion,
setSessionCookie,
startAuthenticatorSetup,
verifyLocalUserMfa,
} from "./_core/auth";
import { systemRouter } from "./_core/systemRouter";
import { publicProcedure, protectedProcedure, router } from "./_core/trpc";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { nanoid } from "nanoid";
import { randomBytes } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { storagePut, storageGet } from "./storage";
import * as db from "./db";
import { notifyOwner } from "./_core/notification";
import { generateRequestPdfDocument } from "./requestPdf";
import { generateRequestRecapEmail } from "./emailRecap";
import { canSendOperationalEmails, sendOperationalEmail } from "./mailer";
import { getOAuthProviderStatus } from "./socialAuth";
import { createAssociationProfileFromDirectoryEntry, parseAssociationDirectoryWorkbook, resolveAssociationDirectoryImportRows } from "./associationDirectory";
import { generateAssociationInvitationEmail } from "./associationInvitationEmail";
import { computeAssociationDirectoryGeoUpdate, getPublicMapCoordinates } from "./associationGeo";
import { computeAssociationDirectoryReferenceProposal } from "./associationReferenceSync";
import {
HELLOASSO_SETTINGS_KEY,
HelloAssoSyncClient,
computeAssociationDirectoryHelloAssoUpdate,
sanitizeHelloAssoSettings,
serializeHelloAssoSettingsPublic,
} from "./associationHelloAssoSync";
import type { AssociationDirectoryMatchResult } from "./associationDirectoryMatcher";
import { getLogisticsSettings, saveLogisticsSettings } from "./logisticsSettings";
import { getLogisticsGroupSettings, saveLogisticsGroupSettings } from "./logisticsGroup";
import {
getSalleSignatureDelegateCandidates,
getSalleSignatureDelegates,
withEffectiveInternalAccess,
} from "./internalAccess";
import {
addDelegatedSignerForDirectrice,
getDelegatedSignerIdsForDirectrice,
removeDelegatedSignerForDirectrice,
setDelegatedSignerIdsForDirectrice,
} from "./salleSignatureDelegation";
import { getAdminMailSettings, saveAdminMailSettings } from "./mailSettings";
import {
agentRoleOptions,
buildMaterialReturnUploadLink,
borrowerRoleOptions,
computeMaterialReturnBoardState,
getMaterialReturnUploadLinkState,
materialReturnDiscrepancyOptions,
materialReturnBoardStateLabels,
scheduleMaterialReturnFollowup,
sendMaterialReturnFollowupEmail,
serializeMaterialReturnFollowup,
} from "./materialReturnWorkflow";
import {
generateCompletedMaterialReturnStatementPdf,
generateMaterialReturnStatementPdf,
} from "./materialReturnPdf";
import { generateMaterialReturnLitigationLetterPdf } from "./materialReturnLitigationPdf";
import { generateMaterialReturnCompletionEmail } from "./materialReturnEmail";
import { generateMaterialConventionPdf, type MaterialContractFinancialMode, type MaterialContractStatus } from "./materialConventionPdf";
import { generateSalleConventionPdf } from "./salleConventionPdf";
import { generateSalleQuotePdf } from "./salleQuotePdf";
import { generateSalleAdministrativeDecisionPdf } from "./salleAdministrativeDecisionPdf";
import { generateSalleInvoicePdf } from "./salleInvoicePdf";
import { generateSalleFinalEmail, generateSalleQuoteEmail } from "./salleReservationEmails";
import { generateReservationAnalyticsExcel, generateReservationAnalyticsPdf } from "./statsReportArtifacts";
import {
buildDefaultSalleWorkflowTexts,
computeSalleWorkflowPricing,
getSalleWorkflowData,
mergeSalleWorkflowData,
updateSalleWorkflowDirectorStatus,
updateSalleWorkflowQuoteStatus,
} from "./salleReservationWorkflow";
import {
APPEARANCE_SETTING_KEY,
appearanceAssetFields,
appearanceCardDecorationAnchors,
appearanceDecorationScopes,
appearanceDecorationLayers,
appearanceBackgroundTargets,
appearanceBackgroundPositions,
appearanceBackgroundPresentationModes,
appearanceButtonStyles,
appearanceBackgroundModes,
appearanceCardStyles,
appearanceFontFamilies,
appearanceSidebarStyles,
appearanceTableStyles,
appearanceThemeModes,
appearanceInputStyles,
defaultPortalAppearance,
sanitizePortalAppearance,
} from "@shared/appearance";
import { associationCommuneOptions } from "@shared/associationCommunes";
import {
DATA_PRIVACY_NOTICE_VERSION,
dataPrivacyConsentSchema,
type DataPrivacyConsent,
} from "@shared/privacyCompliance";
import {
buildDisplayName,
legalRepresentativeRoleValues,
governanceMemberRoleValues,
normalizeLegalRepresentativeRole,
parseAssociationGovernance,
sanitizeGovernance,
serializeAssociationGovernance,
type AssociationGovernance,
} from "@shared/associationGovernance";
import {
associationThematicValues,
getAssociationThematicLabel,
parseAssociationThematics,
serializeAssociationThematics,
} from "@shared/associationThematics";
import {
emptyMaterialEventQuantityMap,
getMaterialEventLabel,
materialEventItems,
parseMaterialEventQuantity,
sanitizeMaterialEventQuantityMap,
type MaterialEventItemKey,
} from "@shared/materialEvent";
import { isValidFacebookUrl, isValidInstagramUrl, normalizeSocialUrl } from "@shared/socialLinks";
import { supportedMailProviders } from "@shared/mailProviders";
import {
CONDITIONS_FINANCIERES_SALLE_TEXT,
computeSallePricing,
SALLE_WORKFLOW_DIRECTOR_STATUS_LABELS,
SALLE_WORKFLOW_PAYMENT_STATUS_LABELS,
SALLE_WORKFLOW_QUOTE_STATUS_LABELS,
} from "@shared/sallePricing";
import {
operationalTourAuthModeSchema,
clearOperationalTourHistory,
getOperationalTourHistory,
getOperationalTourSettings,
operationalTourEnvironmentSchema,
OPERATIONAL_TOUR_SETTINGS_KEY,
runOperationalTour,
saveOperationalTourSettings,
} from "./operationalTour";
const SALLE_BILLING_SETTING_KEY = "system.salle.billing";
const INTERNAL_DIRECTORY_SETTING_KEY = "system.ccds.internalDirectory";
const STATS_AUTOMATION_SETTING_KEY = "system.stats.automation";
const STATS_REPORT_HISTORY_SETTING_KEY = "system.stats.reportHistory";
const ASSOCIATION_MAP_SETTING_KEY = "system.associationDirectory.map";
const USER_OPERATING_TOUR_SETTING_KEY = "system.userOperatingTour";
const COMPLIANCE_SUPPLIERS_SETTING_KEY = "system.compliance.suppliers";
const COMPLIANCE_DPA_SETTING_KEY = "system.compliance.dpa";
const COMPLIANCE_BACKUP_SETTING_KEY = "system.compliance.backups";
const COMPLIANCE_RETENTION_HISTORY_SETTING_KEY = "system.compliance.retentionHistory";
const COMPLIANCE_EVIDENCE_SETTING_KEY = "system.compliance.evidenceCenter";
const COMPLIANCE_ISO_DELIVERABLES_SETTING_KEY = "system.compliance.isoDeliverables";
const ANALYTICS_PERIOD_VALUES = ["7d", "month", "quarter", "semester", "year"] as const;
type AnalyticsPeriod = (typeof ANALYTICS_PERIOD_VALUES)[number];
const analyticsPeriodSchema = z.enum(ANALYTICS_PERIOD_VALUES);
const userOperatingTourSettingsSchema = z.object({
enabled: z.boolean().default(true),
});
type UserOperatingTourSettings = z.infer<typeof userOperatingTourSettingsSchema>;
type ComplianceStatus = "implemented" | "partial" | "missing";
const defaultUserOperatingTourSettings: UserOperatingTourSettings = {
enabled: true,
};
const appearanceSettingsSchema = z.object({
themeMode: z.enum(appearanceThemeModes),
activeSkinId: z.string().trim().min(1).max(80),
portalTitle: z.string().trim().min(1).max(120),
portalTagline: z.string().trim().min(1).max(220),
primaryColor: z.string(),
secondaryColor: z.string(),
accentColor: z.string(),
buttonColor: z.string(),
alertColor: z.string(),
fontFamily: z.enum(appearanceFontFamilies),
headingFontFamily: z.enum(appearanceFontFamilies),
headingScalePercent: z.number().min(85).max(130),
bodyScalePercent: z.number().min(90).max(115),
logoUrl: z.string().optional(),
faviconUrl: z.string().optional(),
heroImageUrl: z.string().optional(),
backgroundImageUrl: z.string().optional(),
backgroundEnabled: z.boolean().optional(),
backgroundMode: z.enum(appearanceBackgroundModes).optional(),
backgroundPresentationMode: z.enum(appearanceBackgroundPresentationModes).optional(),
backgroundOverlayOpacity: z.number().min(0).max(100).optional(),
backgroundBlurRadius: z.number().min(0).max(24).optional(),
backgroundTargets: z.array(z.enum(appearanceBackgroundTargets)).optional(),
backgroundTargetImageUrls: z.record(z.string(), z.string()).optional(),
backgroundTargetOverlayOpacities: z.record(z.string(), z.number().min(0).max(100)).optional(),
backgroundTargetBlurRadii: z.record(z.string(), z.number().min(0).max(24)).optional(),
backgroundTargetPositions: z.record(z.string(), z.enum(appearanceBackgroundPositions)).optional(),
backgroundTargetPresentationModes: z.record(z.string(), z.enum(appearanceBackgroundPresentationModes)).optional(),
heroImagePositionX: z.number().min(0).max(100).optional(),
heroImagePositionY: z.number().min(0).max(100).optional(),
heroOverlayOpacity: z.number().min(0).max(100).optional(),
heroTitlePositionX: z.number().min(-50).max(50).optional(),
heroTitlePositionY: z.number().min(-50).max(50).optional(),
heroTitleWidthPercent: z.number().min(30).max(100).optional(),
heroTaglinePositionX: z.number().min(-50).max(50).optional(),
heroTaglinePositionY: z.number().min(-50).max(50).optional(),
heroTaglineWidthPercent: z.number().min(30).max(100).optional(),
decorativeElements: z.array(z.object({
id: z.string().trim().min(1).max(80),
imageUrl: z.string().trim().min(1),
scope: z.enum(appearanceDecorationScopes),
layer: z.enum(appearanceDecorationLayers),
widthPercent: z.number().min(6).max(38),
topPercent: z.number().min(0).max(100),
leftPercent: z.number().min(0).max(100),
opacity: z.number().min(4).max(28),
blurRadius: z.number().min(0).max(12),
cardAnchor: z.enum(appearanceCardDecorationAnchors),
cardInsetPercent: z.number().min(0).max(20),
})).optional(),
cardStyle: z.enum(appearanceCardStyles),
tableStyle: z.enum(appearanceTableStyles),
sidebarStyle: z.enum(appearanceSidebarStyles),
buttonStyle: z.enum(appearanceButtonStyles),
inputStyle: z.enum(appearanceInputStyles),
});
async function getUserOperatingTourSettings(): Promise<UserOperatingTourSettings> {
const raw = await db.getPortalSetting(USER_OPERATING_TOUR_SETTING_KEY);
if (!raw) {
return defaultUserOperatingTourSettings;
}
try {
return userOperatingTourSettingsSchema.parse(JSON.parse(raw));
} catch {
return defaultUserOperatingTourSettings;
}
}
function buildComplianceItem(
id: string,
label: string,
status: ComplianceStatus,
summary: string,
detail?: string,
) {
return { id, label, status, summary, detail };
}
const complianceSupplierSchema = z.object({
id: z.string().trim().min(1).max(80),
supplierName: z.string().trim().min(1).max(200),
service: z.string().trim().min(1).max(200),
location: z.string().trim().min(1).max(120),
criticality: z.enum(["critique", "elevee", "moyenne"]),
dpaStatus: z.enum(["signed", "pending", "renew", "expired", "missing"]),
reviewDate: z.string().trim().optional().or(z.literal("")),
owner: z.string().trim().min(1).max(160),
notes: z.string().trim().max(2000).optional().or(z.literal("")),
});
type ComplianceSupplier = z.infer<typeof complianceSupplierSchema>;
const complianceDpaSchema = z.object({
id: z.string().trim().min(1).max(80),
supplierId: z.string().trim().optional().or(z.literal("")),
supplierName: z.string().trim().min(1).max(200),
status: z.enum(["signed", "pending", "renew", "expired"]),
signedAt: z.string().trim().optional().or(z.literal("")),
expiresAt: z.string().trim().optional().or(z.literal("")),
reviewDueAt: z.string().trim().optional().or(z.literal("")),
owner: z.string().trim().min(1).max(160),
notes: z.string().trim().max(2000).optional().or(z.literal("")),
});
type ComplianceDpa = z.infer<typeof complianceDpaSchema>;
const complianceBackupRecordSchema = z.object({
id: z.string().trim().min(1).max(80),
scope: z.enum(["database", "documents", "system", "global"]),
environment: z.enum(["production", "preproduction", "staging", "local", "external"]).optional().catch("production"),
backupType: z.enum(["scheduled", "manual", "snapshot", "replication", "external"]).optional().catch("scheduled"),
lastSuccessAt: z.string().trim().optional().or(z.literal("")),
lastFailureAt: z.string().trim().optional().or(z.literal("")),
monthlySuccessRate: z.number().min(0).max(100),
volumeLabel: z.string().trim().max(120).optional().or(z.literal("")),
retentionLabel: z.string().trim().max(120).optional().or(z.literal("")),
proofLabel: z.string().trim().max(160).optional().or(z.literal("")),
proofUrl: z.string().trim().max(1000).optional().or(z.literal("")),
notes: z.string().trim().max(2000).optional().or(z.literal("")),
});
type ComplianceBackupRecord = z.infer<typeof complianceBackupRecordSchema>;
const complianceRestoreTestSchema = z.object({
id: z.string().trim().min(1).max(80),
testedAt: z.string().trim().optional().or(z.literal("")),
environment: z.enum(["production", "preproduction", "staging", "local", "external"]).optional().catch("preproduction"),
scopeLabel: z.string().trim().max(160).optional().or(z.literal("")),
result: z.enum(["success", "warning", "failed"]),
responsible: z.string().trim().max(160).optional().or(z.literal("")),
observations: z.string().trim().max(2000).optional().or(z.literal("")),
proofLabel: z.string().trim().max(160).optional().or(z.literal("")),
nextPlannedAt: z.string().trim().optional().or(z.literal("")),
});
type ComplianceRestoreTest = z.infer<typeof complianceRestoreTestSchema>;
const complianceRetentionReportSchema = z.object({
id: z.string().trim().min(1).max(80),
generatedAt: z.string().trim().optional().or(z.literal("")),
periodLabel: z.string().trim().max(120).optional().or(z.literal("")),
deletedAccounts: z.number().int().min(0),
pendingPurge: z.number().int().min(0),
anonymizedRecords: z.number().int().min(0),
executedPurges: z.number().int().min(0),
legalHoldCount: z.number().int().min(0),
incidentsCount: z.number().int().min(0),
notes: z.string().trim().max(2000).optional().or(z.literal("")),
});
type ComplianceRetentionReport = z.infer<typeof complianceRetentionReportSchema>;
const complianceEvidenceSchema = z.object({
id: z.string().trim().min(1).max(80),
category: z.enum([
"security_policy",
"processing_register",
"dpa",
"audit_report",
"backup_report",
"retention_report",
"restore_test",
"incident_report",
"other",
]),
title: z.string().trim().min(1).max(200),
reference: z.string().trim().max(120).optional().or(z.literal("")),
url: z.string().trim().optional().or(z.literal("")),
description: z.string().trim().max(2000).optional().or(z.literal("")),
updatedAt: z.string().trim().min(1),
});
type ComplianceEvidence = z.infer<typeof complianceEvidenceSchema>;
const complianceIsoDeliverableSectionSchema = z.object({
id: z.string().trim().min(1).max(80),
title: z.string().trim().min(1).max(200),
content: z.string().trim().max(12000).optional().or(z.literal("")),
guidance: z.string().trim().max(1200).optional().or(z.literal("")),
});
type ComplianceIsoDeliverableSection = z.infer<typeof complianceIsoDeliverableSectionSchema>;
const complianceIsoDeliverableSchema = z.object({
id: z.string().trim().min(1).max(80),
reference: z.string().trim().min(1).max(80),
title: z.string().trim().min(1).max(240),
domain: z.enum(["gouvernance", "acces", "incidents", "sauvegardes", "fournisseurs", "risques", "continuite", "rgpd", "audit"]),
status: z.enum(["a_rediger", "en_cours", "a_relire", "a_valider", "valide", "a_reviser"]),
owner: z.string().trim().max(160).optional().or(z.literal("")),
approverName: z.string().trim().max(160).optional().or(z.literal("")),
approverEmail: z.string().trim().max(320).optional().or(z.literal("")),
summary: z.string().trim().max(2000).optional().or(z.literal("")),
notes: z.string().trim().max(6000).optional().or(z.literal("")),
lastReviewedAt: z.string().trim().optional().or(z.literal("")),
validatedAt: z.string().trim().optional().or(z.literal("")),
revisionDueAt: z.string().trim().optional().or(z.literal("")),
sections: z.array(complianceIsoDeliverableSectionSchema).max(20),
});
type ComplianceIsoDeliverable = z.infer<typeof complianceIsoDeliverableSchema>;
type ComplianceOperationsPayload = {
suppliers: ComplianceSupplier[];
dpas: ComplianceDpa[];
backupRecords: ComplianceBackupRecord[];
restoreTests: ComplianceRestoreTest[];
retentionReports: ComplianceRetentionReport[];
evidenceCenter: ComplianceEvidence[];
isoDeliverables: ComplianceIsoDeliverable[];
};
type ComplianceBootstrapSummary = {
suppliersAdded: number;
dpasAdded: number;
evidenceAdded: number;
notes: string[];
};
type StatsAutomationSettings = {
enabled: boolean;
frequencies: Record<Exclude<AnalyticsPeriod, "7d"> | "weekly", boolean>;
recipientEmails: string[];
lastGeneratedCycleKeys: Partial<Record<"weekly" | Exclude<AnalyticsPeriod, "7d">, string>>;
};
type StatsReportHistoryEntry = {
id: string;
period: "weekly" | Exclude<AnalyticsPeriod, "7d">;
periodLabel: string;
startDate: string;
endDate: string;
generatedAt: string;
pdfUrl: string;
pdfName: string;
excelUrl: string;
excelName: string;
cycleKey: string;
generatedBy: "scheduler" | "manual";
};
type AssociationMapSettings = {
publicStyleUrl: string;
adminStyleUrl: string;
styleUrl: string;
};
const salleAnalyticsCatalog = [
{ id: "bureau_11_17", name: "Bureau 1117 m²" },
{ id: "salle_toucan", name: "Salle TOUCAN" },
{ id: "salle_ibis", name: "Salle IBIS" },
{ id: "salle_pelican", name: "Salle PELICAN" },
{ id: "dojo", name: "DOJO" },
{ id: "hall_amphitheatre", name: "Hall-Amphithéâtre" },
{ id: "vestiaires", name: "Vestiaires" },
] as const;
type SalleAnalyticsRoomId = (typeof salleAnalyticsCatalog)[number]["id"];
function sanitizeStatsAutomationSettings(rawValue?: unknown): StatsAutomationSettings {
const source = rawValue && typeof rawValue === "object" ? rawValue as Record<string, unknown> : {};
const parseEmails = (value: unknown) => Array.isArray(value)
? value.map((entry) => normalizeEmail(String(entry || ""))).filter(Boolean)
: [];
return {
enabled: source.enabled !== false,
frequencies: {
weekly: source.frequencies && typeof source.frequencies === "object" ? Boolean((source.frequencies as Record<string, unknown>).weekly) : true,
month: source.frequencies && typeof source.frequencies === "object" ? Boolean((source.frequencies as Record<string, unknown>).month) : true,
quarter: source.frequencies && typeof source.frequencies === "object" ? Boolean((source.frequencies as Record<string, unknown>).quarter) : true,
semester: source.frequencies && typeof source.frequencies === "object" ? Boolean((source.frequencies as Record<string, unknown>).semester) : true,
year: source.frequencies && typeof source.frequencies === "object" ? Boolean((source.frequencies as Record<string, unknown>).year) : true,
},
recipientEmails: parseEmails(source.recipientEmails),
lastGeneratedCycleKeys:
source.lastGeneratedCycleKeys && typeof source.lastGeneratedCycleKeys === "object"
? Object.fromEntries(
Object.entries(source.lastGeneratedCycleKeys as Record<string, unknown>)
.filter(([, value]) => typeof value === "string" && value.trim().length > 0)
.map(([key, value]) => [key, String(value)])
)
: {},
};
}
function sanitizeStatsReportHistory(rawValue?: unknown): StatsReportHistoryEntry[] {
if (!Array.isArray(rawValue)) return [];
return rawValue
.filter((entry) => entry && typeof entry === "object")
.map((entry) => entry as Record<string, unknown>)
.filter((entry) =>
typeof entry.id === "string"
&& typeof entry.period === "string"
&& typeof entry.periodLabel === "string"
&& typeof entry.generatedAt === "string"
&& typeof entry.pdfUrl === "string"
&& typeof entry.excelUrl === "string"
)
.map((entry) => ({
id: String(entry.id),
period: entry.period as StatsReportHistoryEntry["period"],
periodLabel: String(entry.periodLabel),
startDate: String(entry.startDate || ""),
endDate: String(entry.endDate || ""),
generatedAt: String(entry.generatedAt),
pdfUrl: String(entry.pdfUrl),
pdfName: String(entry.pdfName || "rapport.pdf"),
excelUrl: String(entry.excelUrl),
excelName: String(entry.excelName || "rapport.xlsx"),
cycleKey: String(entry.cycleKey || ""),
generatedBy: entry.generatedBy === "manual" ? "manual" : "scheduler",
}));
}
function sanitizeAssociationMapSettings(rawValue?: unknown): AssociationMapSettings {
const source = rawValue && typeof rawValue === "object" ? rawValue as Record<string, unknown> : {};
const fallbackStyleUrl = typeof source.styleUrl === "string" && source.styleUrl.trim().length > 0
? source.styleUrl.trim()
: "https://www.portail-association973.com/map-styles/openmaptiles-positron/style.json";
const publicStyleUrl = typeof source.publicStyleUrl === "string" && source.publicStyleUrl.trim().length > 0
? source.publicStyleUrl.trim()
: fallbackStyleUrl;
const adminStyleUrl = typeof source.adminStyleUrl === "string" && source.adminStyleUrl.trim().length > 0
? source.adminStyleUrl.trim()
: "https://www.portail-association973.com/map-styles/openmaptiles-positron-admin/style.json";
return {
publicStyleUrl,
adminStyleUrl,
styleUrl: publicStyleUrl,
};
}
function parseStoredList<T>(rawValue: string | null, schema: z.ZodType<T>) {
if (!rawValue) return [] as T[];
try {
const parsed = JSON.parse(rawValue);
if (!Array.isArray(parsed)) return [] as T[];
return parsed
.map((entry) => {
try {
return schema.parse(entry);
} catch {
return null;
}
})
.filter(Boolean) as T[];
} catch {
return [] as T[];
}
}
function parseComplianceDate(input?: string | null) {
if (!input) return null;
const parsed = new Date(input);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function formatRecencyStatus(date: Date | null, warningAfterDays: number) {
if (!date) {
return { status: "missing" as const, ageDays: null };
}
const ageMs = Date.now() - date.getTime();
const ageDays = Math.max(0, Math.floor(ageMs / (1000 * 60 * 60 * 24)));
if (ageDays > warningAfterDays) {
return { status: "warning" as const, ageDays };
}
return { status: "ok" as const, ageDays };
}
async function getComplianceOperations(): Promise<ComplianceOperationsPayload> {
const [
suppliersRaw,
dpasRaw,
backupsRaw,
retentionHistoryRaw,
evidenceRaw,
retentionReportRaw,
isoDeliverablesRaw,
] = await Promise.all([
db.getPortalSetting(COMPLIANCE_SUPPLIERS_SETTING_KEY),
db.getPortalSetting(COMPLIANCE_DPA_SETTING_KEY),
db.getPortalSetting(COMPLIANCE_BACKUP_SETTING_KEY),
db.getPortalSetting(COMPLIANCE_RETENTION_HISTORY_SETTING_KEY),
db.getPortalSetting(COMPLIANCE_EVIDENCE_SETTING_KEY),
db.getPortalSetting("retention.lastReport"),
db.getPortalSetting(COMPLIANCE_ISO_DELIVERABLES_SETTING_KEY),
]);
const backupsPayload = backupsRaw ? (() => {
try {
const parsed = JSON.parse(backupsRaw) as Record<string, unknown>;
return {
backupRecords: Array.isArray(parsed.backupRecords)
? parsed.backupRecords.map((entry) => {
try { return complianceBackupRecordSchema.parse(entry); } catch { return null; }
}).filter(Boolean) as ComplianceBackupRecord[]
: [],
restoreTests: Array.isArray(parsed.restoreTests)
? parsed.restoreTests.map((entry) => {
try { return complianceRestoreTestSchema.parse(entry); } catch { return null; }
}).filter(Boolean) as ComplianceRestoreTest[]
: [],
};
} catch {
return { backupRecords: [], restoreTests: [] };
}
})() : { backupRecords: [], restoreTests: [] };
const retentionReports = parseStoredList(retentionHistoryRaw, complianceRetentionReportSchema);
if (retentionReportRaw) {
try {
const latest = JSON.parse(retentionReportRaw) as Record<string, unknown>;
if (typeof latest.generatedAt === "string" && !retentionReports.some((entry) => entry.generatedAt === latest.generatedAt)) {
retentionReports.unshift(complianceRetentionReportSchema.parse({
id: `auto-${latest.generatedAt}`,
generatedAt: String(latest.generatedAt),
periodLabel: "Rapport automatique quotidien",
deletedAccounts: Number(latest.purgedUsers || 0),
pendingPurge: Number(latest.pendingDeletionCount || 0),
anonymizedRecords: Number(latest.purgedUsers || 0),
executedPurges: Number(latest.purgedUsers || 0),
legalHoldCount: 0,
incidentsCount: 0,
notes: "",
}));
}
} catch {
// ignore malformed latest report
}
}
return {
suppliers: parseStoredList(suppliersRaw, complianceSupplierSchema),
dpas: parseStoredList(dpasRaw, complianceDpaSchema),
backupRecords: backupsPayload.backupRecords,
restoreTests: backupsPayload.restoreTests,
retentionReports,
evidenceCenter: parseStoredList(evidenceRaw, complianceEvidenceSchema),
isoDeliverables: mergeComplianceEntriesById(
parseStoredList(isoDeliverablesRaw, complianceIsoDeliverableSchema),
buildDefaultIsoDeliverables(),
).entries.sort((a, b) => a.reference.localeCompare(b.reference, "fr")),
};
}
async function saveComplianceBackups(payload: {
backupRecords: ComplianceBackupRecord[];
restoreTests: ComplianceRestoreTest[];
}) {
await db.setPortalSetting(
COMPLIANCE_BACKUP_SETTING_KEY,
JSON.stringify(payload),
"Supervision des sauvegardes, restaurations et preuves associées"
);
}
function buildDefaultIsoDeliverables(): ComplianceIsoDeliverable[] {
return [
{
id: "iso-pol-001",
reference: "POL-001",
title: "Politique de securite de l'information",
domain: "gouvernance",
status: "a_rediger",
owner: "",
approverName: "",
approverEmail: "",
summary: "Document cadre du SMSI, perimetre, principes de protection et gouvernance.",
notes: "",
lastReviewedAt: "",
validatedAt: "",
revisionDueAt: "",
sections: [
{ id: "scope", title: "Perimetre", content: "", guidance: "Definir les activites, services et environnements couverts par le SMSI." },
{ id: "principles", title: "Principes directeurs", content: "", guidance: "Preciser confidentialite, integrite, disponibilite et tracabilite." },
{ id: "governance", title: "Gouvernance et responsabilites", content: "", guidance: "Nommer les roles de direction, RSSI, DPO et exploitation." },
],
},
{
id: "iso-sec-001",
reference: "SEC-001",
title: "Gestion des acces et habilitations",
domain: "acces",
status: "en_cours",
owner: "",
approverName: "",
approverEmail: "",
summary: "Controle des acces, MFA, moindre privilege et revues d'habilitations.",
notes: "",
lastReviewedAt: "",
validatedAt: "",
revisionDueAt: "",
sections: [
{ id: "roles", title: "Roles et droits", content: "", guidance: "Documenter les profils internes et les niveaux d'acces." },
{ id: "mfa", title: "Authentification forte", content: "", guidance: "Definir les roles soumis a MFA obligatoire et les exceptions." },
{ id: "review", title: "Revue periodique", content: "", guidance: "Prevoir la frequence de revue et le circuit de validation." },
],
},
{
id: "iso-sec-002",
reference: "SEC-002",
title: "Sauvegardes et restauration",
domain: "sauvegardes",
status: "en_cours",
owner: "",
approverName: "",
approverEmail: "",
summary: "Strategie de sauvegarde, retention, tests de restauration et preuves d'execution.",
notes: "",
lastReviewedAt: "",
validatedAt: "",
revisionDueAt: "",
sections: [
{ id: "strategy", title: "Strategie de sauvegarde", content: "", guidance: "Preciser la regle 3-2-1, les frequences et les supports." },
{ id: "restore", title: "Procedure de restauration", content: "", guidance: "Decrire la sequence de restauration et les responsables." },
{ id: "evidence", title: "Preuves et revues", content: "", guidance: "Lister les rapports, journaux et controles mensuels attendus." },
],
},
{
id: "iso-sec-003",
reference: "SEC-003",
title: "Gestion des incidents de securite",
domain: "incidents",
status: "en_cours",
owner: "",
approverName: "",
approverEmail: "",
summary: "Detection, qualification, confinement, remediations et retours d'experience.",
notes: "",
lastReviewedAt: "",
validatedAt: "",
revisionDueAt: "",
sections: [
{ id: "classification", title: "Classification des incidents", content: "", guidance: "Definir les niveaux de gravite et les SLA internes." },
{ id: "workflow", title: "Workflow de traitement", content: "", guidance: "Decrire detection, confinement, investigation, correction et cloture." },
{ id: "rgpd", title: "Lien avec les violations RGPD", content: "", guidance: "Documenter l'escalade CNIL et l'information des personnes si necessaire." },
],
},
{
id: "iso-sec-005",
reference: "SEC-005",
title: "Gestion des fournisseurs et sous-traitants",
domain: "fournisseurs",
status: "en_cours",
owner: "",
approverName: "",
approverEmail: "",
summary: "Registre fournisseurs, DPA, criticite, revues annuelles et preuves contractuelles.",
notes: "",
lastReviewedAt: "",
validatedAt: "",
revisionDueAt: "",
sections: [
{ id: "inventory", title: "Inventaire des fournisseurs", content: "", guidance: "Identifier hebergement, SMTP, analytics, sauvegardes et services tiers." },
{ id: "contracts", title: "Contrats et DPA", content: "", guidance: "Tracer les statuts signes, a renouveler et a recuperer." },
{ id: "annual-review", title: "Revue annuelle", content: "", guidance: "Definir le responsable, la date et les criteres de revue." },
],
},
{
id: "iso-risk-001",
reference: "ISO27001-RISK-001",
title: "Analyse des risques",
domain: "risques",
status: "a_rediger",
owner: "",
approverName: "",
approverEmail: "",
summary: "Identification des actifs, menaces, vulnerabilites, impacts et plans de traitement.",
notes: "",
lastReviewedAt: "",
validatedAt: "",
revisionDueAt: "",
sections: [
{ id: "assets", title: "Actifs et perimetre", content: "", guidance: "Lister les actifs informationnels et les proprietaires." },
{ id: "risks", title: "Evaluation des risques", content: "", guidance: "Qualifier probabilite, impact et niveau de risque." },
{ id: "treatment", title: "Plan de traitement", content: "", guidance: "Associer les mesures, responsables et dates cibles." },
],
},
{
id: "iso-bcp-001",
reference: "SEC-004",
title: "Continuite et reprise d'activite",
domain: "continuite",
status: "a_rediger",
owner: "",
approverName: "",
approverEmail: "",
summary: "PCA/PRA, RTO/RPO, scenarios majeurs et organisation de crise.",
notes: "",
lastReviewedAt: "",
validatedAt: "",
revisionDueAt: "",
sections: [
{ id: "scenarios", title: "Scenarios de rupture", content: "", guidance: "Panne serveur, corruption, cyberattaque, indisponibilite hebergeur." },
{ id: "targets", title: "Objectifs de reprise", content: "", guidance: "Fixer RTO, RPO et priorites de remise en service." },
{ id: "exercise", title: "Tests et exercices", content: "", guidance: "Prevoir la frequence des exercices et les comptes-rendus." },
],
},
{
id: "iso-audit-001",
reference: "ISO27001-AUDIT-001",
title: "Preparation audit et declaration d'applicabilite",
domain: "audit",
status: "a_rediger",
owner: "",
approverName: "",
approverEmail: "",
summary: "Centralisation des preuves, ecarts, controles applicables et readiness audit.",
notes: "",
lastReviewedAt: "",
validatedAt: "",
revisionDueAt: "",
sections: [
{ id: "soa", title: "Declaration d'applicabilite", content: "", guidance: "Documenter les controles applicables, exclus et justifications." },
{ id: "evidence", title: "Corpus de preuves", content: "", guidance: "Lister les preuves documentaires et techniques a fournir." },
{ id: "gaps", title: "Ecarts residuels", content: "", guidance: "Consolider les points restants avant audit." },
],
},
];
}
function mergeComplianceEntriesById<T extends { id: string }>(existing: T[], additions: T[]) {
const knownIds = new Set(existing.map((entry) => entry.id));
const merged = [...existing];
let addedCount = 0;
for (const entry of additions) {
if (knownIds.has(entry.id)) continue;
merged.push(entry);
knownIds.add(entry.id);
addedCount += 1;
}
return { entries: merged, addedCount };
}
function todayIsoDate() {
return new Date().toISOString().slice(0, 10);
}
function plusDaysIsoDate(days: number) {
const date = new Date();
date.setDate(date.getDate() + days);
return date.toISOString().slice(0, 10);
}
function toProviderLabel(input: string) {
const normalized = String(input || "").trim().toLowerCase();
if (normalized === "ovh") return "OVHcloud";
if (normalized === "gmail") return "Google Workspace / Gmail";
if (normalized === "outlook") return "Microsoft 365 / Outlook";
return normalized ? normalized : "Fournisseur à confirmer";
}
function toProviderLocation(input: string) {
const normalized = String(input || "").trim().toLowerCase();
if (normalized === "ovh") return "UE";
if (normalized === "gmail" || normalized === "outlook") {
return "À confirmer contractuellement";
}
return "À confirmer";
}
async function getDocUpdatedAt(relativePath: string) {
try {
const absolutePath = path.resolve(process.cwd(), relativePath);
const stat = await fs.stat(absolutePath);
return stat.mtime.toISOString();
} catch {
return new Date().toISOString();
}
}
async function buildComplianceBootstrapPayload(existing: ComplianceOperationsPayload): Promise<{
suppliers: ComplianceSupplier[];
dpas: ComplianceDpa[];
evidenceCenter: ComplianceEvidence[];
notes: string[];
}> {
const [
mailSettings,
oauthStatus,
helloAssoSettings,
operationalTourHistory,
retentionReportRaw,
sec001UpdatedAt,
sec002UpdatedAt,
sec002aUpdatedAt,
sec002bUpdatedAt,
sec003UpdatedAt,
sec005UpdatedAt,
rgpd002UpdatedAt,
rgpd005UpdatedAt,
rgpd006UpdatedAt,
rgpd007UpdatedAt,
rgpd008UpdatedAt,
] = await Promise.all([
getAdminMailSettings(),
Promise.resolve(getOAuthProviderStatus()),
getHelloAssoSettings(),
getOperationalTourHistory(),
db.getPortalSetting("retention.lastReport"),
getDocUpdatedAt("docs/compliance/SEC-001-gestion-des-acces.md"),
getDocUpdatedAt("docs/compliance/SEC-002-gestion-des-sauvegardes-et-restauration.md"),
getDocUpdatedAt("docs/compliance/SEC-002A-preuve-execution-sauvegarde.md"),
getDocUpdatedAt("docs/compliance/SEC-002B-test-restauration-historise.md"),
getDocUpdatedAt("docs/compliance/SEC-003-gestion-des-incidents.md"),
getDocUpdatedAt("docs/compliance/SEC-005-registre-fournisseurs-et-sous-traitants.md"),
getDocUpdatedAt("docs/compliance/RGPD-002-registre-des-traitements.md"),
getDocUpdatedAt("docs/compliance/RGPD-005-accord-de-traitement-des-donnees-dpa.md"),
getDocUpdatedAt("docs/compliance/RGPD-006-politique-de-retention-des-donnees.md"),
getDocUpdatedAt("docs/compliance/RGPD-007-procedure-suppression-et-purge-planifiee.md"),
getDocUpdatedAt("docs/compliance/RGPD-008-rapport-mensuel-dpo-exploitation.md"),
]);
const notes: string[] = [];
const suppliers: ComplianceSupplier[] = [];
const dpas: ComplianceDpa[] = [];
const evidenceCenter: ComplianceEvidence[] = [];
const reviewDate = todayIsoDate();
suppliers.push({
id: "ovh-hosting",
supplierName: "OVHcloud",
service: "Hébergement principal du portail, DNS et exposition web",
location: "UE",
criticality: "critique",
dpaStatus: "pending",
reviewDate,
owner: "RSSI / exploitation CCDS",
notes: "Prestataire dhébergement principal déclaré pour la production et la préproduction du portail.",
});
dpas.push({
id: "dpa-ovh-hosting",
supplierId: "ovh-hosting",
supplierName: "OVHcloud",
status: "pending",
signedAt: "",
expiresAt: "",
reviewDueAt: plusDaysIsoDate(365),
owner: "DPO / RSSI",
notes: "DPA et clauses de sécurité OVH à centraliser dans le portail.",
});
if (mailSettings.hasSavedConfig || mailSettings.ready) {
const supplierId = `smtp-${mailSettings.smtpProvider}`;
const providerLabel = toProviderLabel(mailSettings.smtpProvider);
const providerLocation = toProviderLocation(mailSettings.smtpProvider);
const mailStatus = mailSettings.ready ? "pending" : "missing";
suppliers.push({
id: supplierId,
supplierName: providerLabel,
service: `Messagerie transactionnelle / SMTP (${mailSettings.smtpProvider === "custom" ? (mailSettings.smtpHost || "hôte personnalisé") : mailSettings.smtpProvider})`,
location: providerLocation,
criticality: "elevee",
dpaStatus: mailStatus,
reviewDate,
owner: "Administration du portail",
notes: mailSettings.ready
? `Configuration SMTP détectée (${mailSettings.source}). Contrat DPA et revue annuelle à verser.`
: "Configuration SMTP partielle détectée. Finaliser les paramètres et centraliser le contrat avant validation.",
});
dpas.push({
id: `dpa-${supplierId}`,
supplierId,
supplierName: providerLabel,
status: mailSettings.ready ? "pending" : "expired",
signedAt: "",
expiresAt: "",
reviewDueAt: plusDaysIsoDate(180),
owner: "DPO / administration du portail",
notes: mailSettings.ready
? "Ajouter le DPA réel du fournisseur de messagerie et sa date de révision."
: "Le fournisseur SMTP est détecté mais reste incomplet ou non validé contractuellement.",
});
} else {
notes.push("Aucun fournisseur SMTP confirmé na été auto-détecté dans la configuration active.");
}
if (oauthStatus.google) {
suppliers.push({
id: "google-oauth",
supplierName: "Google",
service: "Authentification sociale Google",
location: "À confirmer contractuellement",
criticality: "elevee",
dpaStatus: "pending",
reviewDate,
owner: "RSSI / administration du portail",
notes: "Client OAuth Google configuré dans lapplication.",
});
dpas.push({
id: "dpa-google-oauth",
supplierId: "google-oauth",
supplierName: "Google",
status: "pending",
signedAt: "",
expiresAt: "",
reviewDueAt: plusDaysIsoDate(180),
owner: "DPO / RSSI",
notes: "Vérifier le cadre contractuel et les clauses RGPD liées à la connexion Google.",
});
}
if (oauthStatus.facebook) {
suppliers.push({
id: "meta-oauth",
supplierName: "Meta / Facebook",
service: "Authentification sociale Facebook",
location: "À confirmer contractuellement",
criticality: "moyenne",
dpaStatus: "pending",
reviewDate,
owner: "RSSI / administration du portail",
notes: "Client OAuth Facebook configuré dans lapplication.",
});
dpas.push({
id: "dpa-meta-oauth",
supplierId: "meta-oauth",
supplierName: "Meta / Facebook",
status: "pending",
signedAt: "",
expiresAt: "",
reviewDueAt: plusDaysIsoDate(180),
owner: "DPO / RSSI",
notes: "Vérifier le cadre contractuel et la localisation effective des traitements.",
});
}
if (helloAssoSettings.enabled && helloAssoSettings.clientId) {
suppliers.push({
id: "helloasso-api",
supplierName: "HelloAsso",
service: "API dinscription et denrichissement des associations",
location: "À confirmer contractuellement",
criticality: "elevee",
dpaStatus: "pending",
reviewDate,
owner: "Administration associations / DPO",
notes: "Synchronisation HelloAsso activable depuis le portail. Validation manuelle des créations maintenue.",
});
dpas.push({
id: "dpa-helloasso-api",
supplierId: "helloasso-api",
supplierName: "HelloAsso",
status: "pending",
signedAt: "",
expiresAt: "",
reviewDueAt: plusDaysIsoDate(180),
owner: "DPO / administration associations",
notes: "Ajouter les engagements RGPD et le support contractuel de lintégration HelloAsso.",
});
}
evidenceCenter.push(
{
id: "evidence-sec-001",
category: "security_policy",
title: "Politique MFA et gestion des accès",
reference: "SEC-001",
url: "",
description: "Document interne de gestion des accès et politique MFA obligatoire par rôle.",
updatedAt: sec001UpdatedAt,
},
{
id: "evidence-sec-002",
category: "backup_report",
title: "Politique de sauvegardes et restauration",
reference: "SEC-002",
url: "",
description: "Procédure documentaire 3-2-1, RPO/RTO et cadre de restauration.",
updatedAt: sec002UpdatedAt,
},
{
id: "evidence-sec-002a",
category: "backup_report",
title: "Fiche de preuve d'exécution des sauvegardes",
reference: "SEC-002A",
url: "",
description: "Modèle opérationnel pour rattacher date, statut, volume, rétention et source de preuve à chaque sauvegarde utile.",
updatedAt: sec002aUpdatedAt,
},
{
id: "evidence-sec-002b",
category: "restore_test",
title: "Fiche de test de restauration historisé",
reference: "SEC-002B",
url: "",
description: "Modèle de trace pour démontrer qu'une restauration a réellement été testée, validée et commentée.",
updatedAt: sec002bUpdatedAt,
},
{
id: "evidence-sec-003",
category: "incident_report",
title: "Procédure de gestion des incidents de sécurité",
reference: "SEC-003",
url: "",
description: "Classification, confinement, investigation, notification RGPD et revue post-incident.",
updatedAt: sec003UpdatedAt,
},
{
id: "evidence-sec-005",
category: "dpa",
title: "Registre fournisseurs et sous-traitants",
reference: "SEC-005",
url: "",
description: "Référentiel documentaire de suivi des prestataires, criticités et révisions annuelles.",
updatedAt: sec005UpdatedAt,
},
{
id: "evidence-rgpd-002",
category: "processing_register",
title: "Registre des traitements",
reference: "RGPD-002",
url: "",
description: "Base documentaire du registre des traitements du portail.",
updatedAt: rgpd002UpdatedAt,
},
{
id: "evidence-rgpd-005",
category: "dpa",
title: "Modèle daccord de traitement des données",
reference: "RGPD-005",
url: "",
description: "Gabarit DPA à rattacher aux fournisseurs réellement utilisés.",
updatedAt: rgpd005UpdatedAt,
},
{
id: "evidence-rgpd-006",
category: "retention_report",
title: "Politique de rétention des données",
reference: "RGPD-006",
url: "",
description: "Politique de conservation, archivage et suppression des données personnelles.",
updatedAt: rgpd006UpdatedAt,
},
{
id: "evidence-rgpd-007",
category: "retention_report",
title: "Procédure de suppression et purge planifiée",
reference: "RGPD-007",
url: "",
description: "Cycle de suppression différée, retention 30 jours, legal hold et audit de purge.",
updatedAt: rgpd007UpdatedAt,
},
{
id: "evidence-rgpd-008",
category: "audit_report",
title: "Rapport mensuel DPO et exploitation",
reference: "RGPD-008",
url: "",
description: "Cadre mensuel pour suivre sauvegardes, restaurations, incidents, rétention, écarts et actions de conformité.",
updatedAt: rgpd008UpdatedAt,
},
{
id: "evidence-public-privacy",
category: "security_policy",
title: "Politique de confidentialité publique",
reference: "Portail public",
url: "/confidentialite?returnTo=%2Fadmin%3Ftab%3Dconformite",
description: "Version publique exposée dans le portail.",
updatedAt: new Date().toISOString(),
},
{
id: "evidence-public-mentions",
category: "security_policy",
title: "Mentions légales publiques",
reference: "Portail public",
url: "/mentions-legales?returnTo=%2Fadmin%3Ftab%3Dconformite",
description: "Version publique exposée dans le portail.",
updatedAt: new Date().toISOString(),
},
{
id: "evidence-public-cookies",
category: "security_policy",
title: "Politique cookies publique",
reference: "Portail public",
url: "/cookies?returnTo=%2Fadmin%3Ftab%3Dconformite",
description: "Version publique exposée dans le portail.",
updatedAt: new Date().toISOString(),
},
{
id: "evidence-public-cgu",
category: "security_policy",
title: "CGU publiques",
reference: "Portail public",
url: "/cgu?returnTo=%2Fadmin%3Ftab%3Dconformite",
description: "Version publique exposée dans le portail.",
updatedAt: new Date().toISOString(),
},
);
const latestProductionTour = operationalTourHistory.find((entry) => entry.environment === "production");
const latestSecondaryTour = operationalTourHistory.find((entry) => entry.environment === "secondary");
if (latestProductionTour) {
evidenceCenter.push({
id: "evidence-tour-production",
category: "audit_report",
title: "Dernier tour opérationnel production",
reference: "Tour opérationnel",
url: "/admin?tab=dashboard",
description: `${latestProductionTour.summary.ok} contrôles OK, ${latestProductionTour.summary.warning} avertissement(s), ${latestProductionTour.summary.error} erreur(s).`,
updatedAt: latestProductionTour.finishedAt,
});
} else {
notes.push("Aucun tour opérationnel de production na encore été trouvé pour larchivage automatique.");
}
if (latestSecondaryTour) {
evidenceCenter.push({
id: "evidence-tour-secondary",
category: "audit_report",
title: "Dernier tour opérationnel préproduction",
reference: "Tour opérationnel",
url: "/admin?tab=dashboard",
description: `${latestSecondaryTour.summary.ok} contrôles OK, ${latestSecondaryTour.summary.warning} avertissement(s), ${latestSecondaryTour.summary.error} erreur(s).`,
updatedAt: latestSecondaryTour.finishedAt,
});
} else {
notes.push("Aucun tour opérationnel de préproduction na encore été trouvé pour larchivage automatique.");
}
if (retentionReportRaw) {
try {
const latestRetention = JSON.parse(retentionReportRaw) as Record<string, unknown>;
const generatedAt = typeof latestRetention.generatedAt === "string" ? latestRetention.generatedAt : new Date().toISOString();
evidenceCenter.push({
id: "evidence-retention-latest",
category: "retention_report",
title: "Dernier rapport automatique de rétention",
reference: "Retention auto",
url: "",
description: `Purges du run: ${Number(latestRetention.purgedUsers || 0)} • comptes en attente: ${Number(latestRetention.pendingDeletionCount || 0)}.`,
updatedAt: generatedAt,
});
} catch {
notes.push("Le dernier rapport automatique de rétention existe mais na pas pu être interprété automatiquement.");
}
} else {
notes.push("Aucun rapport automatique de rétention na encore été détecté.");
}
if (existing.backupRecords.length === 0) {
notes.push("Les preuves dexécution des sauvegardes restent à saisir manuellement ou à brancher depuis lexploitation.");
}
if (existing.restoreTests.length === 0) {
notes.push("Aucun test de restauration historisé nest encore présent dans le tableau de pilotage.");
}
return { suppliers, dpas, evidenceCenter, notes };
}
const optionalEmailFieldSchema = z
.string()
.trim()
.email("Adresse email invalide")
.optional()
.or(z.literal(""));
type SalleBillingSettings = {
ribDocumentUrl: string;
ribDocumentName: string;
ribMimeType: string;
ribUploadedAt: string;
};
const internalDirectoryContactSchema = z.object({
name: z.string().trim().min(1).max(160),
role: z
.string()
.trim()
.max(200)
.optional()
.or(z.literal(""))
.transform((value) => {
const normalized = String(value || "").trim();
return normalized || "Fonction à compléter";
}),
email: z.string().trim().email().optional().or(z.literal("")).transform((value) => value || undefined),
phone: z.string().trim().max(60).optional().or(z.literal("")).transform((value) => value || undefined),
mobile: z.string().trim().max(60).optional().or(z.literal("")).transform((value) => value || undefined),
extension: z.string().trim().max(20).optional().or(z.literal("")).transform((value) => value || undefined),
});
const internalDirectoryServiceSchema = z.object({
id: z.string().trim().min(1).max(120),
title: z.string().trim().min(1).max(200),
description: z.string().trim().min(1).max(500),
contacts: z.array(internalDirectoryContactSchema).max(100),
});
const internalDirectoryDivisionSchema = z.object({
id: z.string().trim().min(1).max(120),
title: z.string().trim().min(1).max(200),
description: z.string().trim().min(1).max(500),
accentClassName: z.string().trim().min(1).max(200),
services: z.array(internalDirectoryServiceSchema).max(100),
});
// Admin procedure - only for admin users
const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.user.role !== 'admin' && ctx.user.role !== 'super_admin') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès réservé aux administrateurs' });
}
return next({ ctx });
});
// Super admin procedure
const superAdminProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.user.role !== 'super_admin') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès réservé aux super administrateurs' });
}
return next({ ctx });
});
function canAccessAccueilBackoffice(user: { role: string }) {
return user.role === "accueil" || user.role === "logistique_controle" || user.role === "admin" || user.role === "super_admin";
}
function canAccessTerrainWorkspace(user: { role: string }) {
return user.role === "service_terrain" || user.role === "super_admin";
}
function canReadInternalRequest(user: { role: string; canSignSalle?: boolean | null }) {
return canAccessAccueilBackoffice(user) || canSignSalle(user);
}
const accueilAdminProcedure = protectedProcedure.use(({ ctx, next }) => {
if (!canAccessAccueilBackoffice(ctx.user)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Accès réservé à laccueil, aux administrateurs et aux super administrateurs",
});
}
return next({ ctx });
});
const terrainProcedure = protectedProcedure.use(({ ctx, next }) => {
if (!canAccessTerrainWorkspace(ctx.user)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Accès réservé au service terrain désigné",
});
}
return next({ ctx });
});
const logisticsProcedure = protectedProcedure.use(({ ctx, next }) => {
assertLogisticsAccess(ctx.user);
return next({ ctx });
});
function canManageLogistics(user: { role: string; canManageLogistics?: boolean | null }) {
return user.role === "super_admin" || user.role === "logistique_controle" || Boolean(user.canManageLogistics);
}
function assertLogisticsAccess(user: { role: string; canManageLogistics?: boolean | null }) {
if (!canManageLogistics(user)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Accès réservé au service logistique et contrôle désigné par le super administrateur",
});
}
}
function canSignSalle(user: { role: string; canSignSalle?: boolean | null }) {
return user.role === "super_admin" || user.role === "directrice" || Boolean(user.canSignSalle);
}
const salleSignerProcedure = protectedProcedure.use(({ ctx, next }) => {
if (!canSignSalle(ctx.user)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Accès réservé à la Directrice ou à son délégataire de signature",
});
}
return next({ ctx });
});
const directriceProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.user.role !== "directrice" && ctx.user.role !== "super_admin") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Accès réservé à la Directrice",
});
}
return next({ ctx });
});
const salleReadProcedure = protectedProcedure.use(({ ctx, next }) => {
if (!canAccessAccueilBackoffice(ctx.user) && !canSignSalle(ctx.user)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Accès réservé aux profils autorisés à consulter le suivi des salles",
});
}
return next({ ctx });
});
const ACTIVE_MATERIAL_AVAILABILITY_STATUSES = new Set([
"soumise",
"en_cours_traitement",
"information_complementaire",
"validee",
]);
// Helper to log admin actions
async function logAdminAction(userId: number, action: string, entityType: string, entityId?: number, details?: any) {
try {
await db.createAuditLog({
userId,
action,
entityType,
entityId,
details: details ? JSON.stringify(details) : undefined,
ipAddress: typeof details?.ipAddress === "string" ? details.ipAddress : undefined,
});
} catch (e) {
console.error('Failed to log admin action:', e);
}
}
function extractClientIp(req: { headers?: Record<string, unknown>; socket?: { remoteAddress?: string | null } }) {
const forwardedFor = req.headers?.["x-forwarded-for"];
if (typeof forwardedFor === "string" && forwardedFor.trim()) {
return forwardedFor.split(",")[0]?.trim() || null;
}
const realIp = req.headers?.["x-real-ip"];
if (typeof realIp === "string" && realIp.trim()) {
return realIp.trim();
}
return req.socket?.remoteAddress || null;
}
function assertDataPrivacyConsent(consent: DataPrivacyConsent | undefined) {
if (!consent?.accepted) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "La validation des données personnelles est requise pour poursuivre.",
});
}
if (consent.version !== DATA_PRIVACY_NOTICE_VERSION) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "La version d'information sur les données personnelles n'est plus à jour. Merci de relire puis de confirmer à nouveau.",
});
}
}
async function recordDataPrivacyConsent(
userId: number,
req: { headers?: Record<string, unknown>; socket?: { remoteAddress?: string | null } },
consent: DataPrivacyConsent
) {
await db.updateUser(userId, {
privacyConsentVersion: consent.version,
privacyConsentAcceptedAt: new Date(),
privacyConsentContext: consent.context,
});
try {
await db.createAuditLog({
userId,
action: "privacy_consent",
entityType: "privacy",
details: JSON.stringify({
version: consent.version,
context: consent.context,
}),
ipAddress: extractClientIp(req) || undefined,
});
} catch (error) {
console.error("Failed to record privacy consent audit:", error);
}
}
function validateReservationSalleFormData(rawFormData?: string) {
if (!rawFormData) return;
let formData: any;
try {
formData = JSON.parse(rawFormData);
} catch {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Le formulaire de réservation de salle est invalide' });
}
if (formData?.formulaireType !== 'reservation_salle_mjs') {
return;
}
const requiredFields = [
{ key: 'motifReservation', label: 'Motif de la réservation' },
{ key: 'dateReservation', label: 'Date de début' },
{ key: 'dateFinReservation', label: 'Date de fin' },
{ key: 'typeUsage', label: "Type d'usage" },
{ key: 'frequence', label: 'Fréquence' },
{ key: 'nombreParticipants', label: 'Nombre de participants' },
];
const missing = requiredFields.find(field => !String(formData?.[field.key] ?? '').trim());
if (missing) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `${missing.label} est obligatoire pour une demande de salle`,
});
}
if (String(formData.dateFinReservation) < String(formData.dateReservation)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'La date de fin doit être postérieure ou égale à la date de début',
});
}
if (formData.useDetailedSchedule) {
const slots = Array.isArray(formData.horairesParJour) ? formData.horairesParJour : [];
if (slots.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Veuillez renseigner au moins un créneau détaillé pour cette demande de salle',
});
}
for (const slot of slots) {
if (!String(slot?.date || '').trim() || !String(slot?.heureDebut || '').trim() || !String(slot?.heureFin || '').trim()) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Chaque créneau détaillé doit contenir une date, une heure de début et une heure de fin',
});
}
if (String(slot.heureFin) <= String(slot.heureDebut)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Chaque créneau détaillé doit avoir une heure de fin postérieure à lheure de début',
});
}
if (String(slot.date) < String(formData.dateReservation) || String(slot.date) > String(formData.dateFinReservation)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Un créneau détaillé ne correspond pas à la période sélectionnée',
});
}
}
return;
}
if (!String(formData.heureDebut ?? '').trim() || !String(formData.heureFin ?? '').trim()) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Le créneau horaire est obligatoire pour une demande de salle',
});
}
if (String(formData.heureFin) <= String(formData.heureDebut)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Lheure de fin doit être postérieure à lheure de début',
});
}
const pricing = computeSalleWorkflowPricing(rawFormData);
if (pricing?.unsupportedSalles?.length) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Aucune tarification automatique n'est disponible pour : ${pricing.unsupportedSalles.map((item) => item.salleNom).join(', ')}`,
});
}
}
function sanitizeDuplicatedRequestFormData(rawFormData?: string | null) {
if (!rawFormData) return undefined;
try {
const formData = JSON.parse(rawFormData);
if (formData && typeof formData === 'object') {
delete formData.cadreDSU;
delete formData.notificationTrace;
delete formData.dateReception;
delete formData.dateDecision;
delete formData.responsableDSU;
delete formData.observationsDSU;
delete formData.commentaireAdmin;
if (formData.formulaireType === 'demande_materiel_evenementiel') {
delete formData.datePriseEnCharge;
}
}
return JSON.stringify(formData);
} catch {
return rawFormData;
}
}
async function syncAssociationBackToDirectoryEntry(association: Awaited<ReturnType<typeof db.getAssociationById>>) {
if (!association?.sourceDirectoryEntryId) {
return;
}
const existingEntry = await db.getAssociationDirectoryEntryById(association.sourceDirectoryEntryId);
const parsedGovernance = association.gouvernance
? parseAssociationGovernance(association.gouvernance)
: buildGovernanceFromLegacyRepresentative(association.nomRepresentant, association.fonctionRepresentant);
const representantLegal = parsedGovernance.representantLegal;
const representantLegalDisplayName = buildDisplayName(representantLegal) || association.nomRepresentant || null;
await db.updateAssociationDirectoryEntry(association.sourceDirectoryEntryId, {
nomAssociation: association.nomAssociation,
siret: association.siret || existingEntry?.siret || null,
rna: association.rna || existingEntry?.rna || null,
adresse: association.adresse || existingEntry?.adresse || null,
codePostal: association.codePostal || existingEntry?.codePostal || null,
ville: association.ville || existingEntry?.ville || null,
telephone: association.telephone || existingEntry?.telephone || null,
emailOfficiel: association.emailContact || existingEntry?.emailOfficiel || null,
emailOfficielNormalise: association.emailContact
? normalizeEmail(association.emailContact)
: existingEntry?.emailOfficielNormalise || null,
siteWeb: association.siteWeb || existingEntry?.siteWeb || null,
facebookUrl: association.facebookUrl || existingEntry?.facebookUrl || null,
instagramUrl: association.instagramUrl || existingEntry?.instagramUrl || null,
thematique: association.thematique || existingEntry?.thematique || null,
dateCreation: association.dateCreation || existingEntry?.dateCreation || null,
objetAssociation: association.objetAssociation || existingEntry?.objetAssociation || null,
statutJuridique: association.statutJuridique || existingEntry?.statutJuridique || undefined,
nomRepresentant: representantLegalDisplayName || existingEntry?.nomRepresentant || null,
fonctionRepresentant: representantLegal?.fonction || association.fonctionRepresentant || existingEntry?.fonctionRepresentant || null,
gouvernance: association.gouvernance || existingEntry?.gouvernance || null,
updatedAt: new Date(),
});
}
function buildPublicAssociationDirectoryPayload(details: NonNullable<Awaited<ReturnType<typeof db.getAssociationDirectoryEntryDetails>>>) {
const coordinates = getPublicMapCoordinates(details.entry);
if (!coordinates) {
return null;
}
return {
id: details.entry.id,
nomAssociation: details.entry.nomAssociation,
ville: details.entry.ville || details.association?.ville,
siret: details.entry.siret || details.association?.siret,
rna: details.entry.rna || details.association?.rna,
thematiques: parseAssociationThematics(details.entry.thematique || details.association?.thematique),
objetAssociation: details.entry.objetAssociation || details.association?.objetAssociation,
siteWeb: details.entry.siteWeb || details.association?.siteWeb,
facebookUrl: details.entry.facebookUrl || details.association?.facebookUrl,
instagramUrl: details.entry.instagramUrl || details.association?.instagramUrl,
registered: Boolean(details.association),
latitude: coordinates.latitude,
longitude: coordinates.longitude,
geoPrecision: coordinates.precision,
publicUrl: `/associations/${details.entry.id}`,
};
}
function parseRecipients(input?: string[]) {
return (input || [])
.map(value => value.trim().toLowerCase())
.filter(Boolean);
}
function mergeRecipients(...recipientSets: Array<string[] | undefined>) {
return Array.from(
new Set(
recipientSets
.flatMap(values => values || [])
.map(value => value.trim().toLowerCase())
.filter(Boolean)
)
);
}
function parseStoredRecipientEmails(value?: string | null): string[] {
if (!value) return [];
try {
const parsed = JSON.parse(value);
if (!Array.isArray(parsed)) return [];
return parsed
.map((entry) => String(entry || "").trim().toLowerCase())
.filter(Boolean);
} catch {
return [];
}
}
function parseMaterialFinancialDecision(rawFormData?: string | null) {
const formData = parseRequestFormData(rawFormData);
const decision = formData?.cadreDSU?.materielEvent?.financialDecision;
if (!decision || typeof decision !== "object") return null;
return {
financialMode: (["gratuite", "gratuite_avec_caution", "location_payante"].includes(decision.financialMode)
? decision.financialMode
: "gratuite") as MaterialContractFinancialMode,
depositRequired: Boolean(decision.depositRequired),
depositAmountCents: Number.isFinite(Number(decision.depositAmountCents)) ? Math.max(0, Number(decision.depositAmountCents)) : 0,
rentalAmountCents: Number.isFinite(Number(decision.rentalAmountCents)) ? Math.max(0, Number(decision.rentalAmountCents)) : 0,
pricingNotes: decision.pricingNotes ? String(decision.pricingNotes) : "",
contractStatus: (["a_generer", "generee", "signee", "refusee", "annulee"].includes(decision.contractStatus)
? decision.contractStatus
: "a_generer") as MaterialContractStatus,
contractPdfUrl: decision.contractPdfUrl ? String(decision.contractPdfUrl) : "",
contractPdfName: decision.contractPdfName ? String(decision.contractPdfName) : "",
contractGeneratedAt: decision.contractGeneratedAt ? String(decision.contractGeneratedAt) : "",
contractValidatedByUserId: Number.isFinite(Number(decision.contractValidatedByUserId))
? Number(decision.contractValidatedByUserId)
: null,
};
}
function mergeMaterialFinancialDecision(input: {
rawFormData?: string | null;
decision: {
financialMode: MaterialContractFinancialMode;
depositRequired: boolean;
depositAmountCents: number;
rentalAmountCents: number;
pricingNotes?: string | null;
contractStatus: MaterialContractStatus;
contractPdfUrl?: string | null;
contractPdfName?: string | null;
contractGeneratedAt?: string | Date | null;
contractValidatedByUserId?: number | null;
};
}) {
const formData = parseRequestFormData(input.rawFormData);
formData.cadreDSU = formData.cadreDSU && typeof formData.cadreDSU === "object" ? formData.cadreDSU : {};
formData.cadreDSU.materielEvent = formData.cadreDSU.materielEvent && typeof formData.cadreDSU.materielEvent === "object"
? formData.cadreDSU.materielEvent
: {};
formData.cadreDSU.materielEvent.financialDecision = {
financialMode: input.decision.financialMode,
depositRequired: input.decision.depositRequired,
depositAmountCents: input.decision.depositAmountCents,
rentalAmountCents: input.decision.rentalAmountCents,
pricingNotes: input.decision.pricingNotes || "",
contractStatus: input.decision.contractStatus,
contractPdfUrl: input.decision.contractPdfUrl || "",
contractPdfName: input.decision.contractPdfName || "",
contractGeneratedAt: input.decision.contractGeneratedAt
? new Date(input.decision.contractGeneratedAt).toISOString()
: "",
contractValidatedByUserId: input.decision.contractValidatedByUserId ?? null,
};
return JSON.stringify(formData);
}
function buildSalleDatesLabel(formData: any) {
if (formData?.dateReservation === formData?.dateFinReservation || !formData?.dateFinReservation) {
return formatDateFr(formData?.dateReservation);
}
return `${formatDateFr(formData?.dateReservation)} au ${formatDateFr(formData?.dateFinReservation)}`;
}
function getSallePaymentTracking(rawFormData?: string | null) {
const workflow = getSalleWorkflowData(rawFormData);
const formData = parseRequestFormData(rawFormData);
const paymentStatus = ["en_attente_paiement", "paiement_partiel", "paiement_recu", "paiement_valide"].includes(String(workflow.paymentStatus))
? String(workflow.paymentStatus)
: "";
return {
paymentStatus: paymentStatus as "" | "en_attente_paiement" | "paiement_partiel" | "paiement_recu" | "paiement_valide",
paymentDueDate: normalizeDateString(workflow.paymentDueDate || formData?.dateReservation || ""),
amountReceivedCents: Number.isFinite(Number(workflow.amountReceivedCents)) ? Math.max(0, Number(workflow.amountReceivedCents)) : 0,
paymentReceivedAt: typeof workflow.paymentReceivedAt === "string" ? workflow.paymentReceivedAt : "",
paymentValidatedAt: typeof workflow.paymentValidatedAt === "string" ? workflow.paymentValidatedAt : "",
paymentReference: typeof workflow.paymentReference === "string" ? workflow.paymentReference : "",
paymentNotes: typeof workflow.paymentNotes === "string" ? workflow.paymentNotes : "",
};
}
function ensureSallePaymentTracking(input: {
rawFormData?: string | null;
billed: boolean;
totalAmountCents: number;
}) {
if (!input.billed) {
return input.rawFormData || "";
}
const current = getSallePaymentTracking(input.rawFormData);
return mergeSalleWorkflowData(input.rawFormData, {
paymentStatus: current.paymentStatus || "en_attente_paiement",
paymentDueDate: current.paymentDueDate || normalizeDateString(parseRequestFormData(input.rawFormData)?.dateReservation || ""),
amountReceivedCents: current.amountReceivedCents,
paymentReceivedAt: current.paymentReceivedAt,
paymentValidatedAt: current.paymentValidatedAt,
paymentReference: current.paymentReference,
paymentNotes: current.paymentNotes,
});
}
function sanitizeSalleBillingSettings(value: unknown): SalleBillingSettings {
const record = value && typeof value === "object" ? value as Record<string, unknown> : {};
return {
ribDocumentUrl: typeof record.ribDocumentUrl === "string" ? record.ribDocumentUrl : "",
ribDocumentName: typeof record.ribDocumentName === "string" ? record.ribDocumentName : "",
ribMimeType: typeof record.ribMimeType === "string" ? record.ribMimeType : "",
ribUploadedAt: typeof record.ribUploadedAt === "string" ? record.ribUploadedAt : "",
};
}
async function getSalleBillingSettings() {
const storedValue = await db.getPortalSetting(SALLE_BILLING_SETTING_KEY);
if (!storedValue) {
return sanitizeSalleBillingSettings(null);
}
try {
return sanitizeSalleBillingSettings(JSON.parse(storedValue));
} catch {
return sanitizeSalleBillingSettings(null);
}
}
async function getHelloAssoSettings() {
const storedValue = await db.getPortalSetting(HELLOASSO_SETTINGS_KEY);
if (!storedValue) return sanitizeHelloAssoSettings();
try {
return sanitizeHelloAssoSettings(JSON.parse(storedValue));
} catch {
return sanitizeHelloAssoSettings();
}
}
async function getAssociationMapSettings() {
const storedValue = await db.getPortalSetting(ASSOCIATION_MAP_SETTING_KEY);
if (!storedValue) return sanitizeAssociationMapSettings();
try {
return sanitizeAssociationMapSettings(JSON.parse(storedValue));
} catch {
return sanitizeAssociationMapSettings();
}
}
async function saveHelloAssoSettings(nextValue: {
enabled: boolean;
clientId: string;
clientSecret?: string;
}) {
const current = await getHelloAssoSettings();
const normalized = sanitizeHelloAssoSettings({
enabled: nextValue.enabled,
clientId: nextValue.clientId,
clientSecret: nextValue.clientSecret?.trim() ? nextValue.clientSecret : current.clientSecret,
});
await db.setPortalSetting(
HELLOASSO_SETTINGS_KEY,
JSON.stringify(normalized),
"Synchronisation contrôlée du bordereau des associations avec HelloAsso"
);
return normalized;
}
async function readStoredUploadBufferFromUrl(url: string) {
const normalized = String(url || "").trim();
if (!normalized.startsWith("/uploads/")) return null;
const relativePath = normalized.replace(/^\/uploads\//, "");
const absolutePath = path.resolve(process.cwd(), "uploads", relativePath);
try {
return await fs.readFile(absolutePath);
} catch {
return null;
}
}
function serializeOperationalRecapService(
service:
| Awaited<ReturnType<typeof db.getOperationalRecapServiceById>>
| Awaited<ReturnType<typeof db.getAllOperationalRecapServices>>[number]
| null
| undefined
) {
if (!service) return null;
return {
...service,
usage: service.usage === "controle" ? "controle" : "terrain",
recipientEmails: parseStoredRecipientEmails(service.recipientEmails),
};
}
function normalizeEmail(value: string) {
return value.trim().toLowerCase();
}
function normalizeSiretValue(value?: string | null) {
const digits = String(value || "").replace(/\D/g, "");
return digits || null;
}
function normalizeRnaValue(value?: string | null) {
const normalized = String(value || "").replace(/\s+/g, "").trim().toUpperCase();
return normalized || null;
}
async function ensureAssociationLinkedToDirectoryEntry(userId: number, directoryEntryId: number) {
const [existingProfile, directoryEntry, alreadyLinkedProfile] = await Promise.all([
db.getAssociationByUserId(userId),
db.getAssociationDirectoryEntryById(directoryEntryId),
db.getAssociationBySourceDirectoryEntryId(directoryEntryId),
]);
if (!directoryEntry) {
throw new Error("Association du bordereau introuvable");
}
if (alreadyLinkedProfile && alreadyLinkedProfile.userId !== userId) {
throw new Error("Cette fiche du bordereau est déjà rattachée à une autre association portail");
}
if (existingProfile) {
if (existingProfile.sourceDirectoryEntryId !== directoryEntryId) {
await db.updateAssociation(existingProfile.id, { sourceDirectoryEntryId: directoryEntryId });
}
return existingProfile.id;
}
return db.createAssociation(createAssociationProfileFromDirectoryEntry(userId, directoryEntry));
}
const socialUrlSchema = z.string().optional().or(z.literal(''));
async function queueAssociationDirectoryReview(input: {
userId?: number | null;
sourceType: "portal_signup" | "helloasso";
sourceLabel?: string | null;
nomAssociation: string;
email?: string | null;
siret?: string | null;
rna?: string | null;
ville?: string | null;
telephone?: string | null;
nomRepresentant?: string | null;
matchResult: AssociationDirectoryMatchResult;
}) {
const payload = JSON.stringify({
reason: input.matchResult.reason,
proposedTelephone: input.telephone || null,
proposedNomRepresentant: input.nomRepresentant || null,
candidates: input.matchResult.candidates.map((candidate) => ({
id: candidate.id,
nomAssociation: candidate.nomAssociation,
ville: candidate.ville,
siret: candidate.siret,
rna: candidate.rna,
emailOfficiel: candidate.emailOfficiel,
telephone: candidate.telephone,
nomRepresentant: candidate.nomRepresentant,
})),
});
const reviewPayload = {
sourceType: input.sourceType,
sourceLabel: input.sourceLabel || null,
userId: input.userId || null,
proposedNomAssociation: input.nomAssociation,
proposedEmail: input.email || null,
proposedEmailNormalise: input.email ? normalizeEmail(input.email) : null,
proposedSiret: normalizeSiretValue(input.siret),
proposedRna: normalizeRnaValue(input.rna),
proposedVille: input.ville || null,
matchStatus: input.matchResult.status,
payload,
status: "pending" as const,
resolutionNote: null,
};
const existing = input.userId ? await db.getPendingAssociationDirectoryReviewByUserId(input.userId) : undefined;
if (existing) {
await db.updateAssociationDirectoryReview(existing.id, reviewPayload);
return existing.id;
}
return db.createAssociationDirectoryReview(reviewPayload);
}
async function resolvePendingAssociationDirectoryReviewAfterAutoLink(input: {
userId: number;
directoryEntryId: number;
sourceLabel?: string | null;
}) {
const pendingReview = await db.getPendingAssociationDirectoryReviewByUserId(input.userId);
if (!pendingReview) {
return;
}
await db.updateAssociationDirectoryReview(pendingReview.id, {
status: "linked",
resolvedDirectoryEntryId: input.directoryEntryId,
resolutionNote:
input.sourceLabel
? `Rapprochement validé automatiquement après ${input.sourceLabel}.`
: "Rapprochement validé automatiquement après mise à jour du profil portail.",
});
}
const associationDirectoryManualInputSchema = z.object({
reviewId: z.number().optional(),
nomAssociation: z.string().min(1, "Le nom de l'association est obligatoire"),
emailOfficiel: z.string().email("Adresse email invalide").optional().or(z.literal("")),
siret: z.string().optional(),
rna: z.string().optional(),
thematiques: z.array(z.enum(associationThematicValues)).optional(),
adresse: z.string().optional(),
codePostal: z.string().optional(),
ville: z.string().optional(),
telephone: z.string().optional(),
siteWeb: z.string().url().optional().or(z.literal("")),
facebookUrl: socialUrlSchema,
instagramUrl: socialUrlSchema,
dateCreation: z.string().optional(),
objetAssociation: z.string().optional(),
statutJuridique: z.enum(['association_loi_1901', 'association_reconnue_utilite_publique', 'fondation', 'autre']).optional(),
nomRepresentant: z.string().optional(),
fonctionRepresentant: z.string().optional(),
isActive: z.boolean().optional(),
});
function splitRepresentativeName(fullName: string | null | undefined) {
const cleaned = String(fullName || "").trim().replace(/\s+/g, " ");
if (!cleaned) {
return { prenom: "", nom: "" };
}
const parts = cleaned.split(" ");
if (parts.length === 1) {
return { prenom: "", nom: parts[0] };
}
return {
prenom: parts.slice(0, -1).join(" "),
nom: parts.at(-1) || "",
};
}
function buildGovernanceFromLegacyRepresentative(
nomRepresentant: string | null | undefined,
fonctionRepresentant: string | null | undefined
): AssociationGovernance {
const parts = splitRepresentativeName(nomRepresentant);
const hasRepresentative = Boolean(parts.nom || parts.prenom || fonctionRepresentant);
return {
representantLegal: hasRepresentative
? {
nom: parts.nom,
prenom: parts.prenom,
email: "",
telephone: "",
fonction: normalizeLegalRepresentativeRole(fonctionRepresentant),
}
: null,
membres: [],
};
}
function validateSocialUrl(field: "facebookUrl" | "instagramUrl", value?: string) {
const normalized = normalizeSocialUrl(value);
if (!normalized) return;
const isValid = field === "facebookUrl" ? isValidFacebookUrl(normalized) : isValidInstagramUrl(normalized);
if (!isValid) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
field === "facebookUrl"
? "Le lien Facebook doit être une URL HTTPS valide vers facebook.com"
: "Le lien Instagram doit être une URL HTTPS valide vers instagram.com",
});
}
}
function buildAppBaseUrl(req: { protocol?: string; headers?: Record<string, unknown>; get?: (name: string) => string | undefined }) {
const configuredBaseUrl = process.env.APP_BASE_URL?.trim();
if (configuredBaseUrl) {
return configuredBaseUrl.replace(/\/$/, "");
}
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 host = forwardedHost || req.get?.("host") || "localhost:3000";
const protocol = forwardedProto || req.protocol || "http";
return `${protocol}://${host}`.replace(/\/$/, "");
}
function buildAssociationInvitationLink(req: Parameters<typeof buildAppBaseUrl>[0], token: string) {
return `${buildAppBaseUrl(req)}/invite/${token}`;
}
function formatDateFr(date: Date | string | 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 parseRequestFormData(rawFormData?: string | null) {
if (!rawFormData) return {};
try {
return JSON.parse(rawFormData);
} catch {
return {};
}
}
function normalizeDateString(value?: string | null) {
if (!value) return "";
const normalized = String(value).trim();
return /^\d{4}-\d{2}-\d{2}$/.test(normalized) ? normalized : "";
}
function dateRangesOverlap(startA?: string | null, endA?: string | null, startB?: string | null, endB?: string | null) {
const aStart = normalizeDateString(startA);
const aEnd = normalizeDateString(endA || startA);
const bStart = normalizeDateString(startB);
const bEnd = normalizeDateString(endB || startB);
if (!aStart || !aEnd || !bStart || !bEnd) return false;
return aStart <= bEnd && bStart <= aEnd;
}
function addDays(date: Date, days: number) {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
function startOfDay(date: Date) {
const next = new Date(date);
next.setHours(0, 0, 0, 0);
return next;
}
function toIsoDay(date: Date) {
return date.toISOString().slice(0, 10);
}
function daysBetweenInclusive(startIso?: string | null, endIso?: string | null) {
const start = normalizeDateString(startIso);
const end = normalizeDateString(endIso || startIso);
if (!start || !end) return 0;
const startDate = new Date(`${start}T00:00:00`);
const endDate = new Date(`${end}T00:00:00`);
const diff = endDate.getTime() - startDate.getTime();
if (Number.isNaN(diff) || diff < 0) return 0;
return Math.floor(diff / 86_400_000) + 1;
}
function getOverlapDays(startA?: string | null, endA?: string | null, startB?: string | null, endB?: string | null) {
if (!dateRangesOverlap(startA, endA, startB, endB)) return 0;
const left = new Date(`${normalizeDateString(startA)!}T00:00:00`);
const right = new Date(`${normalizeDateString(endA || startA)!}T00:00:00`);
const periodStart = new Date(`${normalizeDateString(startB)!}T00:00:00`);
const periodEnd = new Date(`${normalizeDateString(endB || startB)!}T00:00:00`);
const overlapStart = left > periodStart ? left : periodStart;
const overlapEnd = right < periodEnd ? right : periodEnd;
const diff = overlapEnd.getTime() - overlapStart.getTime();
return diff < 0 ? 0 : Math.floor(diff / 86_400_000) + 1;
}
function buildAnalyticsRange(period: AnalyticsPeriod) {
const today = startOfDay(new Date());
const end = today;
let start = today;
let label = "7 derniers jours";
let comparisonLabel = "période précédente";
let bucketMode: "day" | "week" | "month" = "day";
if (period === "7d") {
start = addDays(today, -6);
label = "7 derniers jours";
comparisonLabel = "7 jours précédents";
bucketMode = "day";
} else if (period === "month") {
start = new Date(today.getFullYear(), today.getMonth(), 1);
label = "Mois en cours";
comparisonLabel = "Mois précédent";
bucketMode = "week";
} else if (period === "quarter") {
const quarterStartMonth = Math.floor(today.getMonth() / 3) * 3;
start = new Date(today.getFullYear(), quarterStartMonth, 1);
label = "Trimestre en cours";
comparisonLabel = "Trimestre précédent";
bucketMode = "month";
} else if (period === "semester") {
const semesterStartMonth = today.getMonth() < 6 ? 0 : 6;
start = new Date(today.getFullYear(), semesterStartMonth, 1);
label = "Semestre en cours";
comparisonLabel = "Semestre précédent";
bucketMode = "month";
} else {
start = new Date(today.getFullYear(), 0, 1);
label = "Année en cours";
comparisonLabel = "Année précédente";
bucketMode = "month";
}
const spanDays = daysBetweenInclusive(toIsoDay(start), toIsoDay(end));
const previousEnd = addDays(start, -1);
const previousStart = addDays(previousEnd, -(spanDays - 1));
return {
start,
end,
startIso: toIsoDay(start),
endIso: toIsoDay(end),
previousStartIso: toIsoDay(previousStart),
previousEndIso: toIsoDay(previousEnd),
label,
comparisonLabel,
bucketMode,
spanDays,
};
}
function buildAnalyticsRangeFromReference(period: AnalyticsPeriod, referenceDate: Date, mode: "current" | "completed" = "current") {
if (mode === "current") {
return buildAnalyticsRange(period);
}
const today = startOfDay(referenceDate);
if (period === "7d") {
const end = addDays(today, -1);
const start = addDays(end, -6);
return {
start,
end,
startIso: toIsoDay(start),
endIso: toIsoDay(end),
previousStartIso: toIsoDay(addDays(start, -7)),
previousEndIso: toIsoDay(addDays(end, -7)),
label: "7 jours précédents",
comparisonLabel: "7 jours antérieurs",
bucketMode: "day" as const,
spanDays: 7,
};
}
if (period === "month") {
const end = new Date(today.getFullYear(), today.getMonth(), 0);
const start = new Date(end.getFullYear(), end.getMonth(), 1);
const previousEnd = new Date(start.getFullYear(), start.getMonth(), 0);
const previousStart = new Date(previousEnd.getFullYear(), previousEnd.getMonth(), 1);
return {
start,
end,
startIso: toIsoDay(start),
endIso: toIsoDay(end),
previousStartIso: toIsoDay(previousStart),
previousEndIso: toIsoDay(previousEnd),
label: "Mois précédent",
comparisonLabel: "Mois antérieur",
bucketMode: "week" as const,
spanDays: daysBetweenInclusive(toIsoDay(start), toIsoDay(end)),
};
}
if (period === "quarter") {
const currentQuarterStartMonth = Math.floor(today.getMonth() / 3) * 3;
const currentQuarterStart = new Date(today.getFullYear(), currentQuarterStartMonth, 1);
const end = new Date(currentQuarterStart.getFullYear(), currentQuarterStart.getMonth(), 0);
const start = new Date(end.getFullYear(), end.getMonth() - 2, 1);
const previousEnd = new Date(start.getFullYear(), start.getMonth(), 0);
const previousStart = new Date(previousEnd.getFullYear(), previousEnd.getMonth() - 2, 1);
return {
start,
end,
startIso: toIsoDay(start),
endIso: toIsoDay(end),
previousStartIso: toIsoDay(previousStart),
previousEndIso: toIsoDay(previousEnd),
label: "Trimestre précédent",
comparisonLabel: "Trimestre antérieur",
bucketMode: "month" as const,
spanDays: daysBetweenInclusive(toIsoDay(start), toIsoDay(end)),
};
}
if (period === "semester") {
const currentSemesterStartMonth = today.getMonth() < 6 ? 0 : 6;
const currentSemesterStart = new Date(today.getFullYear(), currentSemesterStartMonth, 1);
const end = new Date(currentSemesterStart.getFullYear(), currentSemesterStart.getMonth(), 0);
const start = new Date(end.getFullYear(), end.getMonth() - 5, 1);
const previousEnd = new Date(start.getFullYear(), start.getMonth(), 0);
const previousStart = new Date(previousEnd.getFullYear(), previousEnd.getMonth() - 5, 1);
return {
start,
end,
startIso: toIsoDay(start),
endIso: toIsoDay(end),
previousStartIso: toIsoDay(previousStart),
previousEndIso: toIsoDay(previousEnd),
label: "Semestre précédent",
comparisonLabel: "Semestre antérieur",
bucketMode: "month" as const,
spanDays: daysBetweenInclusive(toIsoDay(start), toIsoDay(end)),
};
}
const end = new Date(today.getFullYear(), 0, 0);
const start = new Date(end.getFullYear(), 0, 1);
const previousStart = new Date(start.getFullYear() - 1, 0, 1);
const previousEnd = new Date(start.getFullYear() - 1, 11, 31);
return {
start,
end,
startIso: toIsoDay(start),
endIso: toIsoDay(end),
previousStartIso: toIsoDay(previousStart),
previousEndIso: toIsoDay(previousEnd),
label: "Année précédente",
comparisonLabel: "Année antérieure",
bucketMode: "month" as const,
spanDays: daysBetweenInclusive(toIsoDay(start), toIsoDay(end)),
};
}
async function getStatsAutomationSettings() {
const raw = await db.getPortalSetting(STATS_AUTOMATION_SETTING_KEY);
if (!raw) return sanitizeStatsAutomationSettings();
try {
return sanitizeStatsAutomationSettings(JSON.parse(raw));
} catch {
return sanitizeStatsAutomationSettings();
}
}
async function saveStatsAutomationSettings(settings: StatsAutomationSettings) {
await db.setPortalSetting(
STATS_AUTOMATION_SETTING_KEY,
JSON.stringify(settings),
"Automatisation des rapports statistiques"
);
return settings;
}
async function getStatsReportHistory() {
const raw = await db.getPortalSetting(STATS_REPORT_HISTORY_SETTING_KEY);
if (!raw) return [] as StatsReportHistoryEntry[];
try {
return sanitizeStatsReportHistory(JSON.parse(raw));
} catch {
return [];
}
}
async function saveStatsReportHistory(history: StatsReportHistoryEntry[]) {
await db.setPortalSetting(
STATS_REPORT_HISTORY_SETTING_KEY,
JSON.stringify(history.slice(0, 30)),
"Historique des rapports statistiques générés automatiquement"
);
}
function getCayenneDateParts(date: Date) {
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone: "America/Cayenne",
year: "numeric",
month: "2-digit",
day: "2-digit",
weekday: "short",
});
const parts = formatter.formatToParts(date);
const get = (type: string) => parts.find((part) => part.type === type)?.value || "";
const year = Number(get("year"));
const month = Number(get("month"));
const day = Number(get("day"));
const weekdayLabel = get("weekday");
const weekdayIndexMap: Record<string, number> = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 0 };
return {
year,
month,
day,
isoDate: `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`,
weekday: weekdayIndexMap[weekdayLabel] ?? 0,
};
}
function getStatsCycleForToday(period: StatsReportHistoryEntry["period"], now: Date) {
const parts = getCayenneDateParts(now);
if (period === "weekly") {
return {
due: parts.weekday === 1,
cycleKey: `weekly-${parts.isoDate}`,
analyticsPeriod: "7d" as const,
};
}
if (period === "month") {
return {
due: parts.day === 1,
cycleKey: `month-${parts.year}-${String(parts.month - 1 || 12).padStart(2, "0")}`,
analyticsPeriod: "month" as const,
};
}
if (period === "quarter") {
return {
due: parts.day === 1 && [1, 4, 7, 10].includes(parts.month),
cycleKey: `quarter-${parts.year}-${Math.max(1, Math.ceil(((parts.month - 1) || 12) / 3))}`,
analyticsPeriod: "quarter" as const,
};
}
if (period === "semester") {
return {
due: parts.day === 1 && [1, 7].includes(parts.month),
cycleKey: `semester-${parts.year}-${parts.month === 1 ? "S2-prev" : "S1"}`,
analyticsPeriod: "semester" as const,
};
}
return {
due: parts.day === 1 && parts.month === 1,
cycleKey: `year-${parts.year - 1}`,
analyticsPeriod: "year" as const,
};
}
function formatAnalyticsBucketLabel(start: Date, end: Date, mode: "day" | "week" | "month") {
if (mode === "day") {
return start.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit" });
}
if (mode === "month") {
return start.toLocaleDateString("fr-FR", { month: "short", year: "2-digit" });
}
return `${start.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit" })} - ${end.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit" })}`;
}
function buildAnalyticsBuckets(range: ReturnType<typeof buildAnalyticsRange>) {
const buckets: Array<{ key: string; label: string; startIso: string; endIso: string }> = [];
if (range.bucketMode === "day") {
for (let cursor = new Date(range.start); cursor <= range.end; cursor = addDays(cursor, 1)) {
const startIso = toIsoDay(cursor);
buckets.push({
key: startIso,
label: formatAnalyticsBucketLabel(cursor, cursor, "day"),
startIso,
endIso: startIso,
});
}
return buckets;
}
if (range.bucketMode === "week") {
for (let cursor = new Date(range.start); cursor <= range.end; cursor = addDays(cursor, 7)) {
const bucketStart = new Date(cursor);
const bucketEnd = addDays(bucketStart, 6);
const effectiveEnd = bucketEnd > range.end ? range.end : bucketEnd;
buckets.push({
key: `${toIsoDay(bucketStart)}-${toIsoDay(effectiveEnd)}`,
label: formatAnalyticsBucketLabel(bucketStart, effectiveEnd, "week"),
startIso: toIsoDay(bucketStart),
endIso: toIsoDay(effectiveEnd),
});
}
return buckets;
}
for (let cursor = new Date(range.start.getFullYear(), range.start.getMonth(), 1); cursor <= range.end; cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1)) {
const bucketStart = new Date(cursor);
const bucketEnd = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 0);
const effectiveStart = bucketStart < range.start ? range.start : bucketStart;
const effectiveEnd = bucketEnd > range.end ? range.end : bucketEnd;
buckets.push({
key: `${toIsoDay(effectiveStart)}-${toIsoDay(effectiveEnd)}`,
label: formatAnalyticsBucketLabel(effectiveStart, effectiveEnd, "month"),
startIso: toIsoDay(effectiveStart),
endIso: toIsoDay(effectiveEnd),
});
}
return buckets;
}
export async function computeReservationAnalytics(period: AnalyticsPeriod, mode: "current" | "completed" = "current", referenceDate: Date = new Date()) {
const range = buildAnalyticsRangeFromReference(period, referenceDate, mode);
const buckets = buildAnalyticsBuckets(range);
const allRequests = await db.getAllRequests();
const associations = await db.getAllAssociations();
const associationMap = new Map(associations.map((association) => [association.id, association]));
const roomUsage = new Map(
salleAnalyticsCatalog.map((room) => [room.id, { roomId: room.id, roomName: room.name, bookedDays: 0 }])
);
const materialUsage = new Map(
materialEventItems.map((item) => [item.key, { key: item.key, label: getMaterialEventLabel(item.key), quantity: 0, requests: 0 }])
);
const categoryUsage = new Map<string, { key: string; label: string; count: number }>();
const associationUsage = new Map<number, {
associationId: number;
associationName: string;
totalReservations: number;
salleReservations: number;
materialReservations: number;
lastReservationAt: string;
thematics: string[];
}>();
const timeline = buckets.map((bucket) => ({
label: bucket.label,
salle: 0,
materiel: 0,
total: 0,
}));
const history: Array<{
id: number;
title: string;
type: "demande_salle" | "demande_materiel_evenementiel";
associationName: string;
status: string;
startDate: string;
endDate: string;
resources: string;
createdAt: Date;
}> = [];
let salleReservations = 0;
let materialReservations = 0;
let totalMaterialUnits = 0;
let previousSalleReservations = 0;
let previousMaterialReservations = 0;
const currentAssociationIds = new Set<number>();
const previousAssociationIds = new Set<number>();
const includeInUsage = (status: string) => !["brouillon", "refusee", "annulee"].includes(status);
for (const request of allRequests) {
if (request.type !== "demande_salle" && request.type !== "demande_materiel_evenementiel") continue;
if (!includeInUsage(request.status)) continue;
const association = associationMap.get(request.associationId);
const thematicValues = parseAssociationThematics(association?.thematique).map((value) => getAssociationThematicLabel(value));
const categoryLabels = thematicValues.length > 0 ? thematicValues : ["Non renseignée"];
if (request.type === "demande_salle") {
const formData = parseRequestFormData(request.formData);
const startDate = normalizeDateString(formData.dateReservation);
const endDate = normalizeDateString(formData.dateFinReservation || formData.dateReservation || startDate);
if (!startDate || !endDate) continue;
const inCurrentRange = dateRangesOverlap(startDate, endDate, range.startIso, range.endIso);
const inPreviousRange = dateRangesOverlap(startDate, endDate, range.previousStartIso, range.previousEndIso);
if (!inCurrentRange && !inPreviousRange) continue;
if (inPreviousRange) {
previousSalleReservations += 1;
previousAssociationIds.add(request.associationId);
}
if (!inCurrentRange) continue;
salleReservations += 1;
currentAssociationIds.add(request.associationId);
categoryLabels.forEach((label) => {
const current = categoryUsage.get(label) || { key: label, label, count: 0 };
current.count += 1;
categoryUsage.set(label, current);
});
const associationEntry = associationUsage.get(request.associationId) || {
associationId: request.associationId,
associationName: association?.nomAssociation || formData.nomAssociation || `Association #${request.associationId}`,
totalReservations: 0,
salleReservations: 0,
materialReservations: 0,
lastReservationAt: endDate,
thematics: categoryLabels,
};
associationEntry.totalReservations += 1;
associationEntry.salleReservations += 1;
associationEntry.lastReservationAt = associationEntry.lastReservationAt > endDate ? associationEntry.lastReservationAt : endDate;
associationEntry.thematics = categoryLabels;
associationUsage.set(request.associationId, associationEntry);
const salleIds = deriveSalleIdsForAnalytics(formData);
salleIds.forEach((salleId) => {
const room = roomUsage.get(salleId);
if (!room) return;
room.bookedDays += getOverlapDays(startDate, endDate, range.startIso, range.endIso);
});
buckets.forEach((bucket, index) => {
if (!dateRangesOverlap(startDate, endDate, bucket.startIso, bucket.endIso)) return;
timeline[index].salle += 1;
timeline[index].total += 1;
});
history.push({
id: request.id,
title: request.titre,
type: "demande_salle",
associationName: association?.nomAssociation || formData.nomAssociation || `Association #${request.associationId}`,
status: request.status,
startDate,
endDate,
resources: Array.isArray(formData.sallesSelectionnees) ? formData.sallesSelectionnees.join(", ") : "",
createdAt: request.createdAt,
});
continue;
}
const materialEvent = getMaterialRequestEventData(request, association || null);
const startDate = materialEvent.useStartDate;
const endDate = materialEvent.useEndDate;
if (!startDate || !endDate) continue;
const inCurrentRange = dateRangesOverlap(startDate, endDate, range.startIso, range.endIso);
const inPreviousRange = dateRangesOverlap(startDate, endDate, range.previousStartIso, range.previousEndIso);
if (!inCurrentRange && !inPreviousRange) continue;
if (inPreviousRange) {
previousMaterialReservations += 1;
previousAssociationIds.add(request.associationId);
}
if (!inCurrentRange) continue;
materialReservations += 1;
currentAssociationIds.add(request.associationId);
categoryLabels.forEach((label) => {
const current = categoryUsage.get(label) || { key: label, label, count: 0 };
current.count += 1;
categoryUsage.set(label, current);
});
const associationEntry = associationUsage.get(request.associationId) || {
associationId: request.associationId,
associationName: materialEvent.associationName,
totalReservations: 0,
salleReservations: 0,
materialReservations: 0,
lastReservationAt: endDate,
thematics: categoryLabels,
};
associationEntry.totalReservations += 1;
associationEntry.materialReservations += 1;
associationEntry.lastReservationAt = associationEntry.lastReservationAt > endDate ? associationEntry.lastReservationAt : endDate;
associationEntry.thematics = categoryLabels;
associationUsage.set(request.associationId, associationEntry);
materialEvent.requestedItems.forEach((item) => {
const current = materialUsage.get(item.key);
if (!current) return;
current.quantity += item.quantity;
if (item.quantity > 0) {
current.requests += 1;
totalMaterialUnits += item.quantity;
}
});
buckets.forEach((bucket, index) => {
if (!dateRangesOverlap(startDate, endDate, bucket.startIso, bucket.endIso)) return;
timeline[index].materiel += 1;
timeline[index].total += 1;
});
history.push({
id: request.id,
title: request.titre,
type: "demande_materiel_evenementiel",
associationName: materialEvent.associationName,
status: request.status,
startDate,
endDate,
resources: materialEvent.requestedItems.map((item) => `${item.label}${item.quantity > 0 ? ` x${item.quantity}` : ""}`).join(", "),
createdAt: request.createdAt,
});
}
const totalRoomDays = salleAnalyticsCatalog.length * range.spanDays;
const bookedRoomDays = Array.from(roomUsage.values()).reduce((sum, room) => sum + room.bookedDays, 0);
const occupancyRate = totalRoomDays > 0 ? Math.round((bookedRoomDays / totalRoomDays) * 1000) / 10 : 0;
const previousTotalReservations = previousSalleReservations + previousMaterialReservations;
const currentTotalReservations = salleReservations + materialReservations;
const currentActiveAssociations = currentAssociationIds.size;
const previousActiveAssociations = previousAssociationIds.size;
const comparePercent = (current: number, previous: number) => {
if (previous === 0) return current > 0 ? 100 : 0;
return Math.round(((current - previous) / previous) * 1000) / 10;
};
return {
period,
periodLabel: range.label,
comparisonLabel: range.comparisonLabel,
startDate: range.startIso,
endDate: range.endIso,
summary: {
totalReservations: currentTotalReservations,
salleReservations,
materialReservations,
occupancyRate,
activeAssociations: currentActiveAssociations,
totalMaterialUnits,
averageReservationsPerAssociation: currentActiveAssociations > 0
? Math.round((currentTotalReservations / currentActiveAssociations) * 10) / 10
: 0,
},
comparison: {
totalReservationsPct: comparePercent(currentTotalReservations, previousTotalReservations),
salleReservationsPct: comparePercent(salleReservations, previousSalleReservations),
materialReservationsPct: comparePercent(materialReservations, previousMaterialReservations),
activeAssociationsPct: comparePercent(currentActiveAssociations, previousActiveAssociations),
},
timeline,
roomOccupancy: Array.from(roomUsage.values())
.map((room) => ({
...room,
occupancyRate: range.spanDays > 0 ? Math.round((room.bookedDays / range.spanDays) * 1000) / 10 : 0,
}))
.sort((left, right) => right.bookedDays - left.bookedDays),
materialUsage: Array.from(materialUsage.values())
.filter((item) => item.quantity > 0 || item.requests > 0)
.sort((left, right) => right.quantity - left.quantity),
categoryUsage: Array.from(categoryUsage.values()).sort((left, right) => right.count - left.count),
associationUsage: Array.from(associationUsage.values())
.sort((left, right) => right.totalReservations - left.totalReservations || right.lastReservationAt.localeCompare(left.lastReservationAt))
.slice(0, 20),
history: history
.sort((left, right) => new Date(right.startDate || right.createdAt).getTime() - new Date(left.startDate || left.createdAt).getTime())
.slice(0, 50),
};
}
async function generateAndArchiveStatsReport(input: {
period: StatsReportHistoryEntry["period"];
analyticsPeriod: AnalyticsPeriod;
generatedBy: "scheduler" | "manual";
cycleKey: string;
now?: Date;
}) {
const analytics = await computeReservationAnalytics(input.analyticsPeriod, input.generatedBy === "scheduler" ? "completed" : "current", input.now || new Date());
const pdf = await generateReservationAnalyticsPdf(analytics);
const excel = generateReservationAnalyticsExcel(analytics);
const now = input.now || new Date();
const pdfStored = await storagePut(
`portal/stats-reports/${input.period}/${nanoid()}-${pdf.fileName}`,
pdf.buffer,
pdf.contentType
);
const excelStored = await storagePut(
`portal/stats-reports/${input.period}/${nanoid()}-${excel.fileName}`,
excel.buffer,
excel.contentType
);
const entry: StatsReportHistoryEntry = {
id: nanoid(),
period: input.period,
periodLabel: analytics.periodLabel,
startDate: analytics.startDate,
endDate: analytics.endDate,
generatedAt: now.toISOString(),
pdfUrl: pdfStored.url,
pdfName: pdf.fileName,
excelUrl: excelStored.url,
excelName: excel.fileName,
cycleKey: input.cycleKey,
generatedBy: input.generatedBy,
};
const currentHistory = await getStatsReportHistory();
await saveStatsReportHistory([entry, ...currentHistory]);
return { entry, analytics, pdf, excel };
}
export async function runStatsReportScheduler(baseUrl: string) {
const settings = await getStatsAutomationSettings();
if (!settings.enabled) return;
const periods: Array<StatsReportHistoryEntry["period"]> = ["weekly", "month", "quarter", "semester", "year"];
const now = new Date();
for (const period of periods) {
const enabled = settings.frequencies[period];
if (!enabled) continue;
const cycle = getStatsCycleForToday(period, now);
if (!cycle.due) continue;
if (settings.lastGeneratedCycleKeys[period] === cycle.cycleKey) continue;
const { entry, pdf, excel } = await generateAndArchiveStatsReport({
period,
analyticsPeriod: cycle.analyticsPeriod,
generatedBy: "scheduler",
cycleKey: cycle.cycleKey,
now,
});
settings.lastGeneratedCycleKeys[period] = cycle.cycleKey;
await saveStatsAutomationSettings(settings);
await db.createAdminNotification({
type: "systeme",
titre: `Rapport statistiques ${entry.periodLabel}`,
message: `Le rapport automatique ${entry.periodLabel.toLowerCase()} est prêt. PDF et Excel sont archivés dans le module Statistiques.`,
lien: "/admin?tab=analytics",
});
if (settings.recipientEmails.length > 0 && await canSendOperationalEmails()) {
await sendOperationalEmail({
to: settings.recipientEmails,
subject: `Rapport statistiques CCDS - ${entry.periodLabel}`,
text: [
`Bonjour,`,
``,
`Le rapport automatique ${entry.periodLabel.toLowerCase()} a été généré.`,
`Période couverte : ${entry.startDate} au ${entry.endDate}.`,
`Consulter le module : ${baseUrl.replace(/\/$/, "")}/admin?tab=analytics`,
].join("\n"),
attachments: [
{ filename: pdf.fileName, content: pdf.buffer, contentType: pdf.contentType },
{ filename: excel.fileName, content: excel.buffer, contentType: excel.contentType },
],
fromName: "Portail Associations CCDS",
}).catch((error) => {
console.error("[StatsScheduler] failed to send report email:", error);
});
}
}
}
function deriveSalleIdsForAnalytics(formData: Record<string, any>): SalleAnalyticsRoomId[] {
const explicitIds = Array.isArray(formData?.sallesIds)
? formData.sallesIds.filter((value: unknown): value is SalleAnalyticsRoomId =>
typeof value === "string" && salleAnalyticsCatalog.some((entry) => entry.id === value)
)
: [];
if (explicitIds.length > 0) {
return explicitIds;
}
const names = Array.isArray(formData?.sallesSelectionnees)
? formData.sallesSelectionnees.filter((value: unknown): value is string => typeof value === "string" && value.trim().length > 0)
: [];
return names
.map((name) => {
const normalizedName = name.toLowerCase();
const byId = salleAnalyticsCatalog.find((entry) => normalizedName.includes(entry.id.toLowerCase()));
if (byId) return byId.id;
const byName = salleAnalyticsCatalog.find((entry) => normalizedName.includes(entry.name.toLowerCase()));
if (byName) return byName.id;
if (normalizedName.includes("toucan")) return "salle_toucan";
if (normalizedName.includes("ibis")) return "salle_ibis";
if (normalizedName.includes("pelican") || normalizedName.includes("pélican")) return "salle_pelican";
if (normalizedName.includes("dojo")) return "dojo";
if (normalizedName.includes("hall")) return "hall_amphitheatre";
if (normalizedName.includes("vestiaire")) return "vestiaires";
if (normalizedName.includes("bureau")) return "bureau_11_17";
return null;
})
.filter((value): value is SalleAnalyticsRoomId => value !== null);
}
function getMaterialRequestEventData(request: Awaited<ReturnType<typeof db.getRequestById>> | Awaited<ReturnType<typeof db.getAllRequests>>[number], association?: Awaited<ReturnType<typeof db.getAssociationById>> | null) {
const formData = parseRequestFormData(request?.formData);
const startDate = normalizeDateString(formData.dateDebutManifestation || formData.dateManifestation);
const manifestationEnd = normalizeDateString(formData.dateFinManifestation || formData.dateManifestation || startDate);
const restitutionDate = normalizeDateString(formData.dateRestitution || manifestationEnd);
const pickupDate = normalizeDateString(formData.datePriseEnCharge || startDate);
const grantedItems = formData.cadreDSU?.materielEvent?.itemsAccordes || {};
const grantedQuantities = formData.cadreDSU?.materielEvent?.quantitesAccordees || {};
const requestedItems = formData.materielsDemandes || {};
const requestedQuantities = formData.quantitesDemandees || {};
const items = materialEventItems
.map((item) => {
const granted = Boolean(grantedItems[item.key]);
const requested = Boolean(requestedItems[item.key]);
if (!granted && !requested) return null;
const quantity = parseMaterialEventQuantity(granted ? grantedQuantities[item.key] : requestedQuantities[item.key]);
return {
key: item.key,
label: getMaterialEventLabel(item.key),
quantity,
granted,
requested,
extra: item.key === "autres" ? String(formData.autreMaterielPrecisions || formData.cadreDSU?.materielEvent?.autresPrecisions || "") : "",
};
})
.filter(Boolean) as Array<{
key: MaterialEventItemKey;
label: string;
quantity: number;
granted: boolean;
requested: boolean;
extra: string;
}>;
return {
requestId: request?.id,
title: request?.titre || "",
associationId: request?.associationId,
associationName: association?.nomAssociation || formData.nomAssociation || `Association #${request?.associationId}`,
commune: formData.commune || association?.ville || "",
manifestationStart: startDate,
manifestationEnd,
pickupDate,
restitutionDate,
useStartDate: pickupDate || startDate,
useEndDate: restitutionDate || manifestationEnd,
requestedItems: items,
motif: String(formData.motifDemande || request?.description || ""),
};
}
function getRequestedMaterialQuantityMap(request: Awaited<ReturnType<typeof db.getRequestById>> | Awaited<ReturnType<typeof db.getAllRequests>>[number]) {
const event = getMaterialRequestEventData(request, null);
const next = emptyMaterialEventQuantityMap();
event.requestedItems.forEach((item) => {
next[item.key] = item.quantity;
});
return next;
}
function parseBlockedItems(rawValue?: string | null) {
if (!rawValue) {
return emptyMaterialEventQuantityMap();
}
try {
return sanitizeMaterialEventQuantityMap(JSON.parse(rawValue));
} catch {
return emptyMaterialEventQuantityMap();
}
}
function serializeBlockedItems(value: Partial<Record<MaterialEventItemKey, number>>) {
return JSON.stringify(sanitizeMaterialEventQuantityMap(value));
}
function sumBlockedInventory(
followups: Array<
| Awaited<ReturnType<typeof db.getMaterialReturnFollowupByRequestId>>
| null
| undefined
>
) {
const totals = emptyMaterialEventQuantityMap();
followups.forEach((followup) => {
if (!followup || followup.litigationStatus !== "pending") return;
const blockedItems = parseBlockedItems(followup.blockedItems);
materialEventItems.forEach((item) => {
totals[item.key] += blockedItems[item.key] || 0;
});
});
return totals;
}
function decodeBase64DataUrl(input: string) {
const raw = input.includes(",") ? input.split(",")[1] || "" : input;
return Buffer.from(raw, "base64");
}
async function generateAndStoreMaterialConventionForRequest(input: {
request: NonNullable<Awaited<ReturnType<typeof db.getRequestById>>>;
association: Awaited<ReturnType<typeof db.getAssociationById>>;
decision: {
financialMode: MaterialContractFinancialMode;
depositRequired: boolean;
depositAmountCents: number;
rentalAmountCents: number;
pricingNotes?: string | null;
};
validatedByUserId: number;
}) {
const contractGeneratedAt = new Date();
const pdf = await generateMaterialConventionPdf({
request: input.request,
association: input.association,
decision: {
...input.decision,
contractStatus: "generee",
contractGeneratedAt,
contractValidatedByUserId: input.validatedByUserId,
},
});
const { key, url } = await storagePut(
`materiel-evenementiel/contract-${input.request.id}-${Date.now()}.pdf`,
pdf.buffer,
"application/pdf"
);
return {
contractGeneratedAt,
contractPdfKey: key,
contractPdfUrl: url,
contractPdfName: pdf.fileName,
buffer: pdf.buffer,
};
}
async function generateAndStoreSalleConventionForRequest(input: {
request: NonNullable<Awaited<ReturnType<typeof db.getRequestById>>>;
association: Awaited<ReturnType<typeof db.getAssociationById>>;
decision: {
financialMode: MaterialContractFinancialMode;
depositRequired: boolean;
depositAmountCents: number;
rentalAmountCents: number;
pricingNotes?: string | null;
};
validatedByUserId: number;
}) {
const contractGeneratedAt = new Date();
const pdf = await generateSalleConventionPdf({
request: input.request,
association: input.association,
decision: {
...input.decision,
contractStatus: "generee",
contractGeneratedAt,
contractValidatedByUserId: input.validatedByUserId,
},
});
const { key, url } = await storagePut(
`reservation-salle/contract-${input.request.id}-${Date.now()}.pdf`,
pdf.buffer,
"application/pdf"
);
return {
contractGeneratedAt,
contractPdfKey: key,
contractPdfUrl: url,
contractPdfName: pdf.fileName,
buffer: pdf.buffer,
};
}
async function generateAndStoreSalleQuoteForRequest(input: {
requestId: number;
requestTitle: string;
formData: any;
association: Awaited<ReturnType<typeof db.getAssociationById>>;
pricing: NonNullable<ReturnType<typeof computeSalleWorkflowPricing>>;
}) {
const pdf = await generateSalleQuotePdf({
requestId: input.requestId,
requestTitle: input.requestTitle,
formData: input.formData,
association: input.association,
pricing: input.pricing,
});
const { key, url } = await storagePut(
`reservation-salle/devis-${input.requestId}-${Date.now()}.pdf`,
pdf.buffer,
"application/pdf"
);
return {
quotePdfKey: key,
quotePdfUrl: url,
quotePdfName: pdf.fileName,
buffer: pdf.buffer,
};
}
async function generateAndStoreSalleAdministrativeDecisionForRequest(input: {
requestId: number;
decisionText: string;
signedAt?: string | Date | null;
signedByLabel?: string | null;
}) {
const pdf = await generateSalleAdministrativeDecisionPdf({
requestId: input.requestId,
decisionText: input.decisionText,
signedAt: input.signedAt,
signedByLabel: input.signedByLabel,
});
const { key, url } = await storagePut(
`reservation-salle/decision-${input.requestId}-${Date.now()}.pdf`,
pdf.buffer,
"application/pdf"
);
return {
decisionPdfKey: key,
decisionPdfUrl: url,
decisionPdfName: pdf.fileName,
buffer: pdf.buffer,
};
}
async function generateAndStoreSalleInvoiceForRequest(input: {
requestId: number;
requestTitle: string;
associationName: string;
datesLabel: string;
pricing: NonNullable<ReturnType<typeof computeSalleWorkflowPricing>>;
}) {
const pdf = await generateSalleInvoicePdf({
requestId: input.requestId,
requestTitle: input.requestTitle,
associationName: input.associationName,
datesLabel: input.datesLabel,
pricing: input.pricing,
});
const { key, url } = await storagePut(
`reservation-salle/facture-${input.requestId}-${Date.now()}.pdf`,
pdf.buffer,
"application/pdf"
);
return {
invoicePdfKey: key,
invoicePdfUrl: url,
invoicePdfName: pdf.fileName,
buffer: pdf.buffer,
};
}
async function generateSignedSalleWorkflowPackage(input: {
request: NonNullable<Awaited<ReturnType<typeof db.getRequestById>>>;
association: Awaited<ReturnType<typeof db.getAssociationById>>;
pricing: NonNullable<ReturnType<typeof computeSalleWorkflowPricing>>;
financialDecision: NonNullable<ReturnType<typeof parseMaterialFinancialDecision>>;
signedByUserId: number;
signedByName: string;
}) {
const signedAt = new Date().toISOString();
let nextFormData = updateSalleWorkflowDirectorStatus(input.request.formData, "signee");
nextFormData = mergeSalleWorkflowData(nextFormData, {
directorSignedAt: signedAt,
directorSignedByUserId: input.signedByUserId,
directorSignedByName: input.signedByName,
directorReturnComment: "",
directorReturnedAt: "",
});
const refreshedRequest = {
...input.request,
formData: nextFormData,
};
const texts = buildDefaultSalleWorkflowTexts(nextFormData);
const decisionPdf = await generateAndStoreSalleAdministrativeDecisionForRequest({
requestId: input.request.id,
decisionText: texts.decisionAdministrativeText,
signedAt,
signedByLabel: input.signedByName,
});
const quotePdf = input.financialDecision.financialMode === "location_payante"
? await generateAndStoreSalleQuoteForRequest({
requestId: input.request.id,
requestTitle: input.request.titre,
formData: parseRequestFormData(nextFormData),
association: input.association,
pricing: input.pricing,
})
: null;
let signedQuoteAttachment:
| {
filename: string;
content: Buffer;
contentType: string;
}
| undefined;
if (quotePdf) {
const signedQuotePdf = await generateSalleQuotePdf({
requestId: input.request.id,
requestTitle: input.request.titre,
formData: parseRequestFormData(nextFormData),
association: input.association,
pricing: input.pricing,
signedAt,
signedByLabel: input.signedByName,
});
const { url } = await storagePut(
`reservation-salle/devis-signe-${input.request.id}-${Date.now()}.pdf`,
signedQuotePdf.buffer,
"application/pdf"
);
nextFormData = mergeSalleWorkflowData(nextFormData, {
signedQuotePdfUrl: url,
signedQuotePdfName: signedQuotePdf.fileName,
});
signedQuoteAttachment = {
filename: signedQuotePdf.fileName,
content: signedQuotePdf.buffer,
contentType: "application/pdf",
};
}
const signedRequestPdf = await generateRequestPdfDocument({
...refreshedRequest,
status: "en_cours_traitement",
dateTraitement: new Date(),
formData: nextFormData,
});
const signedRequestStored = await storagePut(
`reservation-salle/formulaire-signe-${input.request.id}-${Date.now()}.pdf`,
signedRequestPdf.buffer,
signedRequestPdf.contentType
);
nextFormData = mergeSalleWorkflowData(nextFormData, {
signedDecisionPdfUrl: decisionPdf.decisionPdfUrl,
signedDecisionPdfName: decisionPdf.decisionPdfName,
signedRequestPdfUrl: signedRequestStored.url,
signedRequestPdfName: signedRequestPdf.fileName,
});
let generatedContractAttachment:
| {
filename: string;
content: Buffer;
contentType?: string;
}
| undefined;
let invoiceAttachment:
| {
filename: string;
content: Buffer;
contentType: string;
}
| undefined;
const generatedContract = await generateAndStoreSalleConventionForRequest({
request: {
...input.request,
formData: nextFormData,
},
association: input.association,
decision: {
financialMode: input.financialDecision.financialMode,
depositRequired: input.financialDecision.depositRequired,
depositAmountCents: input.financialDecision.depositAmountCents,
rentalAmountCents: input.financialDecision.rentalAmountCents,
pricingNotes: input.financialDecision.pricingNotes,
},
validatedByUserId: input.signedByUserId,
});
nextFormData = mergeMaterialFinancialDecision({
rawFormData: nextFormData,
decision: {
financialMode: input.financialDecision.financialMode,
depositRequired: input.financialDecision.depositRequired,
depositAmountCents: input.financialDecision.depositAmountCents,
rentalAmountCents: input.financialDecision.rentalAmountCents,
pricingNotes: input.financialDecision.pricingNotes,
contractStatus: "signee",
contractPdfUrl: generatedContract.contractPdfUrl,
contractPdfName: generatedContract.contractPdfName,
contractGeneratedAt: generatedContract.contractGeneratedAt,
contractValidatedByUserId: input.signedByUserId,
},
});
generatedContractAttachment = {
filename: generatedContract.contractPdfName,
content: generatedContract.buffer,
contentType: "application/pdf",
};
if (input.financialDecision.financialMode === "location_payante") {
const invoiceGeneratedAt = new Date().toISOString();
const invoice = await generateAndStoreSalleInvoiceForRequest({
requestId: input.request.id,
requestTitle: input.request.titre,
associationName:
input.association?.nomAssociation
|| parseRequestFormData(nextFormData).nomAssociation
|| "Association",
datesLabel: buildSalleDatesLabel(parseRequestFormData(nextFormData)),
pricing: input.pricing,
});
nextFormData = mergeSalleWorkflowData(nextFormData, {
invoicePdfUrl: invoice.invoicePdfUrl,
invoicePdfName: invoice.invoicePdfName,
invoiceGeneratedAt,
});
invoiceAttachment = {
filename: invoice.invoicePdfName,
content: invoice.buffer,
contentType: "application/pdf",
};
}
nextFormData = ensureSallePaymentTracking({
rawFormData: nextFormData,
billed: input.financialDecision.financialMode === "location_payante",
totalAmountCents: input.pricing.totalAmountCents,
});
return {
nextFormData,
signedAt,
quotePdfUrl: quotePdf?.quotePdfUrl || "",
quotePdfName: quotePdf?.quotePdfName || "",
signedQuoteAttachment,
signedDecisionAttachment: {
filename: decisionPdf.decisionPdfName,
content: decisionPdf.buffer,
contentType: "application/pdf",
},
signedRequestAttachment: {
filename: signedRequestPdf.fileName,
content: signedRequestPdf.buffer,
contentType: signedRequestPdf.contentType,
},
generatedContractAttachment,
invoiceAttachment,
};
}
async function ensureSalleInvoicePrepared(input: {
request: NonNullable<Awaited<ReturnType<typeof db.getRequestById>>>;
association: Awaited<ReturnType<typeof db.getAssociationById>>;
pricing: NonNullable<ReturnType<typeof computeSalleWorkflowPricing>>;
billed: boolean;
}) {
if (!input.billed) {
return {
nextFormData: input.request.formData || "",
invoicePdfUrl: "",
invoicePdfName: "",
invoiceGeneratedAt: "",
buffer: null as Buffer | null,
};
}
const workflow = getSalleWorkflowData(input.request.formData);
if (workflow.invoicePdfUrl && workflow.invoicePdfName) {
return {
nextFormData: input.request.formData || "",
invoicePdfUrl: workflow.invoicePdfUrl,
invoicePdfName: workflow.invoicePdfName,
invoiceGeneratedAt: workflow.invoiceGeneratedAt || "",
buffer: null as Buffer | null,
};
}
const formData = parseRequestFormData(input.request.formData);
const invoice = await generateAndStoreSalleInvoiceForRequest({
requestId: input.request.id,
requestTitle: input.request.titre,
associationName: input.association?.nomAssociation || formData.nomAssociation || "Association",
datesLabel: buildSalleDatesLabel(formData),
pricing: input.pricing,
});
const nextFormData = mergeSalleWorkflowData(input.request.formData, {
invoicePdfUrl: invoice.invoicePdfUrl,
invoicePdfName: invoice.invoicePdfName,
invoiceGeneratedAt: new Date().toISOString(),
});
const nextFormDataWithPayment = ensureSallePaymentTracking({
rawFormData: nextFormData,
billed: input.billed,
totalAmountCents: input.pricing.totalAmountCents,
});
return {
nextFormData: nextFormDataWithPayment,
invoicePdfUrl: invoice.invoicePdfUrl,
invoicePdfName: invoice.invoicePdfName,
invoiceGeneratedAt: new Date().toISOString(),
buffer: invoice.buffer,
};
}
async function sendSalleQuoteWorkflow(input: {
request: NonNullable<Awaited<ReturnType<typeof db.getRequestById>>>;
association: Awaited<ReturnType<typeof db.getAssociationById>>;
req: Parameters<typeof buildAppBaseUrl>[0];
user: {
id: number;
email?: string | null;
};
}) {
const request = input.request;
if (request.type !== "demande_salle") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Seules les demandes de salle peuvent envoyer un devis" });
}
const associationRecipient = input.association?.emailContact?.trim() || parseRequestFormData(request.formData).emailAssociation?.trim();
if (!associationRecipient) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Aucun email d'association n'est disponible pour envoyer le devis" });
}
const financialDecision = parseMaterialFinancialDecision(request.formData);
if (!financialDecision) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Les conditions financières doivent être renseignées avant l'envoi du devis" });
}
const pricing = computeSalleWorkflowPricing(request.formData);
if (!pricing || pricing.unsupportedSalles.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Tarification automatique indisponible pour : ${pricing?.unsupportedSalles.map((item) => item.salleNom).join(", ") || "cette demande"}`,
});
}
const formData = parseRequestFormData(request.formData);
const texts = buildDefaultSalleWorkflowTexts(request.formData);
const decisionPdf = await generateAndStoreSalleAdministrativeDecisionForRequest({
requestId: request.id,
decisionText: texts.decisionAdministrativeText,
});
const quotePdf = financialDecision.financialMode === "location_payante"
? await generateAndStoreSalleQuoteForRequest({
requestId: request.id,
requestTitle: request.titre,
formData,
association: input.association,
pricing,
})
: null;
const acceptToken = nanoid(48);
const refuseToken = nanoid(48);
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
await Promise.all([
db.createEmailActionToken({ token: acceptToken, requestId: request.id, action: "acceptation_devis_salle", expiresAt }),
db.createEmailActionToken({ token: refuseToken, requestId: request.id, action: "refus_devis_salle", expiresAt }),
]);
const baseUrl = buildAppBaseUrl(input.req);
const acceptUrl = `${baseUrl}/api/email-action/${acceptToken}`;
const refuseUrl = `${baseUrl}/api/email-action/${refuseToken}`;
const email = generateSalleQuoteEmail({
associationName: input.association?.nomAssociation || formData.nomAssociation || "association",
requestTitle: request.titre,
pricing,
acceptUrl,
refuseUrl,
decisionText: texts.decisionAdministrativeText,
});
const sentAt = new Date();
let updatedFormData = mergeSalleWorkflowData(request.formData, {
usageType: pricing.usageType,
frequency: pricing.frequency,
conditionsFinancieresText: CONDITIONS_FINANCIERES_SALLE_TEXT,
decisionAdministrativeText: texts.decisionAdministrativeText,
pricing,
quoteStatus: "en_attente_association",
quoteSentAt: sentAt.toISOString(),
quotePdfUrl: quotePdf?.quotePdfUrl || "",
quotePdfName: quotePdf?.quotePdfName || "",
administrativeDecisionPdfUrl: decisionPdf.decisionPdfUrl,
administrativeDecisionPdfName: decisionPdf.decisionPdfName,
directorStatus: "a_transmettre",
});
updatedFormData = ensureSallePaymentTracking({
rawFormData: updatedFormData,
billed: financialDecision.financialMode === "location_payante",
totalAmountCents: pricing.totalAmountCents,
});
const result = await sendOperationalEmail({
to: [associationRecipient],
subject: email.subject,
text: email.text,
html: email.html,
replyTo: input.user.email || undefined,
fromName: "Maison de la Jeunesse des Savanes - CCDS",
attachments: [
...(quotePdf ? [{ filename: quotePdf.quotePdfName, content: quotePdf.buffer, contentType: "application/pdf" }] : []),
{ filename: decisionPdf.decisionPdfName, content: decisionPdf.buffer, contentType: "application/pdf" },
],
});
if (!result.sent) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: `Le mail n'a pas pu être envoyé (${result.reason || "raison inconnue"})` });
}
return {
updatedFormData,
pricing,
financialMode: financialDecision.financialMode,
quotePdfUrl: quotePdf?.quotePdfUrl || null,
decisionPdfUrl: decisionPdf.decisionPdfUrl,
};
}
function getInvitationExpiry() {
return new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
}
function getInvitationStatusReason(invitation: {
usedAt: Date | null;
revokedAt: Date | null;
expiresAt: Date;
}) {
if (invitation.usedAt) return "used";
if (invitation.revokedAt) return "revoked";
if (new Date() > invitation.expiresAt) return "expired";
return null;
}
export const appRouter = router({
system: systemRouter,
auth: router({
me: publicProcedure.query(async opts => withEffectiveInternalAccess(opts.ctx.user)),
databaseStatus: publicProcedure.query(() => ({
configured: db.isDatabaseConfigured(),
})),
providers: publicProcedure.query(() => getOAuthProviderStatus()),
register: publicProcedure
.input(z.object({
name: z.string().min(2, "Le nom est requis"),
email: z.string().email("Adresse email invalide"),
password: z.string().min(8, "Le mot de passe doit contenir au moins 8 caracteres"),
invitationToken: z.string().min(1).optional(),
privacyConsent: dataPrivacyConsentSchema,
}))
.mutation(async ({ ctx, input }) => {
try {
assertDataPrivacyConsent(input.privacyConsent);
let registrationEmail = input.email;
let invitation: Awaited<ReturnType<typeof db.getAssociationInvitationByToken>> | null = null;
let directoryEntry: Awaited<ReturnType<typeof db.getAssociationDirectoryEntryById>> | undefined;
if (input.invitationToken) {
invitation = await db.getAssociationInvitationByToken(input.invitationToken);
if (!invitation) {
throw new Error("Invitation introuvable");
}
const invalidReason = getInvitationStatusReason(invitation);
if (invalidReason === "used") {
throw new Error("Cette invitation a déjà été utilisée");
}
if (invalidReason === "revoked") {
throw new Error("Cette invitation a été remplacée par une nouvelle");
}
if (invalidReason === "expired") {
throw new Error("Cette invitation a expiré");
}
directoryEntry = await db.getAssociationDirectoryEntryById(invitation.directoryEntryId);
if (!directoryEntry) {
throw new Error("Association du bordereau introuvable");
}
const existingAssociation = await db.getAssociationBySourceDirectoryEntryId(invitation.directoryEntryId);
if (existingAssociation) {
throw new Error("Cette association a déjà activé son espace");
}
if (normalizeEmail(input.email) !== invitation.emailOfficielNormalise) {
throw new Error("L'email saisi ne correspond pas à l'invitation");
}
registrationEmail = invitation.emailOfficiel;
}
const user = await registerLocalUser({
name: input.name,
email: registrationEmail,
password: input.password,
});
await recordDataPrivacyConsent(user.id, ctx.req, input.privacyConsent);
const matchedDirectoryEntry = directoryEntry
|| await db.getAssociationDirectoryEntryByNormalizedEmail(normalizeEmail(registrationEmail));
if (matchedDirectoryEntry) {
await ensureAssociationLinkedToDirectoryEntry(user.id, matchedDirectoryEntry.id);
await resolvePendingAssociationDirectoryReviewAfterAutoLink({
userId: user.id,
directoryEntryId: matchedDirectoryEntry.id,
sourceLabel: "l'inscription portail",
});
} else if (!invitation) {
const matchResult = await db.findAssociationDirectoryMatch({
nomAssociation: input.name,
email: registrationEmail,
});
await queueAssociationDirectoryReview({
userId: user.id,
sourceType: "portal_signup",
sourceLabel: "Inscription portail",
nomAssociation: input.name,
email: registrationEmail,
matchResult,
});
}
if (invitation && matchedDirectoryEntry) {
await db.markAssociationInvitationUsed(invitation.token, user.id);
}
const token = await createSessionToken(user);
setSessionCookie(ctx.req, ctx.res, token);
return withEffectiveInternalAccess(user);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: error instanceof Error ? error.message : "Inscription impossible",
});
}
}),
login: publicProcedure
.input(z.object({
email: z.string().email("Adresse email invalide"),
password: z.string().min(1, "Le mot de passe est requis"),
}))
.mutation(async ({ ctx, input }) => {
try {
const user = await loginLocalUser(input);
const token = await createSessionToken(user);
setSessionCookie(ctx.req, ctx.res, token);
return {
mfaRequired: false as const,
user: await withEffectiveInternalAccess(user),
};
} catch (error) {
if (isMfaCodeRequiredError(error)) {
return {
mfaRequired: true as const,
challengeType: error.challengeType,
challengeToken: error.challengeToken,
expiresAt: error.expiresAt,
maskedEmail: error.maskedEmail,
};
}
if (isAccountLockedError(error)) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: error.message,
});
}
throw new TRPCError({
code: "UNAUTHORIZED",
message: error instanceof Error ? error.message : "Email ou mot de passe incorrect",
});
}
}),
verifyLoginMfa: publicProcedure
.input(z.object({
challengeToken: z.string().min(12),
code: z.string().trim().regex(/^\d{6}$/, "Le code doit contenir 6 chiffres"),
}))
.mutation(async ({ ctx, input }) => {
try {
const user = await verifyLocalUserMfa(input);
const token = await createSessionToken(user);
setSessionCookie(ctx.req, ctx.res, token);
return await withEffectiveInternalAccess(user);
} catch (error) {
if (isAccountLockedError(error)) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: error.message,
});
}
throw new TRPCError({
code: "UNAUTHORIZED",
message: error instanceof Error ? error.message : "Verification impossible",
});
}
}),
resendLoginMfa: publicProcedure
.input(z.object({
challengeToken: z.string().min(12),
}))
.mutation(async ({ input }) => {
try {
const payload = await resendLocalUserMfaChallenge(input.challengeToken);
return {
challengeType: "email" as const,
...payload,
};
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: error instanceof Error ? error.message : "Impossible de renvoyer le code",
});
}
}),
logout: publicProcedure.mutation(({ ctx }) => {
clearSessionCookie(ctx.req, ctx.res);
return { success: true } as const;
}),
getSecuritySettings: protectedProcedure.query(async ({ ctx }) => {
const freshUser = await db.getUserById(ctx.user.id);
const loginMethod = freshUser?.loginMethod || ctx.user.loginMethod || null;
const smtpReady = await canSendOperationalEmails();
const canManageMfa = loginMethod === "local_jwt" && Boolean(freshUser?.email);
const effectiveRole = freshUser?.role || ctx.user.role;
const mfaPolicy = getInternalMfaPolicy(effectiveRole);
const mfaRequiredByRole = Boolean(mfaPolicy?.required);
const hasAuthenticator = Boolean(
freshUser?.mfaMethod === "authenticator_app" && freshUser?.mfaTotpSecretEncrypted
);
const emailMfaActive = Boolean((freshUser?.mfaEnabled || mfaRequiredByRole) && freshUser?.email);
return {
email: freshUser?.email || ctx.user.email || "",
role: effectiveRole,
loginMethod,
mfaEnabled: Boolean(freshUser?.mfaEnabled || mfaRequiredByRole),
canManageMfa,
smtpReady,
passwordHashAlgorithm: freshUser?.passwordHash?.split(":")[0] || null,
mfaMethod: hasAuthenticator ? "authenticator_app" : emailMfaActive ? "email_otp" : null,
mfaRequiredByRole,
hasPendingAuthenticatorSetup: Boolean(freshUser?.mfaTotpPendingSecretEncrypted),
mfaPolicy: {
required: mfaRequiredByRole,
preferredMethod: mfaPolicy?.preferredMethod || null,
enforcedMethod: mfaPolicy?.enforcedMethod || null,
emailFallbackAllowed: mfaPolicy?.emailFallbackAllowed ?? true,
phaseLabel: mfaPolicy?.phaseLabel || null,
currentCompliance:
hasAuthenticator
? "aligned"
: mfaPolicy?.enforcedMethod === "authenticator_app"
? "transition"
: emailMfaActive
? "aligned"
: mfaRequiredByRole
? "missing"
: "optional",
},
deletionRequestedAt: freshUser?.deletionRequestedAt || null,
purgeScheduledAt: freshUser?.purgeScheduledAt || null,
lockoutPolicy: {
maxAttempts: 5,
lockMinutes: 15,
},
};
}),
setMfaEnabled: protectedProcedure
.input(z.object({
enabled: z.boolean(),
}))
.mutation(async ({ ctx, input }) => {
const freshUser = await db.getUserById(ctx.user.id);
if (!freshUser) {
throw new TRPCError({ code: "NOT_FOUND", message: "Compte introuvable" });
}
if (freshUser.loginMethod !== "local_jwt" || !freshUser.email) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Le MFA par email est disponible uniquement pour les comptes locaux avec une adresse email valide.",
});
}
if (input.enabled && !(await canSendOperationalEmails())) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Le SMTP doit etre configure avant d'activer le MFA par email.",
});
}
const rolePolicy = getInternalMfaPolicy(freshUser.role);
const isRequiredByRole = Boolean(rolePolicy?.required);
if (!input.enabled && isRequiredByRole) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Le MFA est obligatoire pour ce rôle interne. Passez sur Authenticator ou gardez le code email.",
});
}
if (input.enabled && rolePolicy?.emailFallbackAllowed === false) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Ce rôle interne doit utiliser l'application Authenticator. Le code email n'est plus autorisé comme méthode normale.",
});
}
await db.updateUser(ctx.user.id, {
mfaEnabled: input.enabled,
mfaMethod: input.enabled ? "email_otp" : null,
mfaChallengeToken: null,
mfaCodeHash: null,
mfaCodeExpiresAt: null,
mfaCodeAttempts: 0,
mfaTotpSecretEncrypted: null,
mfaTotpPendingSecretEncrypted: null,
});
await db.createAuditLog({
userId: ctx.user.id,
action: input.enabled ? "activation_mfa" : "desactivation_mfa",
entityType: "user",
entityId: ctx.user.id,
details: JSON.stringify({
method: input.enabled ? "email_otp" : null,
}),
ipAddress: extractClientIp(ctx.req) || undefined,
});
return { success: true };
}),
startAuthenticatorSetup: protectedProcedure.mutation(async ({ ctx }) => {
const user = await db.getUserById(ctx.user.id);
if (!user) {
throw new TRPCError({ code: "NOT_FOUND", message: "Compte introuvable" });
}
try {
return await startAuthenticatorSetup(user);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: error instanceof Error ? error.message : "Impossible de démarrer la configuration Authenticator",
});
}
}),
confirmAuthenticatorSetup: protectedProcedure
.input(z.object({
code: z.string().trim().regex(/^\d{6}$/, "Le code doit contenir 6 chiffres"),
}))
.mutation(async ({ ctx, input }) => {
const user = await db.getUserById(ctx.user.id);
if (!user) {
throw new TRPCError({ code: "NOT_FOUND", message: "Compte introuvable" });
}
try {
await confirmAuthenticatorSetup(user, input.code);
return { success: true };
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: error instanceof Error ? error.message : "Impossible de confirmer Authenticator",
});
}
}),
cancelAuthenticatorSetup: protectedProcedure.mutation(async ({ ctx }) => {
const user = await db.getUserById(ctx.user.id);
if (!user) {
throw new TRPCError({ code: "NOT_FOUND", message: "Compte introuvable" });
}
await cancelAuthenticatorSetup(user);
return { success: true };
}),
exportMyData: protectedProcedure.query(async ({ ctx }) => {
const [user, association] = await Promise.all([
db.getUserById(ctx.user.id),
db.getAssociationByUserId(ctx.user.id),
]);
const documents = association ? await db.getDocumentsByAssociationId(association.id) : [];
const requests = association ? await db.getRequestsByAssociationId(association.id) : [];
const requestHistories = await Promise.all(
requests.map(async (request) => ({
requestId: request.id,
history: await db.getRequestHistoryByRequestId(request.id),
}))
);
return {
generatedAt: new Date().toISOString(),
exportVersion: "2026-06-12",
user: user
? {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
loginMethod: user.loginMethod,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
lastSignedIn: user.lastSignedIn,
privacyConsentVersion: user.privacyConsentVersion,
privacyConsentAcceptedAt: user.privacyConsentAcceptedAt,
privacyConsentContext: user.privacyConsentContext,
}
: null,
association,
documents: documents.map((document) => ({
id: document.id,
nom: document.nom,
type: document.type,
description: document.description,
fileUrl: document.fileUrl,
mimeType: document.mimeType,
fileSize: document.fileSize,
uploadedAt: document.uploadedAt,
updatedAt: document.updatedAt,
})),
requests,
requestHistories,
};
}),
deleteMyAccount: protectedProcedure
.input(z.object({
confirm: z.literal(true),
}))
.mutation(async ({ ctx }) => {
if (ctx.user.role !== "user") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Les comptes internes doivent etre clotures par l'administration.",
});
}
const user = await db.getUserById(ctx.user.id);
if (!user) {
throw new TRPCError({ code: "NOT_FOUND", message: "Compte introuvable" });
}
let deletion;
try {
deletion = await scheduleAccountDeletion(user);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: error instanceof Error ? error.message : "Impossible de planifier la suppression du compte",
});
}
await db.createAuditLog({
userId: ctx.user.id,
action: "demande_suppression_compte_utilisateur",
entityType: "user",
entityId: ctx.user.id,
details: JSON.stringify({
purgeScheduledAt: deletion.purgeScheduledAt,
ipAddress: extractClientIp(ctx.req),
}),
ipAddress: extractClientIp(ctx.req) || undefined,
});
clearSessionCookie(ctx.req, ctx.res);
return { success: true, purgeScheduledAt: deletion.purgeScheduledAt };
}),
cancelAccountDeletion: protectedProcedure.mutation(async ({ ctx }) => {
const user = await db.getUserById(ctx.user.id);
if (!user) {
throw new TRPCError({ code: "NOT_FOUND", message: "Compte introuvable" });
}
await cancelScheduledAccountDeletion(user);
await db.createAuditLog({
userId: ctx.user.id,
action: "annulation_suppression_compte_utilisateur",
entityType: "user",
entityId: ctx.user.id,
details: JSON.stringify({
ipAddress: extractClientIp(ctx.req),
}),
ipAddress: extractClientIp(ctx.req) || undefined,
});
return { success: true };
}),
}),
// ============== ASSOCIATION ROUTES ==============
association: router({
getMyProfile: protectedProcedure.query(async ({ ctx }) => {
const existing = await db.getAssociationByUserId(ctx.user.id);
if (existing) {
return existing;
}
if (!ctx.user.email) {
return null;
}
const directoryEntry = await db.getAssociationDirectoryEntryByNormalizedEmail(normalizeEmail(ctx.user.email));
if (!directoryEntry) {
return null;
}
const id = await db.createAssociation(createAssociationProfileFromDirectoryEntry(ctx.user.id, directoryEntry));
return db.getAssociationById(id);
}),
upsertProfile: protectedProcedure
.input(z.object({
nomAssociation: z.string().min(1),
siret: z.string().optional(),
rna: z.string().optional(),
thematiques: z.array(z.enum(associationThematicValues)).optional(),
adresse: z.string().optional(),
codePostal: z.string().optional(),
ville: z.string().optional(),
telephone: z.string().optional(),
emailContact: optionalEmailFieldSchema,
siteWeb: z.string().url().optional().or(z.literal('')),
facebookUrl: socialUrlSchema,
instagramUrl: socialUrlSchema,
dateCreation: z.string().optional(),
objetAssociation: z.string().optional(),
statutJuridique: z.enum(['association_loi_1901', 'association_reconnue_utilite_publique', 'fondation', 'autre']).optional(),
nomRepresentant: z.string().optional(),
fonctionRepresentant: z.string().optional(),
gouvernance: z.object({
representantLegal: z.object({
nom: z.string().min(1),
prenom: z.string().min(1),
email: z.string().email().optional().or(z.literal("")),
telephone: z.string().optional(),
fonction: z.enum(legalRepresentativeRoleValues),
}),
membres: z.array(
z.object({
nom: z.string().optional(),
prenom: z.string().optional(),
email: z.string().email().optional().or(z.literal("")),
telephone: z.string().optional(),
fonction: z.enum(governanceMemberRoleValues),
})
).optional(),
}).optional(),
privacyConsent: dataPrivacyConsentSchema,
}))
.mutation(async ({ ctx, input }) => {
assertDataPrivacyConsent(input.privacyConsent);
validateSocialUrl("facebookUrl", input.facebookUrl);
validateSocialUrl("instagramUrl", input.instagramUrl);
const existing = await db.getAssociationByUserId(ctx.user.id);
const rawGovernance: AssociationGovernance = input.gouvernance
? {
representantLegal: {
nom: input.gouvernance.representantLegal.nom,
prenom: input.gouvernance.representantLegal.prenom,
email: input.gouvernance.representantLegal.email || "",
telephone: input.gouvernance.representantLegal.telephone || "",
fonction: input.gouvernance.representantLegal.fonction,
},
membres: (input.gouvernance.membres ?? []).map((member) => ({
nom: member.nom || "",
prenom: member.prenom || "",
email: member.email || "",
telephone: member.telephone || "",
fonction: member.fonction,
})),
}
: buildGovernanceFromLegacyRepresentative(input.nomRepresentant, input.fonctionRepresentant);
const governance = sanitizeGovernance(rawGovernance);
const representantLegal = governance.representantLegal;
if (!representantLegal?.nom || !representantLegal?.prenom || !representantLegal?.fonction) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Le représentant légal principal est obligatoire.",
});
}
const nomRepresentant = buildDisplayName(representantLegal);
const data = {
nomAssociation: input.nomAssociation,
siret: input.siret,
rna: input.rna,
adresse: input.adresse,
codePostal: input.codePostal,
ville: input.ville,
telephone: input.telephone,
emailContact: input.emailContact?.trim() || null,
objetAssociation: input.objetAssociation,
statutJuridique: input.statutJuridique,
userId: ctx.user.id,
dateCreation: input.dateCreation ? new Date(input.dateCreation) : undefined,
siteWeb: input.siteWeb || null,
facebookUrl: normalizeSocialUrl(input.facebookUrl) || null,
instagramUrl: normalizeSocialUrl(input.instagramUrl) || null,
thematique: serializeAssociationThematics(input.thematiques),
nomRepresentant,
fonctionRepresentant: representantLegal.fonction,
gouvernance: serializeAssociationGovernance(governance),
profileComplete: !!(input.nomAssociation && input.adresse && input.ville && nomRepresentant),
};
if (existing) {
const sourceDirectoryEntryId = existing.sourceDirectoryEntryId || null;
let nextSourceDirectoryEntryId = sourceDirectoryEntryId;
if (!sourceDirectoryEntryId) {
const matchResult = await db.findAssociationDirectoryMatch({
nomAssociation: input.nomAssociation,
email: input.emailContact || ctx.user.email || null,
siret: input.siret,
rna: input.rna,
ville: input.ville,
telephone: input.telephone,
nomRepresentant,
});
if (matchResult.status === "matched" && matchResult.matchedEntry) {
const occupied = await db.getAssociationBySourceDirectoryEntryId(matchResult.matchedEntry.id);
if (!occupied || occupied.id === existing.id) {
nextSourceDirectoryEntryId = matchResult.matchedEntry.id;
} else {
await queueAssociationDirectoryReview({
userId: ctx.user.id,
sourceType: "portal_signup",
sourceLabel: "Profil portail",
nomAssociation: input.nomAssociation,
email: input.emailContact || ctx.user.email || null,
siret: input.siret,
rna: input.rna,
ville: input.ville,
telephone: input.telephone,
nomRepresentant,
matchResult: {
...matchResult,
status: "ambiguous",
reason: "Une fiche du bordereau compatible existe déjà, mais elle est rattachée à un autre compte portail.",
},
});
}
} else {
await queueAssociationDirectoryReview({
userId: ctx.user.id,
sourceType: "portal_signup",
sourceLabel: "Profil portail",
nomAssociation: input.nomAssociation,
email: input.emailContact || ctx.user.email || null,
siret: input.siret,
rna: input.rna,
ville: input.ville,
telephone: input.telephone,
nomRepresentant,
matchResult,
});
}
}
if (nextSourceDirectoryEntryId) {
await resolvePendingAssociationDirectoryReviewAfterAutoLink({
userId: ctx.user.id,
directoryEntryId: nextSourceDirectoryEntryId,
sourceLabel: "la mise à jour de la fiche association",
});
}
await db.updateAssociation(existing.id, {
...data,
...(nextSourceDirectoryEntryId ? { sourceDirectoryEntryId: nextSourceDirectoryEntryId } : {}),
});
const updatedAssociation = await db.getAssociationById(existing.id);
await syncAssociationBackToDirectoryEntry(updatedAssociation);
return { id: existing.id, updated: true };
} else {
let sourceDirectoryEntryId: number | undefined;
const matchResult = await db.findAssociationDirectoryMatch({
nomAssociation: input.nomAssociation,
email: input.emailContact || ctx.user.email || null,
siret: input.siret,
rna: input.rna,
ville: input.ville,
telephone: input.telephone,
nomRepresentant,
});
if (matchResult.status === "matched" && matchResult.matchedEntry) {
const occupied = await db.getAssociationBySourceDirectoryEntryId(matchResult.matchedEntry.id);
if (!occupied) {
sourceDirectoryEntryId = matchResult.matchedEntry.id;
} else {
await queueAssociationDirectoryReview({
userId: ctx.user.id,
sourceType: "portal_signup",
sourceLabel: "Profil portail",
nomAssociation: input.nomAssociation,
email: input.emailContact || ctx.user.email || null,
siret: input.siret,
rna: input.rna,
ville: input.ville,
telephone: input.telephone,
nomRepresentant,
matchResult: {
...matchResult,
status: "ambiguous",
reason: "Une fiche du bordereau compatible existe déjà, mais elle est déjà rattachée à un autre compte portail.",
},
});
}
} else {
await queueAssociationDirectoryReview({
userId: ctx.user.id,
sourceType: "portal_signup",
sourceLabel: "Profil portail",
nomAssociation: input.nomAssociation,
email: input.emailContact || ctx.user.email || null,
siret: input.siret,
rna: input.rna,
ville: input.ville,
telephone: input.telephone,
nomRepresentant,
matchResult,
});
}
if (sourceDirectoryEntryId) {
await resolvePendingAssociationDirectoryReviewAfterAutoLink({
userId: ctx.user.id,
directoryEntryId: sourceDirectoryEntryId,
sourceLabel: "la création de la fiche association",
});
}
const id = await db.createAssociation({
...(data as any),
...(sourceDirectoryEntryId ? { sourceDirectoryEntryId } : {}),
});
const createdAssociation = await db.getAssociationById(id);
await syncAssociationBackToDirectoryEntry(createdAssociation);
// Create notification for new association
await db.createAdminNotification({
type: 'nouvelle_association',
titre: 'Nouvelle association inscrite',
message: `L'association "${input.nomAssociation}" vient de s'inscrire sur le portail.`,
lien: `/admin?tab=associations`,
});
return { id, updated: false };
}
await recordDataPrivacyConsent(ctx.user.id, ctx.req, input.privacyConsent);
}),
getById: adminProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const association = await db.getAssociationById(input.id);
if (!association) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Association non trouvée' });
}
// Get related data
const [documents, requests] = await Promise.all([
db.getDocumentsByAssociationId(input.id),
db.getRequestsByAssociationId(input.id),
]);
return { ...association, documents, requests };
}),
listAll: adminProcedure.query(async () => {
return db.getAllAssociations();
}),
search: adminProcedure
.input(z.object({
search: z.string().optional(),
ville: z.string().optional(),
profileComplete: z.boolean().optional(),
isActive: z.boolean().optional(),
limit: z.number().optional(),
offset: z.number().optional(),
}))
.query(async ({ input }) => {
return db.searchAssociations(input);
}),
getCommuneCounts: adminProcedure.query(async () => {
return db.getAssociationCommuneCounts();
}),
toggleStatus: adminProcedure
.input(z.object({
id: z.number(),
isActive: z.boolean(),
}))
.mutation(async ({ ctx, input }) => {
await db.toggleAssociationStatus(input.id, input.isActive);
await logAdminAction(ctx.user.id, input.isActive ? 'activation' : 'desactivation', 'association', input.id);
return { success: true };
}),
export: adminProcedure.query(async () => {
const associations = await db.getAllAssociations();
return associations.map(a => ({
id: a.id,
sourceDirectoryEntryId: a.sourceDirectoryEntryId,
nomAssociation: a.nomAssociation,
siret: a.siret || '',
rna: a.rna || '',
thematique: a.thematique || '',
adresse: a.adresse || '',
codePostal: a.codePostal || '',
ville: a.ville || '',
telephone: a.telephone || '',
emailContact: a.emailContact || '',
statutJuridique: a.statutJuridique || '',
nomRepresentant: a.nomRepresentant || '',
fonctionRepresentant: a.fonctionRepresentant || '',
profileComplete: a.profileComplete ? 'Oui' : 'Non',
isActive: a.isActive ? 'Actif' : 'Inactif',
createdAt: a.createdAt?.toISOString() || '',
}));
}),
}),
associationDirectory: router({
getSummary: adminProcedure.query(async () => {
return db.getAssociationDirectoryEntriesSummary();
}),
listReviewQueue: adminProcedure
.input(z.object({
status: z.enum(["pending", "linked", "created", "ignored"]).optional(),
sourceType: z.enum(["portal_signup", "helloasso"]).optional(),
limit: z.number().min(1).max(100).optional(),
}).optional())
.query(async ({ input }) => {
const reviews = await db.listAssociationDirectoryReviews({
status: input?.status,
sourceType: input?.sourceType,
limit: input?.limit || 50,
});
return reviews.map((review) => {
let payload: Record<string, any> | null = null;
try {
payload = review.payload ? JSON.parse(review.payload) : null;
} catch {
payload = null;
}
return {
...review,
candidates: Array.isArray(payload?.candidates) ? payload?.candidates : [],
reason: typeof payload?.reason === "string" ? payload.reason : "",
proposedTelephone: typeof payload?.proposedTelephone === "string" ? payload.proposedTelephone : "",
proposedNomRepresentant: typeof payload?.proposedNomRepresentant === "string" ? payload.proposedNomRepresentant : "",
};
});
}),
getPendingUpdateProposal: adminProcedure
.input(z.object({
directoryEntryId: z.number(),
sourceType: z.enum(["official_registry", "helloasso"]).optional(),
}))
.query(async ({ input }) => {
const proposal = await db.getLatestPendingAssociationDirectoryUpdateProposalByEntryId(
input.directoryEntryId,
input.sourceType
);
if (!proposal) return null;
let payload: Record<string, any> | null = null;
try {
payload = proposal.payload ? JSON.parse(proposal.payload) : null;
} catch {
payload = null;
}
return {
...proposal,
updates: payload?.updates ?? {},
changes: Array.isArray(payload?.changes) ? payload.changes : [],
};
}),
getById: adminProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const details = await db.getAssociationDirectoryEntryDetails(input.id);
if (!details) {
throw new TRPCError({ code: "NOT_FOUND", message: "Association du bordereau introuvable" });
}
return details;
}),
createManual: adminProcedure
.input(associationDirectoryManualInputSchema)
.mutation(async ({ ctx, input }) => {
validateSocialUrl("facebookUrl", input.facebookUrl);
validateSocialUrl("instagramUrl", input.instagramUrl);
const entryId = await db.createAssociationDirectoryEntry({
nomAssociation: input.nomAssociation,
emailOfficiel: input.emailOfficiel || null,
emailOfficielNormalise: input.emailOfficiel ? normalizeEmail(input.emailOfficiel) : null,
siret: normalizeSiretValue(input.siret),
rna: normalizeRnaValue(input.rna),
thematique: serializeAssociationThematics(input.thematiques),
adresse: input.adresse || null,
codePostal: input.codePostal || null,
ville: input.ville || null,
telephone: input.telephone || null,
siteWeb: input.siteWeb || null,
facebookUrl: normalizeSocialUrl(input.facebookUrl) || null,
instagramUrl: normalizeSocialUrl(input.instagramUrl) || null,
dateCreation: input.dateCreation ? new Date(input.dateCreation) : null,
objetAssociation: input.objetAssociation || null,
statutJuridique: input.statutJuridique || "association_loi_1901",
nomRepresentant: input.nomRepresentant || null,
fonctionRepresentant: input.fonctionRepresentant || null,
sourceFileName: "saisie_manuelle_admin",
sourceRowNumber: null,
sourceFingerprint: `manual:${Date.now()}:${normalizeEmail(ctx.user.email || String(ctx.user.id))}`,
isActive: input.isActive ?? true,
importedAt: new Date(),
externalSourceStatus: "manual_entry",
externalSourceLabel: "Saisie manuelle admin",
});
if (input.reviewId) {
const review = await db.getAssociationDirectoryReviewById(input.reviewId);
if (review) {
await db.updateAssociationDirectoryReview(input.reviewId, {
status: "created",
resolvedDirectoryEntryId: entryId,
resolutionNote: `Fiche créée manuellement par ${ctx.user.email || ctx.user.name || "admin"}.`,
});
if (review.userId) {
await ensureAssociationLinkedToDirectoryEntry(review.userId, entryId);
}
}
}
await logAdminAction(ctx.user.id, "create_manual_directory_entry", "association_directory", entryId, {
reviewId: input.reviewId || null,
nomAssociation: input.nomAssociation,
});
return { success: true, id: entryId } as const;
}),
updateManual: adminProcedure
.input(associationDirectoryManualInputSchema.extend({
id: z.number(),
}))
.mutation(async ({ ctx, input }) => {
validateSocialUrl("facebookUrl", input.facebookUrl);
validateSocialUrl("instagramUrl", input.instagramUrl);
const existing = await db.getAssociationDirectoryEntryById(input.id);
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Association du bordereau introuvable" });
}
await db.updateAssociationDirectoryEntry(input.id, {
nomAssociation: input.nomAssociation,
emailOfficiel: input.emailOfficiel || null,
emailOfficielNormalise: input.emailOfficiel ? normalizeEmail(input.emailOfficiel) : null,
siret: normalizeSiretValue(input.siret),
rna: normalizeRnaValue(input.rna),
thematique: serializeAssociationThematics(input.thematiques),
adresse: input.adresse || null,
codePostal: input.codePostal || null,
ville: input.ville || null,
telephone: input.telephone || null,
siteWeb: input.siteWeb || null,
facebookUrl: normalizeSocialUrl(input.facebookUrl) || null,
instagramUrl: normalizeSocialUrl(input.instagramUrl) || null,
dateCreation: input.dateCreation ? new Date(input.dateCreation) : null,
objetAssociation: input.objetAssociation || null,
statutJuridique: input.statutJuridique || existing.statutJuridique || "association_loi_1901",
nomRepresentant: input.nomRepresentant || null,
fonctionRepresentant: input.fonctionRepresentant || null,
isActive: input.isActive ?? existing.isActive,
externalSourceStatus: "manual_entry_updated",
externalSourceLabel: "Fiche bordereau modifiée dans l'admin",
});
await logAdminAction(ctx.user.id, "update_manual_directory_entry", "association_directory", input.id, {
nomAssociation: input.nomAssociation,
});
return { success: true } as const;
}),
listLatest: adminProcedure
.input(z.object({
search: z.string().optional(),
registrationStatus: z.enum(["all", "registered", "unregistered"]).optional(),
commune: z.enum(associationCommuneOptions.map(option => option.value) as [string, ...string[]]).optional(),
thematique: z.enum(["all", ...associationThematicValues] as [string, ...string[]]).optional(),
limit: z.number().min(1).max(200).optional(),
}).optional())
.query(async ({ input }) => {
const result = await db.listAssociationDirectoryEntriesWithStatus({
search: input?.search,
registrationStatus: input?.registrationStatus,
commune: input?.commune as any,
thematique: input?.thematique as any,
limit: input?.limit || 50,
});
const invitationSummaries = await db.getAssociationInvitationSummariesForDirectoryEntryIds(
result.data.map(entry => entry.id)
);
return {
total: result.total,
data: result.data.map((entry) => ({
...entry,
thematiques: parseAssociationThematics(entry.thematique),
invitationStatus: invitationSummaries[entry.id],
})),
};
}),
listPortal: publicProcedure
.input(z.object({
search: z.string().optional(),
registrationStatus: z.enum(["all", "registered", "unregistered"]).optional(),
commune: z.enum(associationCommuneOptions.map(option => option.value) as [string, ...string[]]).optional(),
thematique: z.enum(["all", ...associationThematicValues] as [string, ...string[]]).optional(),
limit: z.number().min(1).max(200).optional(),
}).optional())
.query(async ({ input }) => {
const result = await db.listAssociationDirectoryEntriesWithStatus({
search: input?.search,
registrationStatus: input?.registrationStatus,
commune: input?.commune as any,
thematique: input?.thematique as any,
limit: input?.limit || 50,
});
const invitationSummaries = await db.getAssociationInvitationSummariesForDirectoryEntryIds(
result.data.map(entry => entry.id)
);
return {
total: result.total,
data: result.data.map((entry) => ({
id: entry.id,
nomAssociation: entry.nomAssociation,
emailOfficiel: entry.emailOfficiel,
siret: entry.siret,
rna: entry.rna,
thematiques: parseAssociationThematics(entry.thematique),
adresse: entry.adresse,
codePostal: entry.codePostal,
ville: entry.ville,
geoSource: entry.geoSource,
geoPrecision: entry.geoPrecision,
siteWeb: entry.siteWeb,
facebookUrl: entry.facebookUrl,
instagramUrl: entry.instagramUrl,
importedAt: entry.importedAt,
registered: entry.registered,
registeredAt: entry.registeredAt,
invitationStatus: invitationSummaries[entry.id],
})),
};
}),
listMap: publicProcedure
.input(z.object({
search: z.string().optional(),
registrationStatus: z.enum(["all", "registered", "unregistered"]).optional(),
commune: z.enum(associationCommuneOptions.map(option => option.value) as [string, ...string[]]).optional(),
thematique: z.enum(["all", ...associationThematicValues] as [string, ...string[]]).optional(),
limit: z.number().min(1).max(1000).optional(),
}).optional())
.query(async ({ input }) => {
const result = await db.listAssociationDirectoryMapEntries({
search: input?.search,
registrationStatus: input?.registrationStatus,
commune: input?.commune as any,
thematique: input?.thematique as any,
limit: input?.limit || 500,
});
return {
total: result.total,
data: result.data.map((entry: any) => ({
...entry,
thematiques: parseAssociationThematics(entry.thematique),
})),
};
}),
getMapEntry: publicProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const details = await db.getAssociationDirectoryEntryDetails(input.id);
if (!details) {
throw new TRPCError({ code: "NOT_FOUND", message: "Association introuvable" });
}
const payload = buildPublicAssociationDirectoryPayload(details);
if (!payload) {
throw new TRPCError({ code: "FORBIDDEN", message: "Cette association n'est pas visible sur la carte publique" });
}
return {
...payload,
sourceLabel: details.entry.externalSourceLabel,
geoSource: details.entry.geoSource,
};
}),
setManualCoordinates: adminProcedure
.input(z.object({
id: z.number(),
latitude: z.number().min(-90).max(90).nullable(),
longitude: z.number().min(-180).max(180).nullable(),
geoPrecision: z.enum(["exact_address", "commune_center", "hidden"]),
}))
.mutation(async ({ ctx, input }) => {
const details = await db.getAssociationDirectoryEntryDetails(input.id);
if (!details) {
throw new TRPCError({ code: "NOT_FOUND", message: "Association du bordereau introuvable" });
}
const hasCoordinates = input.latitude !== null && input.longitude !== null;
await db.updateAssociationDirectoryEntry(input.id, {
latitude: hasCoordinates && input.latitude !== null ? input.latitude.toFixed(6) : null,
longitude: hasCoordinates && input.longitude !== null ? input.longitude.toFixed(6) : null,
geoPrecision: input.geoPrecision,
geoSource: hasCoordinates ? "manual" : null,
geoLastSyncedAt: new Date(),
externalSourceStatus: hasCoordinates ? "manual_coordinates" : "manual_coordinates_cleared",
externalSourceLabel: hasCoordinates ? "Coordonnées définies manuellement" : null,
});
await logAdminAction(ctx.user.id, "set_manual_geo", "association_directory", input.id, {
latitude: input.latitude,
longitude: input.longitude,
geoPrecision: input.geoPrecision,
});
return { success: true };
}),
syncExternalData: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ ctx, input }) => {
const details = await db.getAssociationDirectoryEntryDetails(input.id);
if (!details) {
throw new TRPCError({ code: "NOT_FOUND", message: "Association du bordereau introuvable" });
}
const updates = await computeAssociationDirectoryGeoUpdate(details.entry, details.association);
await db.updateAssociationDirectoryEntry(input.id, updates);
await logAdminAction(ctx.user.id, "sync_geo", "association_directory", input.id, {
externalSourceStatus: updates.externalSourceStatus,
geoSource: updates.geoSource,
});
return {
success: true,
externalSourceStatus: updates.externalSourceStatus,
geoSource: updates.geoSource,
};
}),
syncExternalDataBatch: adminProcedure
.input(z.object({
commune: z.enum(associationCommuneOptions.map(option => option.value) as [string, ...string[]]).optional(),
limit: z.number().min(1).max(500).optional(),
}).optional())
.mutation(async ({ ctx, input }) => {
const result = await db.listAssociationDirectoryEntriesWithStatus({
commune: input?.commune as any,
limit: input?.limit || 200,
registrationStatus: "all",
});
let updated = 0;
let unresolved = 0;
for (const entry of result.data) {
const details = await db.getAssociationDirectoryEntryDetails(entry.id);
if (!details) continue;
const updates = await computeAssociationDirectoryGeoUpdate(details.entry, details.association);
await db.updateAssociationDirectoryEntry(entry.id, updates);
if (updates.latitude && updates.longitude) {
updated += 1;
} else {
unresolved += 1;
}
}
await logAdminAction(ctx.user.id, "sync_geo_batch", "association_directory", undefined, {
commune: input?.commune || "all",
updated,
unresolved,
total: result.data.length,
});
return {
success: true,
total: result.data.length,
updated,
unresolved,
};
}),
syncReferenceData: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ ctx, input }) => {
const details = await db.getAssociationDirectoryEntryDetails(input.id);
if (!details) {
throw new TRPCError({ code: "NOT_FOUND", message: "Association du bordereau introuvable" });
}
const proposal = await computeAssociationDirectoryReferenceProposal(details.entry, details.association);
if (!proposal.hasChanges) {
await logAdminAction(ctx.user.id, "scan_reference_data", "association_directory", input.id, {
hasChanges: false,
sourceLabel: proposal.sourceLabel,
summary: proposal.summary,
});
return {
success: true,
hasChanges: false,
proposalId: null,
summary: proposal.summary,
sourceLabel: proposal.sourceLabel,
changes: [],
};
}
const existingPending = await db.getLatestPendingAssociationDirectoryUpdateProposalByEntryId(
input.id,
"official_registry"
);
const payload = JSON.stringify({
updates: proposal.updates,
changes: proposal.changes,
});
let proposalId = existingPending?.id || null;
if (existingPending) {
await db.updateAssociationDirectoryUpdateProposal(existingPending.id, {
sourceLabel: proposal.sourceLabel,
summary: proposal.summary,
payload,
status: "pending",
dismissedAt: null,
appliedAt: null,
});
} else {
proposalId = await db.createAssociationDirectoryUpdateProposal({
directoryEntryId: input.id,
sourceType: "official_registry",
sourceLabel: proposal.sourceLabel,
summary: proposal.summary,
payload,
status: "pending",
});
}
await logAdminAction(ctx.user.id, "sync_reference_data", "association_directory", input.id, {
hasChanges: true,
proposalId,
sourceLabel: proposal.sourceLabel,
changedFields: proposal.changes.map((change) => change.field),
});
return {
success: true,
hasChanges: true,
proposalId,
summary: proposal.summary,
sourceLabel: proposal.sourceLabel,
changes: proposal.changes,
};
}),
syncReferenceDataBatch: adminProcedure
.input(z.object({
search: z.string().optional(),
registrationStatus: z.enum(["all", "registered", "unregistered"]).optional(),
commune: z.enum(associationCommuneOptions.map(option => option.value) as [string, ...string[]]).optional(),
thematique: z.enum(["all", ...associationThematicValues] as [string, ...string[]]).optional(),
limit: z.number().min(1).max(200).optional(),
}).optional())
.mutation(async ({ ctx, input }) => {
const result = await db.listAssociationDirectoryEntriesWithStatus({
search: input?.search,
registrationStatus: input?.registrationStatus,
commune: input?.commune as any,
thematique: input?.thematique as any,
limit: input?.limit || 50,
});
let proposalsCreated = 0;
let unchanged = 0;
let failed = 0;
for (const row of result.data) {
try {
const details = await db.getAssociationDirectoryEntryDetails(row.id);
if (!details) continue;
const proposal = await computeAssociationDirectoryReferenceProposal(details.entry, details.association);
if (!proposal.hasChanges) {
unchanged += 1;
continue;
}
const existingPending = await db.getLatestPendingAssociationDirectoryUpdateProposalByEntryId(
row.id,
"official_registry"
);
const payload = JSON.stringify({
updates: proposal.updates,
changes: proposal.changes,
});
if (existingPending) {
await db.updateAssociationDirectoryUpdateProposal(existingPending.id, {
sourceLabel: proposal.sourceLabel,
summary: proposal.summary,
payload,
status: "pending",
dismissedAt: null,
appliedAt: null,
});
} else {
await db.createAssociationDirectoryUpdateProposal({
directoryEntryId: row.id,
sourceType: "official_registry",
sourceLabel: proposal.sourceLabel,
summary: proposal.summary,
payload,
status: "pending",
});
}
proposalsCreated += 1;
} catch {
failed += 1;
}
}
await logAdminAction(ctx.user.id, "sync_reference_data_batch", "association_directory", undefined, {
search: input?.search || "",
registrationStatus: input?.registrationStatus || "all",
commune: input?.commune || "all",
thematique: input?.thematique || "all",
total: result.data.length,
proposalsCreated,
unchanged,
failed,
});
return {
success: true,
total: result.data.length,
proposalsCreated,
unchanged,
failed,
};
}),
applyUpdateProposal: adminProcedure
.input(z.object({ proposalId: z.number() }))
.mutation(async ({ ctx, input }) => {
const proposal = await db.getAssociationDirectoryUpdateProposalById(input.proposalId);
if (!proposal) {
throw new TRPCError({ code: "NOT_FOUND", message: "Proposition denrichissement introuvable" });
}
if (proposal.status !== "pending") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Cette proposition a déjà été traitée" });
}
let payload: Record<string, any> | null = null;
try {
payload = proposal.payload ? JSON.parse(proposal.payload) : null;
} catch {
payload = null;
}
const updates = payload?.updates;
if (!updates || typeof updates !== "object") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Le contenu de la proposition est invalide" });
}
await db.updateAssociationDirectoryEntry(proposal.directoryEntryId, updates);
await db.updateAssociationDirectoryUpdateProposal(proposal.id, {
status: "applied",
appliedAt: new Date(),
});
await logAdminAction(ctx.user.id, "apply_directory_update_proposal", "association_directory", proposal.directoryEntryId, {
proposalId: proposal.id,
sourceType: proposal.sourceType,
sourceLabel: proposal.sourceLabel,
});
return { success: true } as const;
}),
dismissUpdateProposal: adminProcedure
.input(z.object({ proposalId: z.number() }))
.mutation(async ({ ctx, input }) => {
const proposal = await db.getAssociationDirectoryUpdateProposalById(input.proposalId);
if (!proposal) {
throw new TRPCError({ code: "NOT_FOUND", message: "Proposition denrichissement introuvable" });
}
if (proposal.status !== "pending") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Cette proposition a déjà été traitée" });
}
await db.updateAssociationDirectoryUpdateProposal(proposal.id, {
status: "dismissed",
dismissedAt: new Date(),
});
await logAdminAction(ctx.user.id, "dismiss_directory_update_proposal", "association_directory", proposal.directoryEntryId, {
proposalId: proposal.id,
sourceType: proposal.sourceType,
sourceLabel: proposal.sourceLabel,
});
return { success: true } as const;
}),
syncHelloAssoById: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ ctx, input }) => {
const details = await db.getAssociationDirectoryEntryDetails(input.id);
if (!details) {
throw new TRPCError({ code: "NOT_FOUND", message: "Association du bordereau introuvable" });
}
const settings = await getHelloAssoSettings();
const result = await computeAssociationDirectoryHelloAssoUpdate(details.entry, details.association, settings);
await db.updateAssociationDirectoryEntry(input.id, result.updates);
await logAdminAction(ctx.user.id, "sync_helloasso", "association_directory", input.id, {
matched: result.matched,
reason: result.reason,
slug: result.slug,
candidateCount: result.candidateCount,
externalSourceStatus: result.updates.externalSourceStatus,
});
return {
success: true,
...result,
};
}),
syncHelloAssoBatch: adminProcedure
.input(z.object({
search: z.string().optional(),
registrationStatus: z.enum(["all", "registered", "unregistered"]).optional(),
commune: z.enum(associationCommuneOptions.map(option => option.value) as [string, ...string[]]).optional(),
thematique: z.enum(["all", ...associationThematicValues] as [string, ...string[]]).optional(),
limit: z.number().min(1).max(200).optional(),
}).optional())
.mutation(async ({ ctx, input }) => {
const settings = await getHelloAssoSettings();
const client = new HelloAssoSyncClient(settings);
const result = await db.listAssociationDirectoryEntriesWithStatus({
search: input?.search,
registrationStatus: input?.registrationStatus,
commune: input?.commune as any,
thematique: input?.thematique as any,
limit: input?.limit || 50,
});
let updated = 0;
let skipped = 0;
let matched = 0;
for (const entry of result.data) {
const details = await db.getAssociationDirectoryEntryDetails(entry.id);
if (!details) continue;
const syncResult = await computeAssociationDirectoryHelloAssoUpdate(
details.entry,
details.association,
settings,
client
);
await db.updateAssociationDirectoryEntry(entry.id, syncResult.updates);
if (syncResult.matched) {
matched += 1;
updated += 1;
} else {
skipped += 1;
}
}
await logAdminAction(ctx.user.id, "sync_helloasso_batch", "association_directory", undefined, {
search: input?.search || "",
registrationStatus: input?.registrationStatus || "all",
commune: input?.commune || "all",
thematique: input?.thematique || "all",
total: result.data.length,
updated,
skipped,
});
return {
success: true,
total: result.data.length,
updated,
matched,
skipped,
};
}),
resolveReviewLink: adminProcedure
.input(z.object({
reviewId: z.number(),
directoryEntryId: z.number(),
}))
.mutation(async ({ ctx, input }) => {
const [review, entry] = await Promise.all([
db.getAssociationDirectoryReviewById(input.reviewId),
db.getAssociationDirectoryEntryById(input.directoryEntryId),
]);
if (!review) {
throw new TRPCError({ code: "NOT_FOUND", message: "Correspondance à valider introuvable" });
}
if (!entry) {
throw new TRPCError({ code: "NOT_FOUND", message: "Association du bordereau introuvable" });
}
await db.updateAssociationDirectoryReview(input.reviewId, {
status: "linked",
resolvedDirectoryEntryId: input.directoryEntryId,
resolutionNote: `Rapprochement validé manuellement par ${ctx.user.email || ctx.user.name || "admin"}.`,
});
if (review.userId) {
await ensureAssociationLinkedToDirectoryEntry(review.userId, input.directoryEntryId);
}
await logAdminAction(ctx.user.id, "resolve_directory_review_link", "association_directory_review", input.reviewId, {
directoryEntryId: input.directoryEntryId,
});
return { success: true } as const;
}),
ignoreReview: adminProcedure
.input(z.object({
reviewId: z.number(),
resolutionNote: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const review = await db.getAssociationDirectoryReviewById(input.reviewId);
if (!review) {
throw new TRPCError({ code: "NOT_FOUND", message: "Correspondance à valider introuvable" });
}
await db.updateAssociationDirectoryReview(input.reviewId, {
status: "ignored",
resolutionNote: input.resolutionNote || `Revue ignorée par ${ctx.user.email || ctx.user.name || "admin"}.`,
});
await logAdminAction(ctx.user.id, "ignore_directory_review", "association_directory_review", input.reviewId);
return { success: true } as const;
}),
previewImport: superAdminProcedure
.input(z.object({
fileData: z.string(),
fileName: z.string(),
mimeType: z.string(),
}))
.mutation(async ({ input }) => {
const fileBuffer = Buffer.from(input.fileData, "base64");
const preview = parseAssociationDirectoryWorkbook(fileBuffer, input.fileName);
return {
fileName: preview.fileName,
totalRows: preview.totalRows,
validRows: preview.validRows,
missingEmailRows: preview.missingEmailRows,
duplicateEmailRows: preview.duplicateEmailRows,
duplicateGroups: preview.duplicateGroups,
previewRows: preview.previewRows.slice(0, 100),
};
}),
importWorkbook: superAdminProcedure
.input(z.object({
fileData: z.string(),
fileName: z.string(),
mimeType: z.string(),
duplicateSelections: z.record(z.string(), z.number()).optional(),
}))
.mutation(async ({ ctx, input }) => {
const fileBuffer = Buffer.from(input.fileData, "base64");
const preview = parseAssociationDirectoryWorkbook(fileBuffer, input.fileName);
const unresolvedDuplicateGroups = preview.duplicateGroups.filter(
group => !input.duplicateSelections?.[group.emailOfficielNormalise]
);
if (unresolvedDuplicateGroups.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Choisis une ligne à conserver pour chaque email dupliqué avant de lancer l'import",
});
}
const rowsToImport = resolveAssociationDirectoryImportRows(preview, input.duplicateSelections);
let created = 0;
let updated = 0;
for (const row of rowsToImport) {
const result = await db.upsertAssociationDirectoryEntry({
nomAssociation: row.nomAssociation,
emailOfficiel: row.emailOfficiel,
emailOfficielNormalise: row.emailOfficielNormalise,
siret: row.siret,
rna: row.rna,
adresse: row.adresse,
codePostal: row.codePostal,
ville: row.ville,
telephone: row.telephone,
siteWeb: row.siteWeb,
facebookUrl: row.facebookUrl,
instagramUrl: row.instagramUrl,
dateCreation: row.dateCreation,
objetAssociation: row.objetAssociation,
statutJuridique: row.statutJuridique,
nomRepresentant: row.nomRepresentant,
fonctionRepresentant: row.fonctionRepresentant,
sourceFileName: input.fileName,
sourceRowNumber: row.rowNumber,
sourceFingerprint: row.sourceFingerprint,
isActive: true,
importedAt: new Date(),
});
if (result === "created") created += 1;
if (result === "updated") updated += 1;
}
await logAdminAction(ctx.user.id, "import", "association_directory", undefined, {
fileName: input.fileName,
totalRows: preview.totalRows,
validRows: preview.validRows,
missingEmailRows: preview.missingEmailRows,
duplicateEmailRows: preview.duplicateEmailRows,
duplicateSelections: input.duplicateSelections || {},
created,
updated,
});
return {
fileName: input.fileName,
totalRows: preview.totalRows,
validRows: preview.validRows,
missingEmailRows: preview.missingEmailRows,
duplicateEmailRows: preview.duplicateEmailRows,
importedRows: rowsToImport.length,
created,
updated,
};
}),
}),
associationInvitation: router({
getPublic: publicProcedure
.input(z.object({
token: z.string().min(1),
}))
.query(async ({ input }) => {
const invitation = await db.getAssociationInvitationByToken(input.token);
if (!invitation) {
throw new TRPCError({ code: "NOT_FOUND", message: "Invitation introuvable" });
}
const directoryEntry = await db.getAssociationDirectoryEntryById(invitation.directoryEntryId);
if (!directoryEntry) {
throw new TRPCError({ code: "NOT_FOUND", message: "Association du bordereau introuvable" });
}
const existingAssociation = await db.getAssociationBySourceDirectoryEntryId(invitation.directoryEntryId);
const invalidReason = getInvitationStatusReason(invitation);
return {
token: invitation.token,
valid: !invalidReason && !existingAssociation,
invalidReason: existingAssociation ? "already_registered" : invalidReason,
association: {
id: directoryEntry.id,
nomAssociation: directoryEntry.nomAssociation,
emailOfficiel: directoryEntry.emailOfficiel,
nomRepresentant: directoryEntry.nomRepresentant,
ville: directoryEntry.ville,
},
expiresAt: invitation.expiresAt,
};
}),
send: adminProcedure
.input(z.object({
directoryEntryId: z.number(),
}))
.mutation(async ({ ctx, input }) => {
const directoryEntry = await db.getAssociationDirectoryEntryById(input.directoryEntryId);
if (!directoryEntry) {
throw new TRPCError({ code: "NOT_FOUND", message: "Association du bordereau introuvable" });
}
if (!directoryEntry.emailOfficiel || !directoryEntry.emailOfficielNormalise) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Aucun email officiel n'est renseigné pour cette association" });
}
const existingAssociation = await db.getAssociationBySourceDirectoryEntryId(directoryEntry.id);
if (existingAssociation) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Cette association a déjà activé son espace" });
}
await db.revokeAssociationInvitationsByDirectoryEntryId(directoryEntry.id);
const token = randomBytes(32).toString("hex");
const expiresAt = getInvitationExpiry();
const invitationLink = buildAssociationInvitationLink(ctx.req, token);
const canSend = await canSendOperationalEmails();
let emailSent = false;
if (canSend) {
try {
const email = generateAssociationInvitationEmail({
associationName: directoryEntry.nomAssociation,
invitationLink,
expiresAt,
});
const delivery = await sendOperationalEmail({
to: [directoryEntry.emailOfficiel],
subject: email.subject,
text: email.text,
html: email.html,
});
emailSent = delivery.sent;
} catch (error) {
console.error("[AssociationInvitation] SMTP send failed:", error);
emailSent = false;
}
}
await db.createAssociationInvitation({
directoryEntryId: directoryEntry.id,
emailOfficiel: directoryEntry.emailOfficiel,
emailOfficielNormalise: directoryEntry.emailOfficielNormalise,
token,
deliveryMode: "email",
emailSent,
sentByUserId: ctx.user.id,
sentAt: new Date(),
expiresAt,
});
await logAdminAction(ctx.user.id, "invitation_association", "association_directory", directoryEntry.id, {
emailOfficiel: directoryEntry.emailOfficiel,
invitationLink,
emailSent,
expiresAt,
});
return {
invitationLink,
emailSent,
expiresAt,
};
}),
getSecureLink: adminProcedure
.input(z.object({
directoryEntryId: z.number(),
}))
.mutation(async ({ ctx, input }) => {
const directoryEntry = await db.getAssociationDirectoryEntryById(input.directoryEntryId);
if (!directoryEntry) {
throw new TRPCError({ code: "NOT_FOUND", message: "Association du bordereau introuvable" });
}
if (!directoryEntry.emailOfficiel || !directoryEntry.emailOfficielNormalise) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Aucun email officiel n'est renseigné pour cette association" });
}
const existingAssociation = await db.getAssociationBySourceDirectoryEntryId(directoryEntry.id);
if (existingAssociation) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Cette association a déjà activé son espace" });
}
let invitation = await db.getActiveAssociationInvitationByDirectoryEntryId(directoryEntry.id);
if (!invitation) {
const token = randomBytes(32).toString("hex");
const expiresAt = getInvitationExpiry();
const invitationId = await db.createAssociationInvitation({
directoryEntryId: directoryEntry.id,
emailOfficiel: directoryEntry.emailOfficiel,
emailOfficielNormalise: directoryEntry.emailOfficielNormalise,
token,
deliveryMode: "manual_link",
emailSent: false,
sentByUserId: ctx.user.id,
sentAt: new Date(),
expiresAt,
});
invitation = await db.getAssociationInvitationByToken(token);
await logAdminAction(ctx.user.id, "copie_invitation_association", "association_directory", directoryEntry.id, {
invitationId,
expiresAt,
});
}
if (!invitation) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Impossible de générer le lien d'invitation" });
}
return {
invitationLink: buildAssociationInvitationLink(ctx.req, invitation.token),
expiresAt: invitation.expiresAt,
};
}),
revoke: adminProcedure
.input(z.object({
directoryEntryId: z.number(),
}))
.mutation(async ({ ctx, input }) => {
await db.revokeAssociationInvitationsByDirectoryEntryId(input.directoryEntryId);
await logAdminAction(ctx.user.id, "revocation_invitation_association", "association_directory", input.directoryEntryId);
return { success: true } as const;
}),
}),
materialReturn: router({
listMine: terrainProcedure.query(async ({ ctx }) => {
const currentEmail = normalizeEmail(String(ctx.user.email || ""));
if (!currentEmail) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Le compte terrain doit disposer d'une adresse email pour retrouver ses missions",
});
}
const logisticsSettings = await getLogisticsSettings();
const requests = (await db.searchRequests({
type: "demande_materiel_evenementiel",
status: "validee",
limit: 500,
})).data;
const entries = await Promise.all(
requests.map(async (request) => {
const association = await db.getAssociationById(request.associationId);
const followup = await db.getMaterialReturnFollowupByRequestId(request.id);
const payload = serializeMaterialReturnFollowup(followup);
if (!payload) return null;
const isAssignedRecipient = payload.recipientEmails.some((email) => normalizeEmail(email) === currentEmail);
const hasAlreadyWorkedOnMission = normalizeEmail(String(payload.uploadedByEmail || "")) === currentEmail;
if (!isAssignedRecipient && !hasAlreadyWorkedOnMission) return null;
const formData = (() => {
try {
return request.formData ? JSON.parse(request.formData) : {};
} catch {
return {};
}
})();
const restitutionDate = payload.restitutionDate || formData.dateRestitution || null;
const boardState = computeMaterialReturnBoardState({
requestStatus: request.status,
restitutionDate,
followup,
graceDays: logisticsSettings.materialReturnGraceDays,
});
return {
requestId: request.id,
title: request.titre,
associationName: association?.nomAssociation || `Association #${request.associationId}`,
commune: formData.commune || association?.ville || "",
pickupDate: formData.datePriseEnCharge || formData.dateDebutManifestation || formData.dateManifestation || null,
restitutionDate,
boardState,
boardStateLabel: materialReturnBoardStateLabels[boardState],
serviceLabel: payload.serviceLabel || "",
plannedSendAt: payload.plannedSendAt || null,
sentAt: payload.sentAt || null,
uploadedAt: payload.uploadedAt || null,
validatedAt: payload.validatedAt || null,
closedAt: payload.closedAt || null,
issueFlag: Boolean(payload.issueFlag),
compliance: payload.compliance || null,
finalPdfUrl: payload.finalPdfUrl || payload.signedFileUrl || null,
finalPdfName: payload.finalPdfName || payload.signedFileName || null,
uploadLink: payload.uploadToken
? buildMaterialReturnUploadLink(buildAppBaseUrl(ctx.req), payload.uploadToken)
: null,
uploadLinkState: getMaterialReturnUploadLinkState({
status: payload.status,
uploadTokenExpiresAt: payload.uploadTokenExpiresAt || null,
}),
uploadTokenExpiresAt: payload.uploadTokenExpiresAt || null,
lastReminderSentAt: payload.lastReminderSentAt || null,
uploadedByName: payload.uploadedByName || "",
uploadedByEmail: payload.uploadedByEmail || "",
};
})
);
const boardOrder = {
terrain: 0,
retard: 1,
planifie: 2,
litige: 3,
conforme: 4,
a_attribuer: 5,
} as const;
return entries
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry))
.sort((left, right) => {
const orderDiff = boardOrder[left.boardState] - boardOrder[right.boardState];
if (orderDiff !== 0) return orderDiff;
const leftDate = left.restitutionDate ? new Date(left.restitutionDate).getTime() : 0;
const rightDate = right.restitutionDate ? new Date(right.restitutionDate).getTime() : 0;
return leftDate - rightDate;
});
}),
getByRequestId: logisticsProcedure
.input(z.object({ requestId: z.number() }))
.query(async ({ input, ctx }) => {
const logisticsSettings = await getLogisticsSettings();
const followup = await db.getMaterialReturnFollowupByRequestId(input.requestId);
const payload = serializeMaterialReturnFollowup(followup);
if (!payload) return null;
const request = await db.getRequestById(input.requestId);
const reactivatedByUser = payload.reactivatedByUserId
? await db.getUserById(payload.reactivatedByUserId)
: null;
const boardState = computeMaterialReturnBoardState({
requestStatus: request?.status,
restitutionDate: payload.restitutionDate,
followup,
graceDays: logisticsSettings.materialReturnGraceDays,
});
return {
...payload,
boardState,
boardStateLabel: materialReturnBoardStateLabels[boardState],
uploadLink: payload.uploadToken
? buildMaterialReturnUploadLink(buildAppBaseUrl(ctx.req), payload.uploadToken)
: null,
uploadLinkState: getMaterialReturnUploadLinkState({
status: payload.status,
uploadTokenExpiresAt: payload.uploadTokenExpiresAt,
}),
reactivatedByName: reactivatedByUser?.name || null,
};
}),
listAdminBoard: logisticsProcedure
.query(async ({ ctx }) => {
const logisticsSettings = await getLogisticsSettings();
const requests = (await db.searchRequests({
type: "demande_materiel_evenementiel",
status: "validee",
limit: 500,
})).data;
const entries = await Promise.all(
requests.map(async (request) => {
const association = await db.getAssociationById(request.associationId);
const followup = await db.getMaterialReturnFollowupByRequestId(request.id);
const payload = serializeMaterialReturnFollowup(followup);
const formData = (() => {
try {
return request.formData ? JSON.parse(request.formData) : {};
} catch {
return {};
}
})();
const restitutionDate = payload?.restitutionDate || formData.dateRestitution || null;
const boardState = computeMaterialReturnBoardState({
requestStatus: request.status,
restitutionDate,
followup,
graceDays: logisticsSettings.materialReturnGraceDays,
});
return {
requestId: request.id,
title: request.titre,
status: request.status,
associationId: request.associationId,
associationName: association?.nomAssociation || `Association #${request.associationId}`,
commune: formData.commune || association?.ville || "",
pickupDate: formData.datePriseEnCharge || formData.dateDebutManifestation || formData.dateManifestation || null,
restitutionDate,
boardState,
boardStateLabel: materialReturnBoardStateLabels[boardState],
serviceLabel: payload?.serviceLabel || "",
recipientEmails: payload?.recipientEmails || [],
supervisionServiceLabel: payload?.supervisionServiceLabel || "",
supervisionRecipientEmails: payload?.supervisionRecipientEmails || [],
plannedSendAt: payload?.plannedSendAt || null,
sentAt: payload?.sentAt || null,
uploadedAt: payload?.uploadedAt || null,
validatedAt: payload?.validatedAt || null,
closedAt: payload?.closedAt || null,
issueFlag: Boolean(payload?.issueFlag),
compliance: payload?.compliance || null,
finalPdfUrl: payload?.finalPdfUrl || payload?.signedFileUrl || null,
uploadLink: payload?.uploadToken
? buildMaterialReturnUploadLink(buildAppBaseUrl(ctx.req), payload.uploadToken)
: null,
uploadLinkState: getMaterialReturnUploadLinkState({
status: payload?.status,
uploadTokenExpiresAt: payload?.uploadTokenExpiresAt || null,
}),
uploadTokenExpiresAt: payload?.uploadTokenExpiresAt || null,
reactivatedAt: payload?.reactivatedAt || null,
lastReminderSentAt: payload?.lastReminderSentAt || null,
};
})
);
const boardOrder = {
a_attribuer: 0,
planifie: 1,
terrain: 2,
retard: 3,
litige: 4,
conforme: 5,
} as const;
return entries.sort((a, b) => {
const orderDiff = boardOrder[a.boardState] - boardOrder[b.boardState];
if (orderDiff !== 0) return orderDiff;
const dateA = a.restitutionDate ? new Date(a.restitutionDate).getTime() : 0;
const dateB = b.restitutionDate ? new Date(b.restitutionDate).getTime() : 0;
return dateA - dateB;
});
}),
assign: logisticsProcedure
.input(
z.object({
requestId: z.number(),
recapServiceId: z.number().optional(),
recapServiceLabel: z.string().optional(),
recapRecipients: z.array(z.string()).optional(),
supervisionServiceId: z.number().optional(),
supervisionServiceLabel: z.string().optional(),
supervisionRecipients: z.array(z.string()).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const request = await db.getRequestById(input.requestId);
if (!request || request.type !== "demande_materiel_evenementiel") {
throw new TRPCError({ code: "NOT_FOUND", message: "Demande de matériel introuvable" });
}
if (request.status !== "validee") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Seules les demandes validées peuvent être assignées à la logistique" });
}
const existingFollowup = await db.getMaterialReturnFollowupByRequestId(request.id);
if (existingFollowup?.status === "cloture") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Cette restitution est déjà clôturée" });
}
const selectedRecapService = input.recapServiceId
? await db.getOperationalRecapServiceById(input.recapServiceId)
: undefined;
const selectedSupervisionService = input.supervisionServiceId
? await db.getOperationalRecapServiceById(input.supervisionServiceId)
: undefined;
const resolvedRecipients = mergeRecipients(
parseStoredRecipientEmails(selectedRecapService?.recipientEmails),
parseRecipients(input.recapRecipients)
);
const resolvedServiceLabel = input.recapServiceLabel?.trim() || selectedRecapService?.label || undefined;
const resolvedSupervisionRecipients = mergeRecipients(
parseStoredRecipientEmails(selectedSupervisionService?.recipientEmails),
parseRecipients(input.supervisionRecipients)
);
const resolvedSupervisionServiceLabel =
input.supervisionServiceLabel?.trim()
|| selectedSupervisionService?.label
|| undefined;
if (resolvedRecipients.length === 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Sélectionne un service ou ajoute au moins une adresse email de récupération",
});
}
let updatedFormData = request.formData;
try {
const existingFormData = request.formData ? JSON.parse(request.formData) : {};
existingFormData.notificationTrace = {
serviceId: selectedRecapService?.id,
serviceLabel: resolvedServiceLabel,
recipients: resolvedRecipients,
supervisionServiceId: selectedSupervisionService?.id,
supervisionServiceLabel: resolvedSupervisionServiceLabel,
supervisionRecipients: resolvedSupervisionRecipients,
processedBy: ctx.user.name || `Admin #${ctx.user.id}`,
processedAt: new Date().toISOString(),
channel: "smtp",
};
updatedFormData = JSON.stringify(existingFormData);
await db.updateRequest(request.id, { formData: updatedFormData });
} catch (error) {
console.error("Failed to persist logistics assignment trace:", error);
}
const scheduledFollowup = await scheduleMaterialReturnFollowup({
request: {
...request,
formData: updatedFormData,
},
serviceLabel: resolvedServiceLabel,
recipientEmails: resolvedRecipients,
supervisionServiceLabel: resolvedSupervisionServiceLabel,
supervisionRecipientEmails: resolvedSupervisionRecipients,
}, {
forceNewToken: Boolean(existingFollowup),
forceImmediateSendWindow: Boolean(existingFollowup),
reactivatedByUserId: existingFollowup ? ctx.user.id : null,
reactivatedAt: existingFollowup ? new Date() : null,
reactivationReason: existingFollowup ? "Assignation logistique mise à jour" : null,
});
if (!scheduledFollowup) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Impossible de planifier la restitution : la date de restitution est manquante sur la demande",
});
}
const association = await db.getAssociationById(request.associationId);
const mailResult = await sendMaterialReturnFollowupEmail({
followup: scheduledFollowup,
request: {
...request,
formData: updatedFormData,
},
association,
baseUrl: buildAppBaseUrl(ctx.req),
tone: existingFollowup ? "reactivation" : "assignment",
});
if (mailResult.sent) {
await db.updateMaterialReturnFollowup(scheduledFollowup.id, {
status: "en_attente",
sentAt: new Date(),
lastReminderSentAt: new Date(),
});
}
await db.createRequestHistory({
requestId: request.id,
action: "ajout_commentaire",
ancienStatut: request.status,
nouveauStatut: request.status,
userId: ctx.user.id,
commentaire: existingFollowup
? `Assignation logistique mise à jour — ${resolvedServiceLabel || resolvedRecipients.join(", ")}`
: `Récupération assignée à ${resolvedServiceLabel || resolvedRecipients.join(", ")}`,
});
await logAdminAction(ctx.user.id, "assignation_logistique", "request", request.id, {
recapServiceId: selectedRecapService?.id,
recapRecipients: resolvedRecipients,
recapServiceLabel: resolvedServiceLabel,
supervisionServiceId: selectedSupervisionService?.id,
supervisionRecipients: resolvedSupervisionRecipients,
supervisionServiceLabel: resolvedSupervisionServiceLabel,
emailSent: mailResult.sent,
reason: mailResult.sent ? undefined : mailResult.reason,
ipAddress: extractClientIp(ctx.req),
});
return {
success: true as const,
emailSent: mailResult.sent,
reason: mailResult.sent ? null : mailResult.reason,
};
}),
reactivate: logisticsProcedure
.input(
z.object({
requestId: z.number(),
recapServiceId: z.number().optional(),
recapServiceLabel: z.string().optional(),
recapRecipients: z.array(z.string()).optional(),
supervisionServiceId: z.number().optional(),
supervisionServiceLabel: z.string().optional(),
supervisionRecipients: z.array(z.string()).optional(),
reason: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const request = await db.getRequestById(input.requestId);
if (!request || request.type !== "demande_materiel_evenementiel") {
throw new TRPCError({ code: "NOT_FOUND", message: "Demande de matériel introuvable" });
}
if (request.status !== "validee") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Seules les demandes validées peuvent être réactivées" });
}
const existingFollowup = await db.getMaterialReturnFollowupByRequestId(request.id);
if (!existingFollowup) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Aucun suivi de restitution n'existe encore pour cette demande" });
}
if (existingFollowup.status === "cloture") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Cette restitution est déjà clôturée" });
}
if (existingFollowup.litigationStatus === "pending") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Le dossier est en litige et ne peut pas être réactivé tant que l'arbitrage n'est pas clôturé" });
}
const selectedRecapService = input.recapServiceId
? await db.getOperationalRecapServiceById(input.recapServiceId)
: undefined;
const selectedSupervisionService = input.supervisionServiceId
? await db.getOperationalRecapServiceById(input.supervisionServiceId)
: undefined;
const resolvedRecipients = mergeRecipients(
parseStoredRecipientEmails(existingFollowup.recipientEmails),
parseStoredRecipientEmails(selectedRecapService?.recipientEmails),
parseRecipients(input.recapRecipients)
);
const resolvedServiceLabel =
input.recapServiceLabel?.trim()
|| selectedRecapService?.label
|| existingFollowup.serviceLabel
|| undefined;
const resolvedSupervisionRecipients = mergeRecipients(
parseStoredRecipientEmails(existingFollowup.supervisionRecipientEmails),
parseStoredRecipientEmails(selectedSupervisionService?.recipientEmails),
parseRecipients(input.supervisionRecipients)
);
const resolvedSupervisionServiceLabel =
input.supervisionServiceLabel?.trim()
|| selectedSupervisionService?.label
|| existingFollowup.supervisionServiceLabel
|| undefined;
if (resolvedRecipients.length === 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Ajoute au moins une adresse email pour réactiver la restitution",
});
}
const reactivatedAt = new Date();
const reactivationReason = input.reason?.trim() || "Suivi rouvert";
const scheduledFollowup = await scheduleMaterialReturnFollowup({
request,
serviceLabel: resolvedServiceLabel,
recipientEmails: resolvedRecipients,
supervisionServiceLabel: resolvedSupervisionServiceLabel,
supervisionRecipientEmails: resolvedSupervisionRecipients,
}, {
forceNewToken: true,
forceImmediateSendWindow: true,
reactivatedByUserId: ctx.user.id,
reactivatedAt,
reactivationReason,
});
if (!scheduledFollowup) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Impossible de réactiver la restitution : la date de restitution est manquante sur la demande",
});
}
const association = await db.getAssociationById(request.associationId);
const mailResult = await sendMaterialReturnFollowupEmail({
followup: scheduledFollowup,
request,
association,
baseUrl: buildAppBaseUrl(ctx.req),
tone: "reactivation",
});
if (mailResult.sent) {
await db.updateMaterialReturnFollowup(scheduledFollowup.id, {
status: "en_attente",
sentAt: reactivatedAt,
lastReminderSentAt: reactivatedAt,
});
}
await db.createRequestHistory({
requestId: request.id,
action: "ajout_commentaire",
ancienStatut: request.status,
nouveauStatut: request.status,
userId: ctx.user.id,
commentaire: `${reactivationReason} — nouvelle échéance de dépôt : ${formatDateFr(scheduledFollowup.uploadTokenExpiresAt)}`,
});
await logAdminAction(ctx.user.id, "reactivation_restitution", "request", request.id, {
recapServiceId: selectedRecapService?.id,
recapRecipients: resolvedRecipients,
recapServiceLabel: resolvedServiceLabel,
supervisionServiceId: selectedSupervisionService?.id,
supervisionRecipients: resolvedSupervisionRecipients,
supervisionServiceLabel: resolvedSupervisionServiceLabel,
reason: reactivationReason,
emailSent: mailResult.sent,
emailFailureReason: mailResult.sent ? undefined : mailResult.reason,
uploadTokenExpiresAt: scheduledFollowup.uploadTokenExpiresAt,
ipAddress: extractClientIp(ctx.req),
});
return {
success: true as const,
emailSent: mailResult.sent,
uploadTokenExpiresAt: scheduledFollowup.uploadTokenExpiresAt,
uploadLink: buildMaterialReturnUploadLink(buildAppBaseUrl(ctx.req), scheduledFollowup.uploadToken),
};
}),
getPublic: publicProcedure
.input(z.object({ token: z.string().min(1) }))
.query(async ({ input, ctx }) => {
const followup = await db.getMaterialReturnFollowupByToken(input.token);
if (!followup) {
throw new TRPCError({ code: "NOT_FOUND", message: "Lien de restitution introuvable" });
}
const request = await db.getRequestById(followup.requestId);
if (!request || request.type !== "demande_materiel_evenementiel") {
throw new TRPCError({ code: "NOT_FOUND", message: "Demande liée introuvable" });
}
const association = await db.getAssociationById(followup.associationId);
const payload = serializeMaterialReturnFollowup(followup);
const formData = (() => {
try {
return request.formData ? JSON.parse(request.formData) : {};
} catch {
return {};
}
})();
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: String(formData.quantitesDemandees?.[key] || ""),
extra: key === "autres" ? String(formData.autreMaterielPrecisions || "") : "",
}));
return {
...payload,
request: {
id: request.id,
titre: request.titre,
commune: formData.commune || association?.ville || "",
manifestationStart: formData.dateDebutManifestation || formData.dateManifestation || "",
manifestationEnd: formData.dateFinManifestation || formData.dateManifestation || "",
requestedItems,
},
association: association
? {
nomAssociation: association.nomAssociation,
telephone: association.telephone,
emailContact: association.emailContact,
}
: null,
currentUser: ctx.user
? {
id: ctx.user.id,
name: ctx.user.name,
email: ctx.user.email,
role: ctx.user.role,
}
: null,
optionSets: {
agentRoles: [...agentRoleOptions],
borrowerRoles: [...borrowerRoleOptions],
discrepancyCategories: materialReturnDiscrepancyOptions,
},
canUpload:
followup.status !== "cloture" &&
new Date() <= new Date(followup.uploadTokenExpiresAt),
uploadDeadlineLabel: formatDateFr(followup.uploadTokenExpiresAt),
uploadLink: buildMaterialReturnUploadLink(buildAppBaseUrl(ctx.req), input.token),
};
}),
complete: publicProcedure
.input(
z.object({
token: z.string().min(1),
agentName: z.string().min(2),
agentEmail: z.string().email().optional().or(z.literal("")),
agentRole: z.enum(agentRoleOptions),
borrowerName: z.string().min(2),
borrowerRole: z.enum(borrowerRoleOptions),
compliance: z.enum(["conforme", "non_conforme"]),
discrepancyCategories: z.array(
z.enum([
"materiel_manquant",
"accessoires_manquants",
"structure_deformee",
"toile_dechiree",
"choc_important",
"materiel_sale",
"materiel_humide",
])
),
discrepancyDetails: z.string().optional(),
agentSignatureData: z.string().min(1),
borrowerSignatureData: z.string().min(1),
geoLatitude: z.string().optional(),
geoLongitude: z.string().optional(),
geoStatus: z.enum(["available", "unavailable"]).optional(),
geoFailureReason: z.string().optional(),
})
)
.mutation(async ({ input, ctx }) => {
const followup = await db.getMaterialReturnFollowupByToken(input.token);
if (!followup) {
throw new TRPCError({ code: "NOT_FOUND", message: "Lien de restitution introuvable" });
}
if (followup.status === "cloture") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Cette restitution a déjà été validée" });
}
if (new Date() > new Date(followup.uploadTokenExpiresAt)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Le délai de validation est dépassé" });
}
if ((input.compliance === "non_conforme" || input.discrepancyCategories.length > 0)
&& !input.discrepancyDetails?.trim()) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Précise les réserves constatées pour une restitution non conforme",
});
}
const request = await db.getRequestById(followup.requestId);
if (!request || request.type !== "demande_materiel_evenementiel") {
throw new TRPCError({ code: "NOT_FOUND", message: "Demande liée introuvable" });
}
const association = await db.getAssociationById(followup.associationId);
const requestFormData = (() => {
try {
return request.formData ? JSON.parse(request.formData) : {};
} catch {
return {};
}
})();
const validatedAt = new Date();
const issueFlag = input.compliance === "non_conforme";
const blockedItems = issueFlag ? getRequestedMaterialQuantityMap(request) : emptyMaterialEventQuantityMap();
const geoLatitude = input.geoLatitude?.trim() || null;
const geoLongitude = input.geoLongitude?.trim() || null;
const hasGeolocation = Boolean(geoLatitude && geoLongitude);
const geoStatus = hasGeolocation ? "available" : "unavailable";
const geoFailureReason = hasGeolocation
? null
: input.geoFailureReason?.trim()
|| (input.geoStatus === "unavailable" ? "Signal faible, refus d'autorisation ou GPS indisponible" : null);
const locationReference = [
requestFormData.commune,
requestFormData.direction,
requestFormData.service,
]
.map((value: unknown) => String(value || "").trim())
.filter(Boolean)
.join(" • ") || association?.ville || request.titre;
const agentSignatureBuffer = decodeBase64DataUrl(input.agentSignatureData);
const borrowerSignatureBuffer = decodeBase64DataUrl(input.borrowerSignatureData);
const { key: agentSignatureKey, url: agentSignatureUrl } = await storagePut(
`materiel-restitution/${followup.requestId}/signature-agent-${Date.now()}.png`,
agentSignatureBuffer,
"image/png"
);
const { key: borrowerSignatureKey, url: borrowerSignatureUrl } = await storagePut(
`materiel-restitution/${followup.requestId}/signature-emprunteur-${Date.now()}.png`,
borrowerSignatureBuffer,
"image/png"
);
const discrepancyLabelMap = new Map(
materialReturnDiscrepancyOptions.map((entry) => [entry.value, entry.label])
);
const discrepancyLabels = input.discrepancyCategories.map(
(value) => discrepancyLabelMap.get(value) || value
);
const pdf = await generateCompletedMaterialReturnStatementPdf({
request,
association,
completion: {
compliance: input.compliance,
discrepancyLabels,
discrepancyDetails: input.discrepancyDetails?.trim() || null,
agentName: input.agentName.trim(),
agentRole: input.agentRole,
borrowerName: input.borrowerName.trim(),
borrowerRole: input.borrowerRole,
validatedAt,
geoLatitude,
geoLongitude,
geoStatus,
geoFailureReason,
locationReference,
agentSignatureBuffer,
borrowerSignatureBuffer,
},
});
const { key: finalPdfKey, url: finalPdfUrl } = await storagePut(
`materiel-restitution/${followup.requestId}/fiche-finalisee-${Date.now()}.pdf`,
pdf.buffer,
"application/pdf"
);
await db.updateMaterialReturnFollowup(followup.id, {
status: "cloture",
uploadedByName: input.agentName.trim(),
uploadedByEmail: input.agentEmail?.trim() || null,
agentUserId: ctx.user?.id || null,
agentRole: input.agentRole,
borrowerName: input.borrowerName.trim(),
borrowerRole: input.borrowerRole,
compliance: input.compliance,
discrepancyCategories: JSON.stringify(input.discrepancyCategories),
discrepancyDetails: input.discrepancyDetails?.trim() || null,
issueFlag,
litigationStatus: issueFlag ? "pending" : "none",
blockedItems: issueFlag ? serializeBlockedItems(blockedItems) : null,
estimatedDamageAmount: null,
estimatedDamageSource: null,
arbitrationDecision: null,
arbitrationAmount: null,
arbitrationNotes: null,
arbitratedByUserId: null,
arbitratedAt: null,
litigationLetterKey: null,
litigationLetterUrl: null,
litigationLetterName: null,
agentSignatureKey,
agentSignatureUrl,
borrowerSignatureKey,
borrowerSignatureUrl,
finalPdfKey,
finalPdfUrl,
finalPdfName: pdf.fileName,
signedFileKey: finalPdfKey,
signedFileUrl: finalPdfUrl,
signedFileName: pdf.fileName,
signedMimeType: "application/pdf",
geoLatitude,
geoLongitude,
geoStatus,
geoFailureReason,
uploadedAt: validatedAt,
validatedAt,
closedAt: validatedAt,
});
await db.createRequestHistory({
requestId: request.id,
action: "ajout_commentaire",
ancienStatut: request.status,
nouveauStatut: request.status,
userId: ctx.user?.id || 0,
commentaire: issueFlag
? `Restitution finalisée avec réserves par ${input.agentName.trim()}`
: `Restitution finalisée sans réserve par ${input.agentName.trim()}`,
});
await db.createAdminNotification({
type: "systeme",
titre: issueFlag
? `Alerte dégradation / litige - ${request.titre}`
: `Matériel récupéré / dossier clôturé - ${request.titre}`,
message: issueFlag
? `Des réserves ont été enregistrées sur la restitution du matériel pour "${request.titre}".`
: `La restitution du matériel a été finalisée pour "${request.titre}".`,
lien: `/dashboard/requests/${request.id}`,
});
if (await canSendOperationalEmails()) {
const completionEmail = generateMaterialReturnCompletionEmail({
associationName: association?.nomAssociation || "Association",
requestTitle: request.titre,
restitutionDate: followup.restitutionDate,
serviceLabel: followup.serviceLabel,
issueFlag,
compliance: input.compliance,
discrepancyDetails: input.discrepancyDetails?.trim() || null,
});
const attachment = {
filename: pdf.fileName,
content: pdf.buffer,
contentType: "application/pdf",
};
const internalRecipients = mergeRecipients(
parseStoredRecipientEmails(followup.supervisionRecipientEmails),
parseStoredRecipientEmails(followup.recipientEmails)
);
const associationRecipient = association?.emailContact?.trim()
|| (() => {
try {
const formData = request.formData ? JSON.parse(request.formData) : {};
return String(formData.emailAssociation || "").trim();
} catch {
return "";
}
})();
try {
if (internalRecipients.length > 0) {
await sendOperationalEmail({
to: internalRecipients,
subject: completionEmail.subject,
text: completionEmail.text,
html: completionEmail.html,
attachments: [attachment],
replyTo: input.agentEmail?.trim() || undefined,
fromName: input.agentName.trim() ? `${input.agentName.trim()} via Portail Associations` : "Portail Associations",
});
}
if (associationRecipient) {
await sendOperationalEmail({
to: [associationRecipient],
subject: completionEmail.subject,
text: completionEmail.text,
html: completionEmail.html,
attachments: [attachment],
replyTo: input.agentEmail?.trim() || undefined,
fromName: "Portail Associations",
});
}
} catch (error) {
console.error("[MaterialReturn] Completion email failed:", error);
}
}
return {
success: true,
issueFlag,
finalPdfUrl,
};
}),
resolveLitigation: logisticsProcedure
.input(
z.object({
requestId: z.number(),
blockedItems: z.record(z.string(), z.number().int().min(0)),
estimatedDamageAmount: z.number().int().min(0).nullable().optional(),
estimatedDamageSource: z.enum(["manual", "replacement_value"]).default("manual"),
arbitrationDecision: z.enum(["partial_retention", "full_retention", "dismissed"]),
arbitrationAmount: z.number().int().min(0),
arbitrationNotes: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const request = await db.getRequestById(input.requestId);
if (!request || request.type !== "demande_materiel_evenementiel") {
throw new TRPCError({ code: "NOT_FOUND", message: "Demande de matériel introuvable" });
}
const followup = await db.getMaterialReturnFollowupByRequestId(input.requestId);
if (!followup || !followup.issueFlag) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Aucun litige actif n'est disponible sur ce dossier" });
}
if (followup.litigationStatus !== "pending") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Le litige a déjà été arbitré" });
}
const logisticsSettings = await getLogisticsSettings();
const blockedItems = sanitizeMaterialEventQuantityMap(input.blockedItems);
const calculatedReplacementAmount = materialEventItems.reduce((sum, item) => {
const replacementValue = logisticsSettings.replacementValues[item.key];
if (replacementValue === null) return sum;
return sum + ((blockedItems[item.key] || 0) * replacementValue);
}, 0);
const estimatedDamageAmount = input.estimatedDamageSource === "replacement_value"
? calculatedReplacementAmount
: Math.max(0, input.estimatedDamageAmount || 0);
const association = await db.getAssociationById(request.associationId);
const serializedFollowup = serializeMaterialReturnFollowup(followup);
const litigationLetterPdf = await generateMaterialReturnLitigationLetterPdf({
request,
association,
followup: {
restitutionDate: followup.restitutionDate,
discrepancyCategories: serializedFollowup?.discrepancyCategories || [],
discrepancyDetails: followup.discrepancyDetails,
},
arbitration: {
blockedItems,
decision: input.arbitrationDecision,
amountCents: input.arbitrationAmount,
notes: input.arbitrationNotes?.trim() || null,
},
});
const { key: litigationLetterKey, url: litigationLetterUrl } = await storagePut(
`materiel-restitution/${followup.requestId}/courrier-litige-${Date.now()}.pdf`,
litigationLetterPdf.buffer,
"application/pdf"
);
const arbitratedAt = new Date();
await db.updateMaterialReturnFollowup(followup.id, {
litigationStatus: "resolved",
blockedItems: serializeBlockedItems(blockedItems),
estimatedDamageAmount,
estimatedDamageSource: input.estimatedDamageSource,
arbitrationDecision: input.arbitrationDecision,
arbitrationAmount: input.arbitrationAmount,
arbitrationNotes: input.arbitrationNotes?.trim() || null,
arbitratedByUserId: ctx.user.id,
arbitratedAt,
litigationLetterKey,
litigationLetterUrl,
litigationLetterName: litigationLetterPdf.fileName,
});
await db.createRequestHistory({
requestId: request.id,
action: "ajout_commentaire",
ancienStatut: request.status,
nouveauStatut: request.status,
userId: ctx.user.id,
commentaire: `Arbitrage litige finalisé (${input.arbitrationDecision}) par ${ctx.user.name || `Admin #${ctx.user.id}`}`,
});
await db.createAdminNotification({
type: "systeme",
titre: `Arbitrage clôturé - ${request.titre}`,
message: `Le litige matériel de "${request.titre}" a été arbitré et archivé.`,
lien: `/dashboard/requests/${request.id}`,
});
await logAdminAction(ctx.user.id, "arbitrage", "request", request.id, {
decision: input.arbitrationDecision,
arbitrationAmount: input.arbitrationAmount,
estimatedDamageAmount,
blockedItems,
ipAddress: extractClientIp(ctx.req),
});
return {
success: true,
litigationLetterUrl,
litigationLetterName: litigationLetterPdf.fileName,
};
}),
uploadSigned: publicProcedure
.input(
z.object({
token: z.string().min(1),
uploadedByName: z.string().min(2, "Le nom du déposant est requis"),
uploadedByEmail: z.string().email("Adresse email invalide").optional().or(z.literal("")),
fileData: z.string().min(1),
fileName: z.string().min(1),
mimeType: z.string().min(1),
})
)
.mutation(async ({ input }) => {
const followup = await db.getMaterialReturnFollowupByToken(input.token);
if (!followup) {
throw new TRPCError({ code: "NOT_FOUND", message: "Lien de restitution introuvable" });
}
if (followup.status === "cloture") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Cette restitution a déjà été clôturée" });
}
if (new Date() > new Date(followup.uploadTokenExpiresAt)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Le délai de dépôt de la fiche est dépassé" });
}
const allowedMimeTypes = [
"application/pdf",
"image/jpeg",
"image/png",
"image/webp",
"image/heic",
];
if (!allowedMimeTypes.includes(input.mimeType)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "La fiche signée doit être un PDF ou une photo",
});
}
const request = await db.getRequestById(followup.requestId);
if (!request) {
throw new TRPCError({ code: "NOT_FOUND", message: "Demande liée introuvable" });
}
const fileBuffer = Buffer.from(input.fileData, "base64");
const storageName = `materiel-restitution/${followup.requestId}/${Date.now()}-${input.fileName}`;
const { key, url } = await storagePut(storageName, fileBuffer, input.mimeType);
await db.updateMaterialReturnFollowup(followup.id, {
status: "cloture",
signedFileKey: key,
signedFileUrl: url,
signedFileName: input.fileName,
signedMimeType: input.mimeType,
uploadedByName: input.uploadedByName.trim(),
uploadedByEmail: input.uploadedByEmail?.trim() || null,
uploadedAt: new Date(),
closedAt: new Date(),
});
await db.createAdminNotification({
type: "systeme",
titre: `Restitution clôturée - ${request.titre}`,
message: `La fiche signée de restitution du matériel a été déposée pour la demande "${request.titre}".`,
lien: `/dashboard/requests/${request.id}`,
});
return {
success: true,
fileUrl: url,
};
}),
generatePdf: publicProcedure
.input(z.object({ token: z.string().min(1) }))
.mutation(async ({ input }) => {
const followup = await db.getMaterialReturnFollowupByToken(input.token);
if (!followup) {
throw new TRPCError({ code: "NOT_FOUND", message: "Lien de restitution introuvable" });
}
const request = await db.getRequestById(followup.requestId);
if (!request) {
throw new TRPCError({ code: "NOT_FOUND", message: "Demande liée introuvable" });
}
const association = await db.getAssociationById(followup.associationId);
const pdf = await generateMaterialReturnStatementPdf({
request,
association,
});
const storageName = `materiel-restitution/${followup.requestId}/fiche-pre-remplie-${Date.now()}.pdf`;
const { url } = await storagePut(storageName, pdf.buffer, "application/pdf");
return {
url,
fileName: pdf.fileName,
};
}),
}),
// ============== DOCUMENT ROUTES ==============
document: router({
getMyDocuments: protectedProcedure.query(async ({ ctx }) => {
const association = await db.getAssociationByUserId(ctx.user.id);
if (!association) return [];
return db.getDocumentsByAssociationId(association.id);
}),
upload: protectedProcedure
.input(z.object({
nom: z.string().min(1),
type: z.enum(['statuts', 'recepisse_declaration', 'rib', 'rapport_activite', 'rapport_financier', 'pv_assemblee', 'liste_dirigeants', 'attestation_assurance', 'autre']),
description: z.string().optional(),
fileData: z.string(),
fileName: z.string(),
mimeType: z.string(),
}))
.mutation(async ({ ctx, input }) => {
const association = await db.getAssociationByUserId(ctx.user.id);
if (!association) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Veuillez d\'abord créer votre profil association' });
}
const fileBuffer = Buffer.from(input.fileData, 'base64');
const fileKey = `associations/${association.id}/documents/${nanoid()}-${input.fileName}`;
const { url } = await storagePut(fileKey, fileBuffer, input.mimeType);
const docId = await db.createDocument({
associationId: association.id,
nom: input.nom,
type: input.type,
description: input.description,
fileKey,
fileUrl: url,
mimeType: input.mimeType,
fileSize: fileBuffer.length,
});
return { id: docId, url };
}),
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ ctx, input }) => {
const association = await db.getAssociationByUserId(ctx.user.id);
if (!association) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Association non trouvée' });
}
const doc = await db.getDocumentById(input.id);
if (!doc || doc.associationId !== association.id) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Document non trouvé' });
}
await db.deleteDocument(input.id);
return { success: true };
}),
getByAssociationId: adminProcedure
.input(z.object({ associationId: z.number() }))
.query(async ({ input }) => {
return db.getDocumentsByAssociationId(input.associationId);
}),
getDownloadUrl: protectedProcedure
.input(z.object({ id: z.number() }))
.query(async ({ ctx, input }) => {
const association = await db.getAssociationByUserId(ctx.user.id);
const doc = await db.getDocumentById(input.id);
if (!doc) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Document non trouvé' });
}
if (!canReadInternalRequest(ctx.user) && (!association || doc.associationId !== association.id)) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès non autorisé' });
}
const { url } = await storageGet(doc.fileKey);
return { url };
}),
}),
salleSignature: router({
getDelegation: directriceProcedure.query(async ({ ctx }) => {
const [candidates, delegatedUsers, legacyDelegatedUser] = await Promise.all([
getSalleSignatureDelegateCandidates(ctx.user.id),
getSalleSignatureDelegates(ctx.user.id),
ctx.user.delegatedSalleSignerUserId
? db.getUserById(ctx.user.delegatedSalleSignerUserId)
: Promise.resolve(undefined),
]);
const normalizedDelegatedUsers = legacyDelegatedUser && !delegatedUsers.some((entry) => entry.id === legacyDelegatedUser.id)
? delegatedUsers.concat({
id: legacyDelegatedUser.id,
name: legacyDelegatedUser.name || "Sans nom",
email: legacyDelegatedUser.email || "",
role: legacyDelegatedUser.role,
})
: delegatedUsers;
return {
delegatedUserId: normalizedDelegatedUsers[0]?.id ?? ctx.user.delegatedSalleSignerUserId ?? null,
delegatedUserName: normalizedDelegatedUsers[0]?.name || legacyDelegatedUser?.name || null,
delegatedUserEmail: normalizedDelegatedUsers[0]?.email || legacyDelegatedUser?.email || null,
delegatedUsers: normalizedDelegatedUsers,
candidates,
};
}),
setDelegation: directriceProcedure
.input(z.object({
delegateUserIds: z.array(z.number()).max(20),
}))
.mutation(async ({ ctx, input }) => {
if (ctx.user.role !== "directrice") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Seule la Directrice peut choisir son délégataire de signature",
});
}
const candidates = await getSalleSignatureDelegateCandidates(ctx.user.id);
const candidateIds = new Set(candidates.map((entry) => entry.id));
const invalidUserId = input.delegateUserIds.find((userId) => !candidateIds.has(userId));
if (invalidUserId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "La personne choisie ne peut pas recevoir la délégation de signature",
});
}
await db.updateUser(ctx.user.id, {
delegatedSalleSignerUserId: input.delegateUserIds[0] ?? null,
});
await setDelegatedSignerIdsForDirectrice(ctx.user.id, input.delegateUserIds);
await logAdminAction(ctx.user.id, "delegation_signature_salle", "user", ctx.user.id, {
delegatedSalleSignerUserIds: input.delegateUserIds,
ipAddress: extractClientIp(ctx.req),
});
return {
success: true,
delegatedUsers: candidates.filter((entry) => input.delegateUserIds.includes(entry.id)),
};
}),
addDelegateByEmail: directriceProcedure
.input(z.object({
email: z.string().trim().email("Adresse email invalide"),
name: z.string().trim().min(2).optional(),
}))
.mutation(async ({ ctx, input }) => {
if (ctx.user.role !== "directrice") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Seule la Directrice peut ajouter un signataire remplaçant",
});
}
const normalizedEmail = input.email.trim().toLowerCase();
let user = await db.getUserByEmail(normalizedEmail);
if (!user) {
const generatedPassword = randomBytes(24).toString("base64url");
const fallbackName = normalizedEmail.split("@")[0]?.replace(/[._-]+/g, " ").trim() || "Signataire";
user = await registerLocalUser({
name: input.name?.trim() || fallbackName,
email: normalizedEmail,
password: generatedPassword,
});
await db.updateUser(user.id, {
name: input.name?.trim() || user.name || fallbackName,
email: normalizedEmail,
role: "user",
canManageLogistics: false,
isActive: true,
loginMethod: "local_jwt",
});
} else {
await db.updateUser(user.id, {
name: input.name?.trim() || user.name || normalizedEmail,
email: normalizedEmail,
isActive: true,
});
}
const candidates = await getSalleSignatureDelegateCandidates(ctx.user.id);
const candidate = candidates.find((entry) => entry.id === user.id);
if (!candidate) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Cette adresse ne peut pas être utilisée pour la délégation de signature",
});
}
const delegateUserIds = await addDelegatedSignerForDirectrice(ctx.user.id, user.id);
await db.updateUser(ctx.user.id, {
delegatedSalleSignerUserId: delegateUserIds[0] ?? null,
});
await logAdminAction(ctx.user.id, "ajout_delegataire_signature_salle", "user", user.id, {
email: normalizedEmail,
delegatedSalleSignerUserIds: delegateUserIds,
ipAddress: extractClientIp(ctx.req),
});
return {
success: true,
addedUser: candidate,
};
}),
removeDelegate: directriceProcedure
.input(z.object({
userId: z.number(),
}))
.mutation(async ({ ctx, input }) => {
if (ctx.user.role !== "directrice") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Seule la Directrice peut retirer un signataire remplaçant",
});
}
const currentDelegateIds = await getDelegatedSignerIdsForDirectrice(ctx.user.id);
if (!currentDelegateIds.includes(input.userId)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Cette personne n'est pas dans la délégation active",
});
}
const nextDelegateIds = await removeDelegatedSignerForDirectrice(ctx.user.id, input.userId);
await db.updateUser(ctx.user.id, {
delegatedSalleSignerUserId: nextDelegateIds[0] ?? null,
});
await logAdminAction(ctx.user.id, "suppression_delegataire_signature_salle", "user", input.userId, {
delegatedSalleSignerUserIds: nextDelegateIds,
ipAddress: extractClientIp(ctx.req),
});
return {
success: true,
delegateUserIds: nextDelegateIds,
};
}),
}),
// ============== REQUEST ROUTES ==============
request: router({
getMyRequests: protectedProcedure.query(async ({ ctx }) => {
const association = await db.getAssociationByUserId(ctx.user.id);
if (!association) return [];
return db.getRequestsByAssociationId(association.id);
}),
getById: protectedProcedure
.input(z.object({ id: z.number() }))
.query(async ({ ctx, input }) => {
const request = await db.getRequestById(input.id);
if (!request) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Demande non trouvée' });
}
if (!canReadInternalRequest(ctx.user)) {
const association = await db.getAssociationByUserId(ctx.user.id);
if (!association || request.associationId !== association.id) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès non autorisé' });
}
}
// Get history for admins
let history: any[] = [];
if (canReadInternalRequest(ctx.user)) {
history = await db.getRequestHistoryByRequestId(input.id);
}
// Get association info
const association = await db.getAssociationById(request.associationId);
// Get attached documents
let attachedDocuments: any[] = [];
if (request.documentsJoints) {
try {
const docIds = JSON.parse(request.documentsJoints) as number[];
if (Array.isArray(docIds) && docIds.length > 0) {
const docs = await Promise.all(
docIds.map(async (docId) => {
const doc = await db.getDocumentById(docId);
return doc || null;
})
);
attachedDocuments = docs.filter(Boolean);
}
} catch {
// ignore parse errors
}
}
return { ...request, history, associationInfo: association || null, attachedDocuments };
}),
generatePdf: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ ctx, input }) => {
const request = await db.getRequestById(input.id);
if (!request) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Demande non trouvée' });
}
// Vérifier l'accès : admin ou propriétaire
if (!canReadInternalRequest(ctx.user)) {
const association = await db.getAssociationByUserId(ctx.user.id);
if (!association || request.associationId !== association.id) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès non autorisé' });
}
}
const pdfDocument = await generateRequestPdfDocument(request);
const { url } = await storagePut(pdfDocument.storageName, pdfDocument.buffer, pdfDocument.contentType);
return { url, fileName: pdfDocument.fileName };
}),
generateMaterialConvention: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ ctx, input }) => {
const request = await db.getRequestById(input.id);
if (!request || request.type !== 'demande_materiel_evenementiel') {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Demande de matériel introuvable' });
}
if (request.status !== 'validee') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'La convention ne peut être générée que pour une demande validée' });
}
const financialDecision = parseMaterialFinancialDecision(request.formData);
if (!financialDecision) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Les conditions financières ne sont pas encore renseignées' });
}
const association = await db.getAssociationById(request.associationId);
const generatedContract = await generateAndStoreMaterialConventionForRequest({
request,
association,
decision: {
financialMode: financialDecision.financialMode,
depositRequired: financialDecision.depositRequired,
depositAmountCents: financialDecision.depositAmountCents,
rentalAmountCents: financialDecision.rentalAmountCents,
pricingNotes: financialDecision.pricingNotes,
},
validatedByUserId: ctx.user.id,
});
const nextFormData = mergeMaterialFinancialDecision({
rawFormData: request.formData,
decision: {
financialMode: financialDecision.financialMode,
depositRequired: financialDecision.depositRequired,
depositAmountCents: financialDecision.depositAmountCents,
rentalAmountCents: financialDecision.rentalAmountCents,
pricingNotes: financialDecision.pricingNotes,
contractStatus: 'generee',
contractPdfUrl: generatedContract.contractPdfUrl,
contractPdfName: generatedContract.contractPdfName,
contractGeneratedAt: generatedContract.contractGeneratedAt,
contractValidatedByUserId: ctx.user.id,
},
});
await db.updateRequest(input.id, {
formData: nextFormData,
});
await db.createRequestHistory({
requestId: input.id,
action: 'modification',
ancienStatut: request.status,
nouveauStatut: request.status,
userId: ctx.user.id,
commentaire: 'Convention de mise à disposition / location générée',
});
await logAdminAction(ctx.user.id, 'generation_convention', 'request', input.id, {
financialMode: financialDecision.financialMode,
depositAmountCents: financialDecision.depositAmountCents,
rentalAmountCents: financialDecision.rentalAmountCents,
contractPdfName: generatedContract.contractPdfName,
ipAddress: extractClientIp(ctx.req),
});
return {
url: generatedContract.contractPdfUrl,
fileName: generatedContract.contractPdfName,
};
}),
generateSalleConvention: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ ctx, input }) => {
const request = await db.getRequestById(input.id);
if (!request || request.type !== 'demande_salle') {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Demande de salle introuvable' });
}
if (request.status !== 'validee') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'La convention ne peut être générée que pour une demande validée' });
}
const financialDecision = parseMaterialFinancialDecision(request.formData);
if (!financialDecision) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Les conditions financières ne sont pas encore renseignées' });
}
const association = await db.getAssociationById(request.associationId);
const generatedContract = await generateAndStoreSalleConventionForRequest({
request,
association,
decision: {
financialMode: financialDecision.financialMode,
depositRequired: financialDecision.depositRequired,
depositAmountCents: financialDecision.depositAmountCents,
rentalAmountCents: financialDecision.rentalAmountCents,
pricingNotes: financialDecision.pricingNotes,
},
validatedByUserId: ctx.user.id,
});
const nextFormData = mergeMaterialFinancialDecision({
rawFormData: request.formData,
decision: {
financialMode: financialDecision.financialMode,
depositRequired: financialDecision.depositRequired,
depositAmountCents: financialDecision.depositAmountCents,
rentalAmountCents: financialDecision.rentalAmountCents,
pricingNotes: financialDecision.pricingNotes,
contractStatus: 'generee',
contractPdfUrl: generatedContract.contractPdfUrl,
contractPdfName: generatedContract.contractPdfName,
contractGeneratedAt: generatedContract.contractGeneratedAt,
contractValidatedByUserId: ctx.user.id,
},
});
await db.updateRequest(input.id, {
formData: nextFormData,
});
await db.createRequestHistory({
requestId: input.id,
action: 'modification',
ancienStatut: request.status,
nouveauStatut: request.status,
userId: ctx.user.id,
commentaire: 'Convention de mise à disposition / location de salle générée',
});
await logAdminAction(ctx.user.id, 'generation_convention_salle', 'request', input.id, {
financialMode: financialDecision.financialMode,
depositAmountCents: financialDecision.depositAmountCents,
rentalAmountCents: financialDecision.rentalAmountCents,
contractPdfName: generatedContract.contractPdfName,
ipAddress: extractClientIp(ctx.req),
});
return {
url: generatedContract.contractPdfUrl,
fileName: generatedContract.contractPdfName,
};
}),
sendSalleQuote: accueilAdminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ ctx, input }) => {
const request = await db.getRequestById(input.id);
if (!request || request.type !== "demande_salle") {
throw new TRPCError({ code: "NOT_FOUND", message: "Demande de salle introuvable" });
}
if (request.status === "validee" || request.status === "refusee" || request.status === "annulee") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Cette demande n'est plus éligible à l'envoi du devis" });
}
const association = await db.getAssociationById(request.associationId);
const quoteWorkflow = await sendSalleQuoteWorkflow({
request,
association,
req: ctx.req,
user: ctx.user,
});
await db.updateRequest(request.id, {
status: request.status === "soumise" ? "en_cours_traitement" : request.status,
formData: quoteWorkflow.updatedFormData,
traitePar: ctx.user.id,
});
await db.createRequestHistory({
requestId: request.id,
action: "modification",
ancienStatut: request.status,
nouveauStatut: request.status === "soumise" ? "en_cours_traitement" : request.status,
userId: ctx.user.id,
commentaire: "Devis et décision administrative prioritaire envoyés à l'association",
});
await logAdminAction(ctx.user.id, "envoi_devis_salle", "request", request.id, {
pricingTotalCents: quoteWorkflow.pricing.totalAmountCents,
financialMode: quoteWorkflow.financialMode,
ipAddress: extractClientIp(ctx.req),
});
return {
success: true,
quotePdfUrl: quoteWorkflow.quotePdfUrl,
decisionPdfUrl: quoteWorkflow.decisionPdfUrl,
quoteStatusLabel: SALLE_WORKFLOW_QUOTE_STATUS_LABELS.en_attente_association,
};
}),
respondSalleQuote: protectedProcedure
.input(z.object({
id: z.number(),
decision: z.enum(["accepte", "refuse"]),
}))
.mutation(async ({ ctx, input }) => {
const request = await db.getRequestById(input.id);
if (!request || request.type !== "demande_salle") {
throw new TRPCError({ code: "NOT_FOUND", message: "Demande de salle introuvable" });
}
const association = await db.getAssociationByUserId(ctx.user.id);
if (!association || request.associationId !== association.id) {
throw new TRPCError({ code: "FORBIDDEN", message: "Accès non autorisé" });
}
if (request.status === "validee" || request.status === "refusee" || request.status === "annulee") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Cette demande ne peut plus recevoir de réponse" });
}
const workflow = getSalleWorkflowData(request.formData);
if (workflow.quoteStatus !== "en_attente_association") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "La réponse de l'association a déjà été enregistrée pour ce dossier",
});
}
const isAccepted = input.decision === "accepte";
const nextStatus = isAccepted ? request.status : "refusee";
const nextFormData = updateSalleWorkflowQuoteStatus(request.formData, input.decision);
await db.updateRequest(request.id, {
formData: nextFormData,
status: nextStatus,
dateTraitement: isAccepted ? request.dateTraitement : new Date(),
commentaireAdmin: isAccepted
? request.commentaireAdmin
: ((request.commentaireAdmin ? `${request.commentaireAdmin}\n\n` : "") + "Devis refusé par l'association depuis le portail"),
});
await db.createRequestHistory({
requestId: request.id,
action: isAccepted ? "modification" : "refus",
ancienStatut: request.status,
nouveauStatut: nextStatus,
userId: ctx.user.id,
commentaire: isAccepted
? "Devis accepté par l'association depuis le portail"
: "Devis refusé par l'association depuis le portail",
});
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}" depuis son espace.`
: `L'association a refusé le devis pour la demande "${request.titre}" depuis son espace.`,
lien: `/dashboard/requests/${request.id}`,
});
return {
success: true,
quoteStatus: input.decision,
quoteStatusLabel: isAccepted
? SALLE_WORKFLOW_QUOTE_STATUS_LABELS.accepte
: SALLE_WORKFLOW_QUOTE_STATUS_LABELS.refuse,
nextStatus,
};
}),
sendSalleToDirector: accueilAdminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ ctx, input }) => {
const request = await db.getRequestById(input.id);
if (!request || request.type !== "demande_salle") {
throw new TRPCError({ code: "NOT_FOUND", message: "Demande de salle introuvable" });
}
const workflow = getSalleWorkflowData(request.formData);
if (workflow.quoteStatus !== "accepte") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Le dossier doit d'abord être accepté par l'association" });
}
const nextFormData = updateSalleWorkflowDirectorStatus(request.formData, "en_attente_signature");
await db.updateRequest(request.id, { formData: nextFormData, traitePar: ctx.user.id });
await db.createRequestHistory({
requestId: request.id,
action: "modification",
ancienStatut: request.status,
nouveauStatut: request.status,
userId: ctx.user.id,
commentaire: "Dossier transmis à la Directrice pour signature",
});
await db.createAdminNotification({
type: "systeme",
titre: "Une demande est en attente de signature.",
message: `Le dossier "${request.titre}" a été transmis à la Direction et attend maintenant une signature.`,
lien: `/dashboard/requests/${request.id}`,
});
await logAdminAction(ctx.user.id, "transmission_directrice_salle", "request", request.id, {
directorStatus: "en_attente_signature",
ipAddress: extractClientIp(ctx.req),
});
return {
success: true,
directorStatusLabel: SALLE_WORKFLOW_DIRECTOR_STATUS_LABELS.en_attente_signature,
};
}),
returnSalleToHostess: salleSignerProcedure
.input(z.object({
id: z.number(),
comment: z.string().trim().max(1000).optional(),
}))
.mutation(async ({ ctx, input }) => {
const request = await db.getRequestById(input.id);
if (!request || request.type !== "demande_salle") {
throw new TRPCError({ code: "NOT_FOUND", message: "Demande de salle introuvable" });
}
const workflow = getSalleWorkflowData(request.formData);
if (workflow.directorStatus !== "en_attente_signature") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Ce dossier n'est pas en attente de signature",
});
}
const nextFormData = mergeSalleWorkflowData(
updateSalleWorkflowDirectorStatus(request.formData, "a_transmettre"),
{
directorReturnComment: input.comment?.trim() || "",
directorReturnedAt: new Date().toISOString(),
}
);
await db.updateRequest(request.id, {
formData: nextFormData,
});
const comment = input.comment?.trim();
await db.createRequestHistory({
requestId: request.id,
action: "modification",
ancienStatut: request.status,
nouveauStatut: request.status,
userId: ctx.user.id,
commentaire: comment
? `Dossier renvoyé à lhôtesse par la Directrice : ${comment}`
: "Dossier renvoyé à lhôtesse par la Directrice",
});
await db.createAdminNotification({
type: "systeme",
titre: `Retour Directrice : ${request.titre}`,
message: comment
? `La Directrice a renvoyé le dossier "${request.titre}" à lhôtesse avec la note suivante : ${comment}`
: `La Directrice a renvoyé le dossier "${request.titre}" à lhôtesse pour reprise.`,
lien: `/dashboard/requests/${request.id}`,
});
await logAdminAction(ctx.user.id, "retour_hotesse_salle", "request", request.id, {
comment: comment || null,
ipAddress: extractClientIp(ctx.req),
});
return {
success: true,
directorStatusLabel: SALLE_WORKFLOW_DIRECTOR_STATUS_LABELS.a_transmettre,
};
}),
finalizeSalleReservation: salleSignerProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ ctx, input }) => {
const request = await db.getRequestById(input.id);
if (!request || request.type !== "demande_salle") {
throw new TRPCError({ code: "NOT_FOUND", message: "Demande de salle introuvable" });
}
const workflow = getSalleWorkflowData(request.formData);
if (workflow.quoteStatus !== "accepte") {
throw new TRPCError({ code: "BAD_REQUEST", message: "La demande doit être acceptée par l'association avant finalisation" });
}
if (workflow.directorStatus !== "en_attente_signature") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Le dossier doit d'abord être transmis à la Directrice" });
}
const association = await db.getAssociationById(request.associationId);
const pricing = workflow.pricing || computeSalleWorkflowPricing(request.formData);
const financialDecision = parseMaterialFinancialDecision(request.formData);
if (!pricing || !financialDecision) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Le dossier ne contient pas les données financières nécessaires à la finalisation" });
}
const signaturePackage = await generateSignedSalleWorkflowPackage({
request,
association,
pricing,
financialDecision,
signedByUserId: ctx.user.id,
signedByName: ctx.user.name || `Directrice #${ctx.user.id}`,
});
await db.updateRequest(request.id, {
formData: signaturePackage.nextFormData,
});
await db.createRequestHistory({
requestId: request.id,
action: "validation",
ancienStatut: request.status,
nouveauStatut: request.status,
userId: ctx.user.id,
commentaire: "Dossier signé par la Direction et renvoyé à lhôtesse pour confirmation finale",
});
await db.createAdminNotification({
type: "systeme",
titre: `Dossier signé : ${request.titre}`,
message: `La Direction a signé le dossier "${request.titre}". Lhôtesse retrouve maintenant le devis signé, la décision signée, le formulaire signé et la facture générée pour préparer lenvoi final à lassociation.`,
lien: `/dashboard/requests/${request.id}`,
});
await logAdminAction(ctx.user.id, "signature_direction_salle", "request", request.id, {
directorStatus: "signee",
pricingTotalCents: pricing.totalAmountCents,
financialMode: financialDecision.financialMode,
ipAddress: extractClientIp(ctx.req),
});
return { success: true };
}),
prepareSalleFinalNotification: accueilAdminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const request = await db.getRequestById(input.id);
if (!request || request.type !== "demande_salle") {
throw new TRPCError({ code: "NOT_FOUND", message: "Demande de salle introuvable" });
}
const workflow = getSalleWorkflowData(request.formData);
if (workflow.quoteStatus !== "accepte" || workflow.directorStatus !== "signee") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Le dossier doit d'abord être signé par la Direction avant l'envoi final",
});
}
if (workflow.finalNotificationSentAt) {
throw new TRPCError({ code: "BAD_REQUEST", message: "La confirmation finale a déjà été envoyée" });
}
const association = await db.getAssociationById(request.associationId);
const pricing = workflow.pricing || computeSalleWorkflowPricing(request.formData);
const financialDecision = parseMaterialFinancialDecision(request.formData);
const salleBilling = await getSalleBillingSettings();
if (!pricing || !financialDecision) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Les données financières de ce dossier sont incomplètes" });
}
const invoicePreview = await ensureSalleInvoicePrepared({
request,
association,
pricing,
billed: financialDecision.financialMode === "location_payante",
});
if (invoicePreview.nextFormData !== request.formData) {
await db.updateRequest(request.id, {
formData: invoicePreview.nextFormData,
});
}
const effectiveFormData = parseRequestFormData(invoicePreview.nextFormData);
const finalEmail = generateSalleFinalEmail({
associationName: association?.nomAssociation || effectiveFormData.nomAssociation || "association",
datesLabel: buildSalleDatesLabel(effectiveFormData),
pricingTotalCents: pricing.totalAmountCents,
billed: financialDecision.financialMode === "location_payante",
});
return {
success: true,
associationName: association?.nomAssociation || effectiveFormData.nomAssociation || "Association",
datesLabel: buildSalleDatesLabel(effectiveFormData),
billed: financialDecision.financialMode === "location_payante",
pricingTotalCents: pricing.totalAmountCents,
quotePdfUrl: workflow.signedQuotePdfUrl || workflow.quotePdfUrl || "",
quotePdfName: workflow.signedQuotePdfName || workflow.quotePdfName || "",
decisionPdfUrl: workflow.signedDecisionPdfUrl || workflow.administrativeDecisionPdfUrl || "",
decisionPdfName: workflow.signedDecisionPdfName || workflow.administrativeDecisionPdfName || "",
requestPdfUrl: workflow.signedRequestPdfUrl || "",
requestPdfName: workflow.signedRequestPdfName || "",
invoicePdfUrl: invoicePreview.invoicePdfUrl,
invoicePdfName: invoicePreview.invoicePdfName,
invoiceGeneratedAt: invoicePreview.invoiceGeneratedAt,
ribDocumentUrl: salleBilling.ribDocumentUrl,
ribDocumentName: salleBilling.ribDocumentName,
mailPreview: finalEmail.text,
};
}),
sendSalleFinalNotification: accueilAdminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ ctx, input }) => {
const request = await db.getRequestById(input.id);
if (!request || request.type !== "demande_salle") {
throw new TRPCError({ code: "NOT_FOUND", message: "Demande de salle introuvable" });
}
const workflow = getSalleWorkflowData(request.formData);
if (workflow.quoteStatus !== "accepte" || workflow.directorStatus !== "signee") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Le dossier doit être signé par la Direction avant l'envoi final",
});
}
if (workflow.finalNotificationSentAt) {
throw new TRPCError({ code: "BAD_REQUEST", message: "La confirmation finale a déjà été envoyée" });
}
const association = await db.getAssociationById(request.associationId);
const associationRecipient = association?.emailContact?.trim() || parseRequestFormData(request.formData).emailAssociation?.trim();
if (!associationRecipient) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Aucun email d'association n'est disponible pour la notification finale" });
}
const pricing = workflow.pricing || computeSalleWorkflowPricing(request.formData);
const financialDecision = parseMaterialFinancialDecision(request.formData);
const salleBilling = await getSalleBillingSettings();
if (!pricing || !financialDecision) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Le dossier ne contient pas les données financières nécessaires à l'envoi final" });
}
let effectiveFormDataRaw = request.formData || "";
const invoicePreview = await ensureSalleInvoicePrepared({
request,
association,
pricing,
billed: financialDecision.financialMode === "location_payante",
});
effectiveFormDataRaw = invoicePreview.nextFormData;
const signedWorkflow = getSalleWorkflowData(effectiveFormDataRaw);
const finalEmail = generateSalleFinalEmail({
associationName: association?.nomAssociation || parseRequestFormData(effectiveFormDataRaw).nomAssociation || "association",
datesLabel: buildSalleDatesLabel(parseRequestFormData(effectiveFormDataRaw)),
pricingTotalCents: pricing.totalAmountCents,
billed: financialDecision.financialMode === "location_payante",
});
const attachments: Array<{ filename: string; content: Buffer; contentType?: string }> = [];
const pushStoredPdf = async (url: string | undefined, filename: string | undefined) => {
if (!url || !filename) return;
const stored = await readStoredUploadBufferFromUrl(url);
if (!stored) return;
attachments.push({
filename,
content: stored,
contentType: "application/pdf",
});
};
if (financialDecision.financialMode === "location_payante") {
await pushStoredPdf(signedWorkflow.signedQuotePdfUrl || workflow.signedQuotePdfUrl, signedWorkflow.signedQuotePdfName || workflow.signedQuotePdfName);
}
await pushStoredPdf(signedWorkflow.signedDecisionPdfUrl || workflow.signedDecisionPdfUrl, signedWorkflow.signedDecisionPdfName || workflow.signedDecisionPdfName);
await pushStoredPdf(signedWorkflow.signedRequestPdfUrl || workflow.signedRequestPdfUrl, signedWorkflow.signedRequestPdfName || workflow.signedRequestPdfName);
if (invoicePreview.buffer) {
attachments.push({
filename: invoicePreview.invoicePdfName,
content: invoicePreview.buffer,
contentType: "application/pdf",
});
} else {
await pushStoredPdf(invoicePreview.invoicePdfUrl, invoicePreview.invoicePdfName);
}
if (financialDecision.financialMode === "location_payante" && salleBilling.ribDocumentUrl && salleBilling.ribDocumentName) {
const ribBuffer = await readStoredUploadBufferFromUrl(salleBilling.ribDocumentUrl);
if (ribBuffer) {
attachments.push({
filename: salleBilling.ribDocumentName,
content: ribBuffer,
contentType: salleBilling.ribMimeType || "application/pdf",
});
}
}
const result = await sendOperationalEmail({
to: [associationRecipient],
subject: finalEmail.subject,
text: finalEmail.text,
html: finalEmail.html,
replyTo: ctx.user.email || undefined,
fromName: "Maison de la Jeunesse des Savanes - CCDS",
attachments,
});
if (!result.sent) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: `Le mail final n'a pas pu être envoyé (${result.reason || "raison inconnue"})` });
}
const nextFormData = mergeSalleWorkflowData(effectiveFormDataRaw, {
finalNotificationSentAt: new Date().toISOString(),
});
await db.updateRequest(request.id, {
status: "validee",
dateTraitement: new Date(),
traitePar: ctx.user.id,
formData: nextFormData,
});
await db.createRequestHistory({
requestId: request.id,
action: "validation",
ancienStatut: request.status,
nouveauStatut: "validee",
userId: ctx.user.id,
commentaire: "Confirmation finale envoyée à l'association avec les documents signés et la facture",
});
await logAdminAction(ctx.user.id, "envoi_final_salle", "request", request.id, {
directorStatus: "signee",
pricingTotalCents: pricing.totalAmountCents,
financialMode: financialDecision.financialMode,
ipAddress: extractClientIp(ctx.req),
});
return { success: true };
}),
updateSallePaymentTracking: accueilAdminProcedure
.input(z.object({
id: z.number(),
paymentStatus: z.enum(["en_attente_paiement", "paiement_partiel", "paiement_recu", "paiement_valide"]),
amountReceivedCents: z.number().min(0).optional(),
paymentReceivedAt: z.string().optional(),
paymentReference: z.string().trim().max(255).optional(),
paymentNotes: z.string().trim().max(2000).optional(),
}))
.mutation(async ({ ctx, input }) => {
const request = await db.getRequestById(input.id);
if (!request || request.type !== "demande_salle") {
throw new TRPCError({ code: "NOT_FOUND", message: "Demande de salle introuvable" });
}
const financialDecision = parseMaterialFinancialDecision(request.formData);
if (!financialDecision || financialDecision.financialMode !== "location_payante") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Le suivi de paiement est réservé aux locations de salle payantes",
});
}
const existingPayment = getSallePaymentTracking(request.formData);
const nextPaymentReceivedAt =
input.paymentStatus === "paiement_recu" || input.paymentStatus === "paiement_valide"
? (input.paymentReceivedAt?.trim()
|| existingPayment.paymentReceivedAt
|| new Date().toISOString())
: "";
const nextPaymentValidatedAt =
input.paymentStatus === "paiement_valide"
? (existingPayment.paymentValidatedAt || new Date().toISOString())
: "";
const nextFormData = mergeSalleWorkflowData(
ensureSallePaymentTracking({
rawFormData: request.formData,
billed: true,
totalAmountCents: Number.isFinite(Number(getSalleWorkflowData(request.formData).pricing?.totalAmountCents))
? Number(getSalleWorkflowData(request.formData).pricing?.totalAmountCents)
: financialDecision.rentalAmountCents,
}),
{
paymentStatus: input.paymentStatus,
amountReceivedCents: input.amountReceivedCents ?? existingPayment.amountReceivedCents,
paymentReceivedAt: nextPaymentReceivedAt,
paymentValidatedAt: nextPaymentValidatedAt,
paymentReference: input.paymentReference?.trim() || "",
paymentNotes: input.paymentNotes?.trim() || "",
}
);
await db.updateRequest(request.id, {
formData: nextFormData,
traitePar: ctx.user.id,
});
await db.createRequestHistory({
requestId: request.id,
action: "modification",
ancienStatut: request.status,
nouveauStatut: request.status,
userId: ctx.user.id,
commentaire: `Suivi de paiement mis à jour : ${SALLE_WORKFLOW_PAYMENT_STATUS_LABELS[input.paymentStatus]}`,
});
await logAdminAction(ctx.user.id, "mise_a_jour_paiement_salle", "request", request.id, {
paymentStatus: input.paymentStatus,
amountReceivedCents: input.amountReceivedCents ?? existingPayment.amountReceivedCents,
ipAddress: extractClientIp(ctx.req),
});
return {
success: true,
paymentStatusLabel: SALLE_WORKFLOW_PAYMENT_STATUS_LABELS[input.paymentStatus],
};
}),
create: protectedProcedure
.input(z.object({
type: z.enum(['subvention_fonctionnement', 'subvention_projet', 'agrement_jeunesse_education', 'agrement_sport', 'autorisation_occupation', 'demande_salle', 'demande_materiel_evenementiel', 'autre']),
titre: z.string().min(1),
description: z.string().optional(),
formData: z.string().optional(),
montantDemande: z.number().optional(),
documentsJoints: z.array(z.number()).optional(),
submitImmediately: z.boolean().optional(), // true = créer ET soumettre en une seule opération
privacyConsent: dataPrivacyConsentSchema,
}))
.mutation(async ({ ctx, input }) => {
assertDataPrivacyConsent(input.privacyConsent);
const association = await db.getAssociationByUserId(ctx.user.id);
if (!association) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Veuillez d\'abord créer votre profil association' });
}
await recordDataPrivacyConsent(ctx.user.id, ctx.req, input.privacyConsent);
// Enrichir le formData avec les infos association actuelles pour garantir la persistance
let enrichedFormData = input.formData;
if (input.formData) {
try {
const fd = JSON.parse(input.formData);
// S'assurer que les infos association sont toujours présentes depuis la source de vérité
fd.nomAssociation = fd.nomAssociation || association.nomAssociation || '';
fd.adresseAssociation = fd.adresseAssociation || (association.adresse ? `${association.adresse}, ${association.codePostal || ''} ${association.ville || ''}`.trim() : '');
fd.communeSiege = fd.communeSiege || association.ville || '';
fd.representantLegal = fd.representantLegal || association.nomRepresentant || '';
fd.telephoneAssociation = fd.telephoneAssociation || association.telephone || '';
fd.emailAssociation = fd.emailAssociation || association.emailContact || '';
enrichedFormData = JSON.stringify(fd);
} catch (e) {
// keep original if parse fails
}
}
if (input.type === 'demande_salle') {
validateReservationSalleFormData(enrichedFormData);
}
// Calculate deadline (30 days by default)
const dateLimite = new Date();
dateLimite.setDate(dateLimite.getDate() + 30);
const requestId = await db.createRequest({
associationId: association.id,
type: input.type,
titre: input.titre,
description: input.description,
formData: enrichedFormData,
montantDemande: input.montantDemande,
documentsJoints: input.documentsJoints ? JSON.stringify(input.documentsJoints) : null,
status: 'brouillon',
dateLimiteTraitement: dateLimite,
});
// Log creation
await db.createRequestHistory({
requestId,
action: 'creation',
userId: ctx.user.id,
commentaire: 'Création de la demande',
});
// Si submitImmediately, soumettre directement
if (input.submitImmediately) {
await db.updateRequest(requestId, {
status: 'soumise',
dateSubmission: new Date(),
});
await db.createRequestHistory({
requestId,
action: 'soumission',
ancienStatut: 'brouillon',
nouveauStatut: 'soumise',
userId: ctx.user.id,
commentaire: 'Soumission immédiate de la demande',
});
// Notification admin
await db.createAdminNotification({
type: 'nouvelle_demande',
titre: `Nouvelle demande: ${input.titre}`,
message: `L'association "${association.nomAssociation}" a soumis une demande de type "${input.type}".`,
lien: `/dashboard/requests/${requestId}`,
});
// Generate email action tokens (valid for 7 days)
const validateToken = nanoid(48);
const refuseToken = nanoid(48);
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
await Promise.all([
db.createEmailActionToken({ token: validateToken, requestId, action: 'validee', expiresAt }),
db.createEmailActionToken({ token: refuseToken, requestId, action: 'refusee', expiresAt }),
]);
const baseUrl = ctx.req.headers.origin || `${ctx.req.protocol}://${ctx.req.headers.host}`;
const validateUrl = `${baseUrl}/api/email-action/${validateToken}`;
const refuseUrl = `${baseUrl}/api/email-action/${refuseToken}`;
const viewUrl = `${baseUrl}/dashboard/requests/${requestId}`;
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',
demande_materiel_evenementiel: 'Demande du matériel événementiel',
autre: 'Autre demande',
};
const typeLabel = requestTypeLabels[input.type] || input.type;
try {
await notifyOwner({
title: `📨 Nouvelle demande: ${input.titre}`,
content: `**Association:** ${association.nomAssociation}\n**Type:** ${typeLabel}\n**Description:** ${input.description || 'Non spécifiée'}\n\n---\n\n✅ **Valider la demande:** ${validateUrl}\n\n❌ **Refuser la demande:** ${refuseUrl}\n\n🔍 **Voir le détail:** ${viewUrl}\n\n_Ces liens sont valides pendant 7 jours._`,
});
} catch (e) {
console.error('Failed to notify owner:', e);
}
return { id: requestId, submitted: true };
}
return { id: requestId, submitted: false };
}),
update: protectedProcedure
.input(z.object({
id: z.number(),
titre: z.string().optional(),
description: z.string().optional(),
formData: z.string().optional(),
montantDemande: z.number().optional(),
documentsJoints: z.array(z.number()).optional(),
}))
.mutation(async ({ ctx, input }) => {
const association = await db.getAssociationByUserId(ctx.user.id);
const request = await db.getRequestById(input.id);
if (!request || !association || request.associationId !== association.id) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Demande non trouvée' });
}
if (request.status !== 'brouillon' && request.status !== 'information_complementaire') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Seules les demandes en brouillon ou revenues pour complément peuvent être modifiées' });
}
if (request.type === 'demande_salle' && input.formData) {
validateReservationSalleFormData(input.formData);
}
await db.updateRequest(input.id, {
titre: input.titre,
description: input.description,
formData: input.formData,
montantDemande: input.montantDemande,
documentsJoints: input.documentsJoints ? JSON.stringify(input.documentsJoints) : undefined,
});
await db.createRequestHistory({
requestId: input.id,
action: 'modification',
userId: ctx.user.id,
commentaire: 'Modification de la demande',
});
// Notify admin when the request comes back with requested complementary information
if (request.status === 'information_complementaire') {
await db.createAdminNotification({
type: 'modification_demande',
titre: `Demande modifiée : ${request.titre}`,
message: `L'association "${association.nomAssociation}" a modifié sa demande "${request.titre}" (statut : ${request.status}).`,
lien: `/dashboard/requests/${input.id}`,
});
try {
await notifyOwner({
title: `✏️ Demande modifiée : ${request.titre}`,
content: `L'association "${association.nomAssociation}" a modifié sa demande "${request.titre}".\n\nType : ${request.type}\nStatut actuel : ${request.status}`,
});
} catch (e) {
console.error('Failed to notify owner about modification:', e);
}
}
return { success: true };
}),
submit: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ ctx, input }) => {
const association = await db.getAssociationByUserId(ctx.user.id);
const request = await db.getRequestById(input.id);
if (!request || !association || request.associationId !== association.id) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Demande non trouvée' });
}
if (request.status !== 'brouillon' && request.status !== 'information_complementaire') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cette demande ne peut pas être soumise dans son état actuel' });
}
await db.updateRequest(input.id, {
status: 'soumise',
dateSubmission: new Date(),
});
await db.createRequestHistory({
requestId: input.id,
action: 'soumission',
ancienStatut: request.status,
nouveauStatut: 'soumise',
userId: ctx.user.id,
commentaire: 'Soumission de la demande',
});
// Create admin notification
await db.createAdminNotification({
type: 'nouvelle_demande',
titre: `Nouvelle demande: ${request.titre}`,
message: `L'association "${association.nomAssociation}" a soumis une demande de type "${request.type}".`,
lien: `/dashboard/requests/${input.id}`,
});
// Generate email action tokens (valid for 7 days)
const validateToken = nanoid(48);
const refuseToken = nanoid(48);
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
await Promise.all([
db.createEmailActionToken({ token: validateToken, requestId: input.id, action: 'validee', expiresAt }),
db.createEmailActionToken({ token: refuseToken, requestId: input.id, action: 'refusee', expiresAt }),
]);
// Build base URL for email links
const baseUrl = ctx.req.headers.origin || `${ctx.req.protocol}://${ctx.req.headers.host}`;
const validateUrl = `${baseUrl}/api/email-action/${validateToken}`;
const refuseUrl = `${baseUrl}/api/email-action/${refuseToken}`;
const viewUrl = `${baseUrl}/dashboard/requests/${input.id}`;
// Notify owner with action buttons
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',
demande_materiel_evenementiel: 'Demande du matériel événementiel',
autre: 'Autre demande',
};
const typeLabel = requestTypeLabels[request.type] || request.type;
const montantStr = request.montantDemande ? `${(request.montantDemande / 100).toLocaleString('fr-FR')}` : 'Non spécifié';
await notifyOwner({
title: `📨 Nouvelle demande: ${request.titre}`,
content: `**Association:** ${association.nomAssociation}\n**Type:** ${typeLabel}\n**Montant demandé:** ${montantStr}\n**Description:** ${request.description || 'Non spécifiée'}\n\n---\n\n✅ **Valider la demande:** ${validateUrl}\n\n❌ **Refuser la demande:** ${refuseUrl}\n\n🔍 **Voir le détail:** ${viewUrl}\n\n_Ces liens sont valides pendant 7 jours._`,
});
return { success: true };
}),
duplicate: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ ctx, input }) => {
const association = await db.getAssociationByUserId(ctx.user.id);
const request = await db.getRequestById(input.id);
if (!request || !association || request.associationId !== association.id) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Demande non trouvée' });
}
const dateLimite = new Date();
dateLimite.setDate(dateLimite.getDate() + 30);
const duplicatedRequestId = await db.createRequest({
associationId: association.id,
type: request.type,
titre: `Copie - ${request.titre}`,
description: request.description,
formData: sanitizeDuplicatedRequestFormData(request.formData),
montantDemande: request.montantDemande,
documentsJoints: request.documentsJoints || null,
status: 'brouillon',
priority: request.priority,
dateLimiteTraitement: dateLimite,
});
await db.createRequestHistory({
requestId: duplicatedRequestId,
action: 'creation',
userId: ctx.user.id,
commentaire: `Demande dupliquée à partir du dossier #${request.id}`,
});
return { id: duplicatedRequestId, type: request.type };
}),
listAll: adminProcedure.query(async () => {
return db.getAllRequests();
}),
search: accueilAdminProcedure
.input(z.object({
search: z.string().optional(),
type: z.string().optional(),
status: z.string().optional(),
priority: z.string().optional(),
assigneA: z.number().optional(),
associationId: z.number().optional(),
dateFrom: z.string().optional(),
dateTo: z.string().optional(),
limit: z.number().optional(),
offset: z.number().optional(),
}))
.query(async ({ input }) => {
return db.searchRequests({
...input,
dateFrom: input.dateFrom ? new Date(input.dateFrom) : undefined,
dateTo: input.dateTo ? new Date(input.dateTo) : undefined,
});
}),
getPending: accueilAdminProcedure.query(async () => {
return db.getPendingRequests();
}),
getOverdue: accueilAdminProcedure.query(async () => {
return db.getOverdueRequests();
}),
getSalleSignatureQueue: salleSignerProcedure.query(async () => {
const requests = await db.getAllRequests();
const salleRequests = requests.filter((request) => {
if (request.type !== "demande_salle") return false;
if (request.status === "refusee" || request.status === "annulee") return false;
const workflow = getSalleWorkflowData(request.formData);
return workflow.quoteStatus === "accepte" && workflow.directorStatus === "en_attente_signature";
});
const queue = await Promise.all(
salleRequests.map(async (request) => {
const association = await db.getAssociationById(request.associationId);
const formData = parseRequestFormData(request.formData);
const workflow = getSalleWorkflowData(request.formData);
const financialDecision = parseMaterialFinancialDecision(request.formData);
const paymentTracking = getSallePaymentTracking(request.formData);
let attachedDocuments: Array<{
id: number;
nom: string;
type: string | null;
fileUrl: string;
}> = [];
if (request.documentsJoints) {
try {
const docIds = JSON.parse(request.documentsJoints) as number[];
if (Array.isArray(docIds) && docIds.length > 0) {
const docs = await Promise.all(docIds.map((docId) => db.getDocumentById(docId)));
attachedDocuments = docs
.filter((doc): doc is NonNullable<typeof doc> => Boolean(doc))
.map((doc) => ({
id: doc.id,
nom: doc.nom,
type: doc.type ?? null,
fileUrl: doc.fileUrl,
}));
}
} catch {
attachedDocuments = [];
}
}
return {
id: request.id,
titre: request.titre,
status: request.status,
createdAt: request.createdAt,
associationName: association?.nomAssociation || formData.nomAssociation || `Association #${request.associationId}`,
sallesSelectionnees: Array.isArray(formData.sallesSelectionnees) ? formData.sallesSelectionnees : [],
dateReservation: String(formData.dateReservation || ""),
dateFinReservation: String(formData.dateFinReservation || ""),
heureDebut: String(formData.heureDebut || ""),
heureFin: String(formData.heureFin || ""),
useDetailedSchedule: Boolean(formData.useDetailedSchedule),
horairesParJour: Array.isArray(formData.horairesParJour) ? formData.horairesParJour : [],
quotePdfUrl: typeof workflow.quotePdfUrl === "string" ? workflow.quotePdfUrl : "",
quotePdfName: typeof workflow.quotePdfName === "string" ? workflow.quotePdfName : "",
administrativeDecisionPdfUrl:
typeof workflow.administrativeDecisionPdfUrl === "string" ? workflow.administrativeDecisionPdfUrl : "",
administrativeDecisionPdfName:
typeof workflow.administrativeDecisionPdfName === "string" ? workflow.administrativeDecisionPdfName : "",
directorTransmissionAt: typeof workflow.directorTransmissionAt === "string" ? workflow.directorTransmissionAt : "",
pricing: workflow.pricing || computeSalleWorkflowPricing(request.formData),
financialMode: financialDecision?.financialMode || null,
paymentStatus: paymentTracking.paymentStatus || null,
paymentDueDate: paymentTracking.paymentDueDate || null,
amountReceivedCents: paymentTracking.amountReceivedCents || 0,
paymentReceivedAt: paymentTracking.paymentReceivedAt || null,
attachedDocuments,
};
})
);
return queue.sort((left, right) => {
const leftDate = left.directorTransmissionAt ? new Date(left.directorTransmissionAt).getTime() : 0;
const rightDate = right.directorTransmissionAt ? new Date(right.directorTransmissionAt).getTime() : 0;
return rightDate - leftDate;
});
}),
getPastForSignature: salleReadProcedure.query(async () => {
const allRequests = await db.getAllRequests();
const pastStatuses = new Set(["validee", "refusee", "annulee"]);
return Promise.all(
allRequests
.filter((request) => pastStatuses.has(request.status))
.slice(0, 25)
.map(async (request) => {
const association = await db.getAssociationById(request.associationId);
const formData = parseRequestFormData(request.formData);
const workflow = request.type === "demande_salle" ? getSalleWorkflowData(request.formData) : {};
const paymentTracking = request.type === "demande_salle" ? getSallePaymentTracking(request.formData) : null;
const financialDecision = request.type === "demande_salle" ? parseMaterialFinancialDecision(request.formData) : null;
return {
id: request.id,
type: request.type,
titre: request.titre,
status: request.status,
associationName: association?.nomAssociation || formData.nomAssociation || "Association inconnue",
createdAt: request.createdAt,
updatedAt: request.updatedAt,
dateReservation: formData.dateReservation || null,
dateFinReservation: formData.dateFinReservation || null,
sallesSelectionnees: Array.isArray(formData.sallesSelectionnees) ? formData.sallesSelectionnees : [],
quoteStatus: workflow.quoteStatus || null,
directorStatus: workflow.directorStatus || null,
financialMode: financialDecision?.financialMode || null,
paymentStatus: paymentTracking?.paymentStatus || null,
};
})
);
}),
getSalleBillingBoard: salleReadProcedure.query(async () => {
const allRequests = await db.getAllRequests();
const entries = await Promise.all(
allRequests
.filter((request) => request.type === "demande_salle")
.map(async (request) => {
const formData = parseRequestFormData(request.formData);
const workflow = getSalleWorkflowData(request.formData);
const financialDecision = parseMaterialFinancialDecision(request.formData);
if (financialDecision?.financialMode !== "location_payante") {
return null;
}
const association = await db.getAssociationById(request.associationId);
const paymentTracking = getSallePaymentTracking(request.formData);
const amountDueCents =
workflow.pricing?.totalAmountCents
|| financialDecision.rentalAmountCents
|| 0;
const paymentStatus = paymentTracking.paymentStatus || "en_attente_paiement";
const paymentDueDate = paymentTracking.paymentDueDate || null;
const isLate = Boolean(
paymentDueDate
&& paymentStatus !== "paiement_recu"
&& paymentStatus !== "paiement_valide"
&& paymentDueDate < new Date().toISOString().slice(0, 10)
);
return {
id: request.id,
titre: request.titre,
status: request.status,
createdAt: request.createdAt,
updatedAt: request.updatedAt,
associationName: association?.nomAssociation || formData.nomAssociation || "Association inconnue",
sallesSelectionnees: Array.isArray(formData.sallesSelectionnees) ? formData.sallesSelectionnees : [],
dateReservation: formData.dateReservation || null,
dateFinReservation: formData.dateFinReservation || null,
amountDueCents,
amountReceivedCents: paymentTracking.amountReceivedCents || 0,
paymentStatus,
paymentDueDate,
paymentReceivedAt: paymentTracking.paymentReceivedAt || null,
paymentValidatedAt: paymentTracking.paymentValidatedAt || null,
paymentReference: paymentTracking.paymentReference || null,
finalNotificationSentAt:
typeof workflow.finalNotificationSentAt === "string" ? workflow.finalNotificationSentAt : null,
isLate,
};
})
);
return entries
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry))
.sort((left, right) => {
if (left.isLate !== right.isLate) {
return left.isLate ? -1 : 1;
}
if (left.paymentDueDate && right.paymentDueDate) {
return new Date(left.paymentDueDate).getTime() - new Date(right.paymentDueDate).getTime();
}
return new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime();
});
}),
assign: adminProcedure
.input(z.object({
id: z.number(),
adminId: z.number(),
}))
.mutation(async ({ ctx, input }) => {
const request = await db.getRequestById(input.id);
if (!request) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Demande non trouvée' });
}
await db.assignRequest(input.id, input.adminId);
await db.createRequestHistory({
requestId: input.id,
action: 'assignation',
ancienStatut: request.status,
nouveauStatut: 'en_cours_traitement',
userId: ctx.user.id,
commentaire: `Assignation à l'agent #${input.adminId}`,
});
await logAdminAction(ctx.user.id, 'assignation', 'request', input.id, { adminId: input.adminId });
return { success: true };
}),
process: accueilAdminProcedure
.input(z.object({
id: z.number(),
status: z.enum(['en_cours_traitement', 'information_complementaire', 'validee', 'refusee']),
commentaireAdmin: z.string().optional(),
montantAccorde: z.number().optional(),
recapServiceId: z.number().optional(),
recapRecipients: z.array(z.string().email()).optional(),
recapServiceLabel: z.string().optional(),
supervisionServiceId: z.number().optional(),
supervisionRecipients: z.array(z.string().email()).optional(),
supervisionServiceLabel: z.string().optional(),
// Cadre DSU pour les demandes de salle et de matériel événementiel
dsuData: z.object({
dateReception: z.string().optional(),
avisDSU: z.enum(['favorable', 'defavorable', 'favorable_avec_reserves']).optional(),
conditionsParticulieres: z.string().optional(),
cautionRequise: z.boolean().optional(),
montantCaution: z.string().optional(),
assuranceRequise: z.boolean().optional(),
horairesImposes: z.string().optional(),
responsableDSU: z.string().optional(),
dateDecision: z.string().optional(),
observationsDSU: z.string().optional(),
salleReservation: z.object({
usageType: z.enum(["conventionne", "occasionnel"]).optional(),
frequency: z.enum(["demi_journee", "journee", "mensuel"]).optional(),
}).optional(),
materielEvent: z.object({
itemsAccordes: z.object({
tente3x3: z.boolean().optional(),
chapiteau5x5: z.boolean().optional(),
podium: z.boolean().optional(),
autres: z.boolean().optional(),
}).optional(),
quantitesAccordees: z.object({
tente3x3: z.string().optional(),
chapiteau5x5: z.string().optional(),
podium: z.string().optional(),
autres: z.string().optional(),
}).optional(),
autresPrecisions: z.string().optional(),
financialDecision: z.object({
financialMode: z.enum(["gratuite", "gratuite_avec_caution", "location_payante"]).optional(),
depositRequired: z.boolean().optional(),
depositAmountCents: z.number().int().min(0).optional(),
rentalAmountCents: z.number().int().min(0).optional(),
pricingNotes: z.string().optional(),
contractStatus: z.enum(["a_generer", "generee", "signee", "refusee", "annulee"]).optional(),
}).optional(),
}).optional(),
}).optional(),
generateContract: z.boolean().optional(),
}))
.mutation(async ({ ctx, input }) => {
const request = await db.getRequestById(input.id);
if (!request) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Demande non trouvée' });
}
if (ctx.user.role === "accueil" && request.type !== "demande_salle") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Le rôle accueil ne peut traiter que les demandes de salle",
});
}
if (ctx.user.role === "logistique_controle" && request.type !== "demande_materiel_evenementiel") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Le rôle matériel CCDS - logistique et contrôle ne peut traiter que les demandes de matériel",
});
}
if (request.type === "demande_materiel_evenementiel" && !canManageLogistics(ctx.user)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Les demandes de matériel sont gérées par le service logistique et contrôle",
});
}
if (request.type === "demande_salle") {
const workflow = getSalleWorkflowData(request.formData);
if (workflow.quoteStatus === "en_attente_association") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Cette demande est en attente de réponse de l'association. Elle ne peut plus être traitée tant que le devis n'est pas accepté.",
});
}
}
const selectedRecapService = input.recapServiceId
? await db.getOperationalRecapServiceById(input.recapServiceId)
: undefined;
const selectedSupervisionService = input.supervisionServiceId
? await db.getOperationalRecapServiceById(input.supervisionServiceId)
: undefined;
const resolvedServiceRecipients = parseStoredRecipientEmails(selectedRecapService?.recipientEmails);
const resolvedRecipients = mergeRecipients(resolvedServiceRecipients, parseRecipients(input.recapRecipients));
const resolvedServiceLabel = input.recapServiceLabel?.trim() || selectedRecapService?.label || undefined;
const resolvedSupervisionServiceRecipients = parseStoredRecipientEmails(selectedSupervisionService?.recipientEmails);
const resolvedSupervisionRecipients = mergeRecipients(
resolvedSupervisionServiceRecipients,
parseRecipients(input.supervisionRecipients)
);
const resolvedSupervisionServiceLabel =
input.supervisionServiceLabel?.trim() || selectedSupervisionService?.label || undefined;
if ((request.type === 'demande_salle' || request.type === 'demande_materiel_evenementiel')
&& !input.dsuData?.responsableDSU?.trim()) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Le responsable DSU est obligatoire pour traiter cette demande',
});
}
if ((request.type === 'demande_salle' || request.type === 'demande_materiel_evenementiel') && input.status === 'validee') {
const financialDecision = input.dsuData?.materielEvent?.financialDecision;
if (!financialDecision?.financialMode) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Le mode financier est obligatoire pour valider une ${request.type === 'demande_salle' ? 'demande de salle' : 'demande de matériel'}`,
});
}
}
if (request.type === 'demande_materiel_evenementiel' && input.status === 'validee') {
const grantedItems = input.dsuData?.materielEvent?.itemsAccordes || {};
const grantedQuantities = input.dsuData?.materielEvent?.quantitesAccordees || {};
const invalidGrantedItem = materialEventItems.find((item) => {
if (!grantedItems[item.key]) return false;
return parseMaterialEventQuantity(grantedQuantities[item.key]) <= 0;
});
if (invalidGrantedItem) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `La quantité accordée est obligatoire pour ${invalidGrantedItem.label}`,
});
}
}
const canSendRecapEmails = await canSendOperationalEmails();
const existingFinancialDecision = parseMaterialFinancialDecision(request.formData);
const parsedRequestFormData = parseRequestFormData(request.formData);
const resolvedSalleUsageType = request.type === 'demande_salle'
? (input.dsuData?.salleReservation?.usageType || parsedRequestFormData.typeUsage || 'conventionne')
: undefined;
const resolvedSalleFrequency = request.type === 'demande_salle'
? (input.dsuData?.salleReservation?.frequency || parsedRequestFormData.frequence || 'journee')
: undefined;
const computedSallePricing = request.type === 'demande_salle'
? computeSallePricing({
sallesIds: Array.isArray(parsedRequestFormData.sallesIds)
? parsedRequestFormData.sallesIds.filter((value: unknown): value is string => typeof value === 'string' && value.trim().length > 0)
: [],
usageType: resolvedSalleUsageType as "conventionne" | "occasionnel",
frequency: resolvedSalleFrequency as "demi_journee" | "journee" | "mensuel",
dateReservation: parsedRequestFormData.dateReservation,
dateFinReservation: parsedRequestFormData.dateFinReservation,
})
: null;
if (
request.type === 'demande_salle'
&& input.dsuData?.materielEvent?.financialDecision?.financialMode === 'location_payante'
&& (!computedSallePricing || computedSallePricing.unsupportedSalles.length > 0)
) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: computedSallePricing?.unsupportedSalles?.length
? `Tarification automatique indisponible pour : ${computedSallePricing.unsupportedSalles.map((item: { salleNom: string }) => item.salleNom).join(', ')}`
: 'La tarification automatique est indisponible pour cette demande de salle',
});
}
// If DSU data is provided, merge it into the existing formData
let updatedFormData: string | undefined;
if (input.dsuData && (request.type === 'demande_salle' || request.type === 'demande_materiel_evenementiel')) {
try {
const existingFormData = request.formData ? JSON.parse(request.formData) : {};
if (request.type === 'demande_salle') {
existingFormData.typeUsage = resolvedSalleUsageType;
existingFormData.frequence = resolvedSalleFrequency;
}
const nextFinancialDecision = (request.type === 'demande_salle' || request.type === 'demande_materiel_evenementiel')
? (() => {
const inputFinancial = input.dsuData?.materielEvent?.financialDecision;
if (input.status === 'refusee') {
return {
...(existingFinancialDecision || {
financialMode: 'gratuite' as const,
depositRequired: false,
depositAmountCents: 0,
rentalAmountCents: 0,
pricingNotes: '',
}),
contractStatus: 'refusee' as const,
};
}
if (!inputFinancial?.financialMode) {
return existingFinancialDecision;
}
const depositRequired = Boolean(inputFinancial.depositRequired);
const depositAmountCents = depositRequired ? Math.max(0, inputFinancial.depositAmountCents || 0) : 0;
const rentalAmountCents = inputFinancial.financialMode === 'location_payante'
? request.type === 'demande_salle' && computedSallePricing
? computedSallePricing.totalAmountCents
: Math.max(0, inputFinancial.rentalAmountCents || 0)
: 0;
return {
financialMode: inputFinancial.financialMode,
depositRequired,
depositAmountCents,
rentalAmountCents,
pricingNotes: inputFinancial.pricingNotes || '',
contractStatus: inputFinancial.contractStatus || (input.status === 'validee' ? 'a_generer' : 'annulee'),
};
})()
: null;
existingFormData.cadreDSU = {
...input.dsuData,
...(request.type === 'demande_salle' ? {
salleReservation: {
usageType: resolvedSalleUsageType,
frequency: resolvedSalleFrequency,
},
} : {}),
responsableDSU: input.dsuData.responsableDSU?.trim(),
cautionRequise: nextFinancialDecision ? nextFinancialDecision.depositRequired : input.dsuData.cautionRequise,
montantCaution: nextFinancialDecision?.depositRequired
? String((nextFinancialDecision.depositAmountCents || 0) / 100)
: input.dsuData.montantCaution,
rempliPar: ctx.user.id,
dateRemplissage: new Date().toISOString(),
...((request.type === 'demande_salle' || request.type === 'demande_materiel_evenementiel') ? {
materielEvent: {
...input.dsuData.materielEvent,
financialDecision: nextFinancialDecision || undefined,
},
} : {}),
};
if (
resolvedRecipients.length
|| resolvedServiceLabel
|| resolvedSupervisionRecipients.length
|| resolvedSupervisionServiceLabel
) {
existingFormData.notificationTrace = {
serviceId: selectedRecapService?.id,
serviceLabel: resolvedServiceLabel,
recipients: resolvedRecipients,
supervisionServiceId: selectedSupervisionService?.id,
supervisionServiceLabel: resolvedSupervisionServiceLabel,
supervisionRecipients: resolvedSupervisionRecipients,
processedBy: ctx.user.name || `Admin #${ctx.user.id}`,
processedAt: new Date().toISOString(),
channel: canSendRecapEmails ? 'smtp' : 'notification_only',
};
}
updatedFormData = JSON.stringify(existingFormData);
if (request.type === 'demande_salle' && nextFinancialDecision) {
const texts = buildDefaultSalleWorkflowTexts(updatedFormData);
updatedFormData = mergeSalleWorkflowData(updatedFormData, {
usageType: resolvedSalleUsageType,
frequency: resolvedSalleFrequency,
pricing: computedSallePricing || undefined,
conditionsFinancieresText: CONDITIONS_FINANCIERES_SALLE_TEXT,
decisionAdministrativeText: texts.decisionAdministrativeText,
quoteStatus: existingFormData.salleWorkflow?.quoteStatus || 'a_preparer',
directorStatus: existingFormData.salleWorkflow?.directorStatus || 'a_transmettre',
});
}
} catch (e) {
console.error('Failed to merge DSU data:', e);
}
} else if (
resolvedRecipients.length
|| resolvedServiceLabel
|| resolvedSupervisionRecipients.length
|| resolvedSupervisionServiceLabel
) {
try {
const existingFormData = request.formData ? JSON.parse(request.formData) : {};
existingFormData.notificationTrace = {
serviceId: selectedRecapService?.id,
serviceLabel: resolvedServiceLabel,
recipients: resolvedRecipients,
supervisionServiceId: selectedSupervisionService?.id,
supervisionServiceLabel: resolvedSupervisionServiceLabel,
supervisionRecipients: resolvedSupervisionRecipients,
processedBy: ctx.user.name || `Admin #${ctx.user.id}`,
processedAt: new Date().toISOString(),
channel: canSendRecapEmails ? 'smtp' : 'notification_only',
};
updatedFormData = JSON.stringify(existingFormData);
} catch (e) {
console.error('Failed to merge notification trace:', e);
}
}
const association = request.associationId ? await db.getAssociationById(request.associationId) : null;
let effectiveStatus = input.status;
if (request.type === 'demande_salle' && input.status === 'validee') {
effectiveStatus = 'en_cours_traitement';
}
await db.updateRequest(input.id, {
status: effectiveStatus,
commentaireAdmin: input.commentaireAdmin,
montantAccorde: input.montantAccorde,
traitePar: ctx.user.id,
dateTraitement: input.status === 'refusee' ? new Date() : undefined,
...(updatedFormData ? { formData: updatedFormData } : {}),
});
if (request.type === 'demande_salle' && input.status === 'validee') {
const refreshedRequest = await db.getRequestById(input.id);
if (!refreshedRequest) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Demande de salle introuvable après traitement' });
}
const quoteWorkflow = await sendSalleQuoteWorkflow({
request: refreshedRequest,
association: association || undefined,
req: ctx.req,
user: ctx.user,
});
updatedFormData = quoteWorkflow.updatedFormData;
await db.updateRequest(input.id, {
status: effectiveStatus,
formData: updatedFormData,
traitePar: ctx.user.id,
});
}
let generatedContract: {
contractGeneratedAt: Date;
contractPdfKey: string;
contractPdfUrl: string;
contractPdfName: string;
} | null = null;
if (
(request.type === 'demande_materiel_evenementiel' || request.type === 'demande_salle')
&& input.status === 'validee'
&& input.generateContract
&& updatedFormData
&& request.type !== 'demande_salle'
) {
const refreshedRequest = await db.getRequestById(input.id);
const financialDecision = parseMaterialFinancialDecision(updatedFormData);
if (refreshedRequest && financialDecision) {
generatedContract = await generateAndStoreMaterialConventionForRequest({
request: {
...refreshedRequest,
formData: updatedFormData,
},
association: association || undefined,
decision: {
financialMode: financialDecision.financialMode,
depositRequired: financialDecision.depositRequired,
depositAmountCents: financialDecision.depositAmountCents,
rentalAmountCents: financialDecision.rentalAmountCents,
pricingNotes: financialDecision.pricingNotes,
},
validatedByUserId: ctx.user.id,
});
updatedFormData = mergeMaterialFinancialDecision({
rawFormData: updatedFormData,
decision: {
financialMode: financialDecision.financialMode,
depositRequired: financialDecision.depositRequired,
depositAmountCents: financialDecision.depositAmountCents,
rentalAmountCents: financialDecision.rentalAmountCents,
pricingNotes: financialDecision.pricingNotes,
contractStatus: 'generee',
contractPdfUrl: generatedContract.contractPdfUrl,
contractPdfName: generatedContract.contractPdfName,
contractGeneratedAt: generatedContract.contractGeneratedAt,
contractValidatedByUserId: ctx.user.id,
},
});
await db.updateRequest(input.id, {
formData: updatedFormData,
});
}
}
const actionType = input.status === 'validee' ? 'validation' : input.status === 'refusee' ? 'refus' : 'changement_statut';
await db.createRequestHistory({
requestId: input.id,
action: actionType,
ancienStatut: request.status,
nouveauStatut: effectiveStatus,
userId: ctx.user.id,
commentaire: request.type === 'demande_salle' && input.status === 'validee'
? (input.commentaireAdmin
? `${input.commentaireAdmin}\n\nDevis et décision administrative prioritaire envoyés à l'association pour validation.`
: "Devis et décision administrative prioritaire envoyés à l'association pour validation.")
: input.commentaireAdmin,
});
await logAdminAction(ctx.user.id, actionType, 'request', input.id, {
status: effectiveStatus,
montantAccorde: input.montantAccorde,
dsuData: input.dsuData,
generateContract: Boolean(input.generateContract),
contractPdfName: generatedContract?.contractPdfName,
recapServiceId: selectedRecapService?.id,
recapRecipients: resolvedRecipients,
recapServiceLabel: resolvedServiceLabel,
supervisionServiceId: selectedSupervisionService?.id,
supervisionRecipients: resolvedSupervisionRecipients,
supervisionServiceLabel: resolvedSupervisionServiceLabel,
});
// Envoyer le récapitulatif complet par email à tous les administrateurs
if ((input.status === 'validee' || input.status === 'refusee') && request.type !== 'demande_salle') {
try {
// Récupérer les documents joints
const allDocs = request.associationId ? await db.getDocumentsByAssociationId(request.associationId) : [];
let requestDocs: { name: string; type: string }[] = [];
if (request.formData) {
try {
const fd = JSON.parse(request.formData);
if (fd.documentsJoints && Array.isArray(fd.documentsJoints)) {
requestDocs = fd.documentsJoints.map((docId: number) => {
const doc = allDocs.find((d: any) => d.id === docId);
return doc ? { name: doc.nom, type: doc.type || 'Document' } : { name: `Document #${docId}`, type: 'Document' };
});
}
} catch (e) { /* ignore */ }
}
// Récupérer la demande mise à jour (avec les données DSU mises à jour)
const updatedRequest = await db.getRequestById(input.id);
const reqForRecap = updatedRequest || request;
let pdfAttachment:
| {
filename: string;
content: Buffer;
contentType?: string;
}
| undefined;
if (input.status === 'validee') {
try {
const pdfDocument = await generateRequestPdfDocument({
...reqForRecap,
status: input.status,
commentaireAdmin: input.commentaireAdmin ?? reqForRecap.commentaireAdmin,
dateTraitement: new Date(),
});
pdfAttachment = {
filename: pdfDocument.fileName,
content: pdfDocument.buffer,
contentType: pdfDocument.contentType,
};
} catch (pdfError) {
console.warn(
`PDF non joint au mail opérationnel pour la demande #${input.id}:`,
pdfError
);
}
}
const recap = generateRequestRecapEmail({
request: {
id: reqForRecap.id,
titre: reqForRecap.titre,
type: reqForRecap.type,
status: effectiveStatus,
description: reqForRecap.description,
montantDemande: reqForRecap.montantDemande,
montantAccorde: input.montantAccorde ?? reqForRecap.montantAccorde,
commentaireAdmin: input.commentaireAdmin ?? reqForRecap.commentaireAdmin,
formData: reqForRecap.formData,
dateSubmission: reqForRecap.dateSubmission,
dateTraitement: new Date(),
createdAt: reqForRecap.createdAt,
},
association: association ? {
nomAssociation: association.nomAssociation,
siret: association.siret,
adresse: association.adresse,
codePostal: association.codePostal,
ville: association.ville,
telephone: association.telephone,
emailContact: association.emailContact,
nomRepresentant: association.nomRepresentant,
} : null,
traitePar: ctx.user.name || `Admin #${ctx.user.id}`,
documents: requestDocs.length > 0 ? requestDocs : undefined,
serviceLabel: resolvedServiceLabel,
});
try {
await notifyOwner(recap);
} catch (notificationError) {
console.warn('Notification propriétaire non envoyée:', notificationError);
}
const recipients = resolvedRecipients;
if (recipients.length > 0) {
try {
const result = await sendOperationalEmail({
to: recipients,
subject: recap.title,
text: recap.content,
html: recap.html,
replyTo: ctx.user.email || undefined,
fromName: ctx.user.name ? `${ctx.user.name} via Portail Associations` : "Portail Associations",
attachments: pdfAttachment ? [pdfAttachment] : undefined,
});
if (!result.sent) {
console.warn('Envoi email opérationnel non effectué:', result.reason);
}
} catch (mailError) {
console.error('Erreur lors de lenvoi du mail opérationnel:', mailError);
}
}
} catch (e) {
console.error('Erreur lors de l\'envoi du récapitulatif par email:', e);
// Ne pas bloquer le traitement si l'envoi échoue
}
}
if (request.type === 'demande_materiel_evenementiel' && input.status === 'validee') {
await scheduleMaterialReturnFollowup({
request: {
...request,
status: input.status,
formData: updatedFormData || request.formData,
},
serviceLabel: resolvedServiceLabel,
recipientEmails: resolvedRecipients,
supervisionServiceLabel: resolvedSupervisionServiceLabel,
supervisionRecipientEmails: resolvedSupervisionRecipients,
});
}
return { success: true };
}),
reopen: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ ctx, input }) => {
const request = await db.getRequestById(input.id);
if (!request) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Demande non trouvée' });
}
if (request.status !== 'validee' && request.status !== 'refusee') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Seules les demandes traitées peuvent être rouvertes' });
}
const reopenedComment = request.commentaireAdmin
? `${request.commentaireAdmin}\n\nDemande rouverte par l'administration pour modification.`
: 'Demande rouverte par l\'administration pour modification.';
await db.updateRequest(input.id, {
status: 'information_complementaire',
commentaireAdmin: reopenedComment,
dateTraitement: null,
traitePar: null,
});
await db.createRequestHistory({
requestId: input.id,
action: 'changement_statut',
ancienStatut: request.status,
nouveauStatut: 'information_complementaire',
userId: ctx.user.id,
commentaire: 'Réouverture de la demande pour permettre une nouvelle modification par lassociation',
});
await logAdminAction(ctx.user.id, 'reopen_request', 'request', input.id, {
ancienStatut: request.status,
nouveauStatut: 'information_complementaire',
});
return { success: true };
}),
getHistory: adminProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
return db.getRequestHistoryByRequestId(input.id);
}),
// Calendrier : récupérer toutes les réservations accordées
getApprovedReservations: salleReadProcedure.query(async () => {
return db.getApprovedReservations();
}),
getMaterialCalendar: salleReadProcedure.query(async () => {
const logisticsSettings = await getLogisticsSettings();
const inventory = logisticsSettings.inventory;
const materialRequests = (await db.searchRequests({
type: "demande_materiel_evenementiel",
status: "validee",
limit: 500,
})).data;
const events = await Promise.all(
materialRequests.map(async (request) => {
const association = await db.getAssociationById(request.associationId);
const followup = await db.getMaterialReturnFollowupByRequestId(request.id);
const event = getMaterialRequestEventData(request, association);
const boardState = computeMaterialReturnBoardState({
requestStatus: request.status,
restitutionDate: event.restitutionDate,
followup,
graceDays: logisticsSettings.materialReturnGraceDays,
});
return {
...event,
boardState,
boardStateLabel: materialReturnBoardStateLabels[boardState],
logisticServiceLabel: followup?.serviceLabel || "",
logisticRecipients: parseStoredRecipientEmails(followup?.recipientEmails),
followupStatus: followup?.status || null,
issueFlag: Boolean(followup?.issueFlag),
litigationStatus: followup?.litigationStatus || "none",
finalPdfUrl: followup?.finalPdfUrl || followup?.signedFileUrl || null,
uploadLink: followup?.uploadToken ? `/materiel/restitution/${followup.uploadToken}` : null,
};
})
);
const followups = await Promise.all(materialRequests.map((request) => db.getMaterialReturnFollowupByRequestId(request.id)));
const blockedTotals = sumBlockedInventory(followups);
const conflicts = events.flatMap((event) =>
event.requestedItems.flatMap((item) => {
const capacity = inventory[item.key];
const blockedQuantity = blockedTotals[item.key] || 0;
const effectiveCapacity = capacity === null ? null : Math.max(capacity - blockedQuantity, 0);
const overlaps = events.filter((other) =>
other.requestId !== event.requestId
&& other.requestedItems.some((otherItem) => otherItem.key === item.key && otherItem.quantity > 0)
&& dateRangesOverlap(event.useStartDate, event.useEndDate, other.useStartDate, other.useEndDate)
);
const stockConflicts = effectiveCapacity === null || item.quantity <= 0
? []
: (() => {
const reservedQuantity = overlaps.reduce((sum, other) => {
const overlappingItem = other.requestedItems.find((entry) => entry.key === item.key);
return sum + (overlappingItem?.quantity || 0);
}, item.quantity);
if (reservedQuantity <= effectiveCapacity) return [];
return [{
requestId: event.requestId,
materialKey: item.key,
materialLabel: item.label,
reservedQuantity,
capacity: effectiveCapacity,
message: `${item.label} réservé au-delà du stock disponible (${reservedQuantity}/${effectiveCapacity})`,
}];
})();
const litigeConflicts = overlaps
.filter((other) => other.litigationStatus === "pending")
.map((other) => ({
requestId: event.requestId,
materialKey: item.key,
materialLabel: item.label,
reservedQuantity: item.quantity,
capacity: effectiveCapacity,
message: `Attention, ${item.label} est planifié pour ce dossier alors qu'un prêt précédent (#${other.requestId}) est déclaré en litige ou dégradation.`,
}));
return [...stockConflicts, ...litigeConflicts];
})
);
return {
inventory: materialEventItems.map((item) => ({
key: item.key,
label: item.label,
capacity: inventory[item.key],
replacementValue: logisticsSettings.replacementValues[item.key],
blocked: blockedTotals[item.key] || 0,
effectiveCapacity: inventory[item.key] === null ? null : Math.max((inventory[item.key] || 0) - (blockedTotals[item.key] || 0), 0),
})),
events,
conflicts,
};
}),
getLogisticsPolicy: protectedProcedure.query(async () => {
return getLogisticsSettings();
}),
getMaterialAvailability: protectedProcedure
.input(z.object({
dateDebut: z.string().optional(),
dateFin: z.string().optional(),
}))
.query(async ({ input }) => {
const logisticsSettings = await getLogisticsSettings();
const inventory = logisticsSettings.inventory;
const allMaterialRequests = (await db.searchRequests({
type: "demande_materiel_evenementiel",
limit: 500,
})).data;
const materialRequests = allMaterialRequests.filter((request) =>
ACTIVE_MATERIAL_AVAILABILITY_STATUSES.has(request.status)
);
const followups = await Promise.all(materialRequests.map((request) => db.getMaterialReturnFollowupByRequestId(request.id)));
const blockedTotals = sumBlockedInventory(followups);
const dateDebut = normalizeDateString(input.dateDebut);
const dateFin = normalizeDateString(input.dateFin || input.dateDebut);
if (!dateDebut || !dateFin) {
return {
graceDays: logisticsSettings.materialReturnGraceDays,
items: materialEventItems.map((item) => ({
key: item.key,
label: item.label,
capacity: inventory[item.key],
replacementValue: logisticsSettings.replacementValues[item.key],
reserved: 0,
available: inventory[item.key] === null ? null : Math.max((inventory[item.key] || 0) - (blockedTotals[item.key] || 0), 0),
blocked: (blockedTotals[item.key] || 0) > 0,
blockedQuantity: blockedTotals[item.key] || 0,
})),
};
}
const reservations = materialRequests.map((request) => getMaterialRequestEventData(request, null));
const items = materialEventItems.map((item) => {
const capacity = inventory[item.key];
const blockedQuantity = blockedTotals[item.key] || 0;
const effectiveCapacity = capacity === null ? null : Math.max(capacity - blockedQuantity, 0);
const reserved = reservations.reduce((sum, reservation) => {
if (!dateRangesOverlap(dateDebut, dateFin, reservation.useStartDate, reservation.useEndDate)) {
return sum;
}
const reservedItem = reservation.requestedItems.find((entry) => entry.key === item.key);
return sum + (reservedItem?.quantity || 0);
}, 0);
const available = effectiveCapacity === null ? null : Math.max(effectiveCapacity - reserved, 0);
return {
key: item.key,
label: item.label,
capacity,
replacementValue: logisticsSettings.replacementValues[item.key],
reserved,
available,
blocked: (blockedQuantity > 0) || (effectiveCapacity !== null ? (available ?? 0) <= 0 : false),
blockedQuantity,
};
});
return { items, graceDays: logisticsSettings.materialReturnGraceDays };
}),
// Modifier l'attribution des salles d'une réservation
changeSalles: adminProcedure
.input(z.object({
id: z.number(),
newSallesIds: z.array(z.string()),
newSallesNoms: z.array(z.string()),
raison: z.string().min(1, 'Veuillez indiquer la raison du changement'),
}))
.mutation(async ({ ctx, input }) => {
const request = await db.getRequestById(input.id);
if (!request) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Demande non trouvée' });
}
if (request.type !== 'demande_salle') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cette demande n\'est pas une demande de salle' });
}
// Parse existing formData
let formData: any = {};
try {
formData = request.formData ? JSON.parse(request.formData) : {};
} catch {
formData = {};
}
// Save old salles for notification
const anciennesSalles = formData.sallesSelectionnees || [];
// Update salles
formData.sallesSelectionnees = input.newSallesNoms;
formData.sallesIds = input.newSallesIds;
// Add change history to formData
if (!formData.historiqueChangementsSalles) {
formData.historiqueChangementsSalles = [];
}
formData.historiqueChangementsSalles.push({
date: new Date().toISOString(),
anciennesSalles,
nouvellesSalles: input.newSallesNoms,
raison: input.raison,
parAdmin: ctx.user.id,
});
// Update in DB
await db.updateRequestSalles(input.id, JSON.stringify(formData));
// Log in request history
await db.createRequestHistory({
requestId: input.id,
action: 'changement_salle',
ancienStatut: request.status,
nouveauStatut: request.status,
userId: ctx.user.id,
commentaire: `Changement de salle(s) : ${anciennesSalles.join(', ')}${input.newSallesNoms.join(', ')}. Raison : ${input.raison}`,
});
// Audit log
await logAdminAction(ctx.user.id, 'changement_salle', 'request', input.id, {
anciennesSalles,
nouvellesSalles: input.newSallesNoms,
raison: input.raison,
});
// Create admin notification for record
await db.createAdminNotification({
type: 'changement_salle',
titre: `Changement de salle : ${request.titre}`,
message: `Salles modifiées de ${anciennesSalles.join(', ')} vers ${input.newSallesNoms.join(', ')}. Raison : ${input.raison}`,
lien: `/dashboard/requests/${input.id}`,
});
// Notify the association owner via platform notification
try {
const association = await db.getAssociationById(request.associationId);
const notifTitle = `Changement de salle pour votre réservation`;
const notifContent = `Bonjour ${association?.nomAssociation || 'Cher adhérent'},\n\nVotre réservation "${request.titre}" a fait l'objet d'un changement de salle.\n\nAnciennes salles : ${anciennesSalles.join(', ')}\nNouvelles salles : ${input.newSallesNoms.join(', ')}\n\nRaison : ${input.raison}\n\nCordialement,\nLa Direction des Services aux Usagers`;
await notifyOwner({ title: notifTitle, content: notifContent });
} catch (e) {
console.error('Failed to send notification for salle change:', e);
}
return { success: true, anciennesSalles, nouvellesSalles: input.newSallesNoms };
}),
cancel: protectedProcedure
.input(z.object({
id: z.number(),
raison: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const request = await db.getRequestById(input.id);
if (!request) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Demande non trouvée' });
}
const association = await db.getAssociationByUserId(ctx.user.id);
if (!association || request.associationId !== association.id) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Vous ne pouvez annuler que vos propres demandes' });
}
if (request.status === 'validee' || request.status === 'refusee' || request.status === 'annulee') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cette demande ne peut plus être annulée' });
}
const ancienStatut = request.status;
await db.updateRequest(input.id, {
status: 'annulee',
dateTraitement: new Date(),
});
await db.createRequestHistory({
requestId: input.id,
action: 'annulation',
ancienStatut: ancienStatut,
nouveauStatut: 'annulee',
userId: ctx.user.id,
commentaire: input.raison || 'Annulation par l\'association',
});
// Notify admin
await db.createAdminNotification({
type: 'annulation_demande',
titre: `Demande annulée : ${request.titre}`,
message: `L'association "${association.nomAssociation}" a annulé sa demande "${request.titre}".${input.raison ? ` Raison : ${input.raison}` : ''}`,
lien: `/dashboard/requests/${input.id}`,
});
// Notify owner
try {
await notifyOwner({
title: `❌ Demande annulée : ${request.titre}`,
content: `L'association "${association.nomAssociation}" a annulé sa demande "${request.titre}".\n\nType : ${request.type}\nAncien statut : ${ancienStatut}${input.raison ? `\nRaison : ${input.raison}` : ''}`,
});
} catch (e) {
console.error('Failed to notify owner about cancellation:', e);
}
await logAdminAction(ctx.user.id, 'annulation_demande', 'request', input.id, {
titre: request.titre,
type: request.type,
ancienStatut,
raison: input.raison,
});
return { success: true };
}),
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ ctx, input }) => {
const request = await db.getRequestById(input.id);
if (!request) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Demande non trouvée' });
}
const association = await db.getAssociationByUserId(ctx.user.id);
if (!association || request.associationId !== association.id) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Vous ne pouvez supprimer que vos propres demandes' });
}
if (request.status === 'validee' || request.status === 'refusee') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Une demande déjà traitée ne peut pas être supprimée' });
}
await db.deleteRequest(input.id);
await logAdminAction(ctx.user.id, 'suppression_demande', 'request', input.id, {
titre: request.titre,
type: request.type,
status: request.status,
});
return { success: true };
}),
}),
// ============== REQUEST TEMPLATE ROUTES ==============
template: router({
listAll: publicProcedure.query(async () => {
return db.getAllRequestTemplates();
}),
getById: publicProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
return db.getRequestTemplateById(input.id);
}),
getByType: publicProcedure
.input(z.object({ type: z.string() }))
.query(async ({ input }) => {
return db.getRequestTemplateByType(input.type);
}),
create: adminProcedure
.input(z.object({
type: z.enum(['subvention_fonctionnement', 'subvention_projet', 'agrement_jeunesse_education', 'agrement_sport', 'autorisation_occupation', 'demande_salle', 'demande_materiel_evenementiel', 'autre']),
nom: z.string().min(1),
description: z.string().optional(),
formSchema: z.string(),
documentsRequis: z.array(z.string()).optional(),
serviceDestinataire: z.string().optional(),
emailDestinataire: z.string().email().optional(),
delaiTraitementJours: z.number().optional(),
}))
.mutation(async ({ ctx, input }) => {
const id = await db.createRequestTemplate({
...input,
documentsRequis: input.documentsRequis ? JSON.stringify(input.documentsRequis) : null,
});
await logAdminAction(ctx.user.id, 'creation', 'template', id);
return { id };
}),
update: adminProcedure
.input(z.object({
id: z.number(),
nom: z.string().optional(),
description: z.string().optional(),
formSchema: z.string().optional(),
documentsRequis: z.array(z.string()).optional(),
serviceDestinataire: z.string().optional(),
emailDestinataire: z.string().email().optional(),
delaiTraitementJours: z.number().optional(),
actif: z.boolean().optional(),
}))
.mutation(async ({ ctx, input }) => {
await db.updateRequestTemplate(input.id, {
...input,
documentsRequis: input.documentsRequis ? JSON.stringify(input.documentsRequis) : undefined,
});
await logAdminAction(ctx.user.id, 'modification', 'template', input.id);
return { success: true };
}),
}),
// ============== RESPONSE TEMPLATE ROUTES ==============
responseTemplate: router({
listAll: adminProcedure.query(async () => {
return db.getAllResponseTemplates();
}),
getById: adminProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
return db.getResponseTemplateById(input.id);
}),
create: adminProcedure
.input(z.object({
nom: z.string().min(1),
type: z.enum(['validation', 'refus', 'information_complementaire', 'autre']),
sujet: z.string().optional(),
contenu: z.string().min(1),
}))
.mutation(async ({ ctx, input }) => {
const id = await db.createResponseTemplate(input);
await logAdminAction(ctx.user.id, 'creation', 'response_template', id);
return { id };
}),
update: adminProcedure
.input(z.object({
id: z.number(),
nom: z.string().optional(),
type: z.enum(['validation', 'refus', 'information_complementaire', 'autre']).optional(),
sujet: z.string().optional(),
contenu: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
await db.updateResponseTemplate(input.id, input);
await logAdminAction(ctx.user.id, 'modification', 'response_template', input.id);
return { success: true };
}),
delete: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ ctx, input }) => {
await db.deleteResponseTemplate(input.id);
await logAdminAction(ctx.user.id, 'suppression', 'response_template', input.id);
return { success: true };
}),
}),
operationalRecapService: router({
listAll: logisticsProcedure.query(async () => {
// Répertoire utilisé pour l'assignation terrain et le suivi interne.
// On le réserve aux administrateurs logistiques et au super administrateur.
const services = await db.getAllOperationalRecapServices();
return services.map(service => ({
...service,
usage: service.usage === "controle" ? "controle" : "terrain",
recipientEmails: parseStoredRecipientEmails(service.recipientEmails),
}));
}),
getById: logisticsProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const service = await db.getOperationalRecapServiceById(input.id);
return serializeOperationalRecapService(service);
}),
create: logisticsProcedure
.input(z.object({
label: z.string().min(1),
usage: z.enum(["terrain", "controle"]),
description: z.string().optional(),
recipientEmails: z.array(z.string().email()).min(1),
actif: z.boolean().optional(),
}))
.mutation(async ({ ctx, input }) => {
const id = await db.createOperationalRecapService({
label: input.label.trim(),
usage: input.usage,
description: input.description?.trim() || null,
recipientEmails: JSON.stringify(parseRecipients(input.recipientEmails)),
actif: input.actif ?? true,
});
await logAdminAction(ctx.user.id, 'creation', 'operational_recap_service', id, {
label: input.label.trim(),
usage: input.usage,
recipientEmails: parseRecipients(input.recipientEmails),
});
return { id };
}),
update: logisticsProcedure
.input(z.object({
id: z.number(),
label: z.string().min(1).optional(),
usage: z.enum(["terrain", "controle"]).optional(),
description: z.string().optional(),
recipientEmails: z.array(z.string().email()).optional(),
actif: z.boolean().optional(),
}))
.mutation(async ({ ctx, input }) => {
await db.updateOperationalRecapService(input.id, {
...(input.label !== undefined ? { label: input.label.trim() } : {}),
...(input.usage !== undefined ? { usage: input.usage } : {}),
...(input.description !== undefined ? { description: input.description.trim() || null } : {}),
...(input.recipientEmails !== undefined ? { recipientEmails: JSON.stringify(parseRecipients(input.recipientEmails)) } : {}),
...(input.actif !== undefined ? { actif: input.actif } : {}),
});
await logAdminAction(ctx.user.id, 'modification', 'operational_recap_service', input.id, {
label: input.label?.trim(),
usage: input.usage,
recipientEmails: input.recipientEmails ? parseRecipients(input.recipientEmails) : undefined,
actif: input.actif,
});
return { success: true };
}),
delete: logisticsProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ ctx, input }) => {
await db.deleteOperationalRecapService(input.id);
await logAdminAction(ctx.user.id, 'suppression', 'operational_recap_service', input.id);
return { success: true };
}),
}),
// ============== ADMIN USER MANAGEMENT ==============
adminUsers: router({
listAll: superAdminProcedure.query(async () => {
return db.getAdminUsers();
}),
create: superAdminProcedure
.input(z.object({
name: z.string().trim().min(2, "Le nom est obligatoire"),
email: z.string().trim().email("Adresse email invalide"),
password: z.string().min(8, "Le mot de passe doit contenir au moins 8 caractères").optional(),
role: z.enum(['accueil', 'service_terrain', 'logistique_controle', 'admin', 'directrice', 'super_admin']),
canManageLogistics: z.boolean().optional(),
}))
.mutation(async ({ ctx, input }) => {
const normalizedEmail = input.email.trim().toLowerCase();
const existingUser = await db.getUserByEmail(normalizedEmail);
if (existingUser) {
const updatePayload: Record<string, unknown> = {
name: input.name.trim(),
email: normalizedEmail,
role: input.role,
mfaEnabled: isInternalRole(input.role) ? true : existingUser.mfaEnabled,
canManageLogistics:
input.role === "super_admin"
|| input.role === "logistique_controle"
? true
: input.role === "admin"
? Boolean(input.canManageLogistics)
: false,
delegatedSalleSignerUserId: null,
isActive: true,
};
if (existingUser.loginMethod === "local_jwt" || existingUser.loginMethod === "bootstrap_jwt" || existingUser.passwordHash) {
updatePayload.openId = normalizedEmail;
}
await db.updateUser(existingUser.id, updatePayload);
await logAdminAction(ctx.user.id, 'creation', 'user', existingUser.id, {
email: normalizedEmail,
role: input.role,
canManageLogistics:
input.role === "super_admin"
|| input.role === "logistique_controle"
? true
: input.role === "admin"
? Boolean(input.canManageLogistics)
: false,
reusedExistingAccount: true,
});
return { success: true, id: existingUser.id, reusedExistingAccount: true };
}
const generatedPassword = randomBytes(24).toString("base64url");
const createdUser = await registerLocalUser({
name: input.name,
email: normalizedEmail,
password: input.password?.trim() || generatedPassword,
});
await db.updateUser(createdUser.id, {
name: input.name.trim(),
email: normalizedEmail,
role: input.role,
mfaEnabled: isInternalRole(input.role),
canManageLogistics:
input.role === "super_admin"
|| input.role === "logistique_controle"
? true
: input.role === "admin"
? Boolean(input.canManageLogistics)
: false,
delegatedSalleSignerUserId: null,
isActive: true,
loginMethod: 'local_jwt',
});
await logAdminAction(ctx.user.id, 'creation', 'user', createdUser.id, {
email: normalizedEmail,
role: input.role,
canManageLogistics:
input.role === "super_admin"
|| input.role === "logistique_controle"
? true
: input.role === "admin"
? Boolean(input.canManageLogistics)
: false,
reusedExistingAccount: false,
});
return { success: true, id: createdUser.id, reusedExistingAccount: false };
}),
update: superAdminProcedure
.input(z.object({
id: z.number(),
name: z.string().trim().min(2, "Le nom est obligatoire"),
email: z.string().trim().email("Adresse email invalide"),
role: z.enum(['user', 'accueil', 'service_terrain', 'logistique_controle', 'admin', 'directrice', 'super_admin']),
isActive: z.boolean(),
canManageLogistics: z.boolean(),
password: z.string().min(8, "Le mot de passe doit contenir au moins 8 caractères").optional(),
}))
.mutation(async ({ ctx, input }) => {
const existingUser = await db.getUserById(input.id);
if (!existingUser) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Utilisateur introuvable' });
}
if (input.id === ctx.user.id && (!input.isActive || input.role !== 'super_admin')) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Tu ne peux pas retirer tes propres droits super administrateur ni désactiver ton compte.',
});
}
if (existingUser.role === 'super_admin' && (input.role !== 'super_admin' || !input.isActive)) {
const adminUsers = await db.getAdminUsers();
const activeSuperAdmins = adminUsers.filter((user) => user.role === 'super_admin' && user.isActive);
if (activeSuperAdmins.length <= 1 && activeSuperAdmins.some((user) => user.id === input.id)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Le dernier super administrateur actif ne peut pas être rétrogradé ou désactivé.',
});
}
}
const normalizedEmail = input.email.trim().toLowerCase();
const existingByEmail = await db.getUserByEmail(normalizedEmail);
if (existingByEmail && existingByEmail.id !== input.id) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Cette adresse email est déjà utilisée par un autre compte.',
});
}
const updatePayload: Record<string, unknown> = {
name: input.name.trim(),
email: normalizedEmail,
role: input.role,
mfaEnabled: isInternalRole(input.role) ? true : existingUser.mfaEnabled,
canManageLogistics:
input.role === "super_admin"
|| input.role === "logistique_controle"
? true
: input.role === "admin"
? input.canManageLogistics
: false,
delegatedSalleSignerUserId: input.role === "directrice" ? existingUser.delegatedSalleSignerUserId ?? null : null,
isActive: input.isActive,
};
if (existingUser.loginMethod === 'local_jwt' || existingUser.loginMethod === 'bootstrap_jwt' || existingUser.passwordHash) {
updatePayload.openId = normalizedEmail;
}
if (input.password?.trim()) {
updatePayload.passwordHash = await hashLocalPassword(input.password.trim());
updatePayload.loginMethod = 'local_jwt';
}
await db.updateUser(input.id, updatePayload);
await logAdminAction(ctx.user.id, 'modification', 'user', input.id, {
email: normalizedEmail,
role: input.role,
canManageLogistics:
input.role === "super_admin"
|| input.role === "logistique_controle"
? true
: input.role === "admin"
? input.canManageLogistics
: false,
isActive: input.isActive,
passwordReset: Boolean(input.password?.trim()),
});
return { success: true };
}),
updateRole: superAdminProcedure
.input(z.object({
id: z.number(),
role: z.enum(['user', 'accueil', 'service_terrain', 'logistique_controle', 'admin', 'directrice', 'super_admin']),
}))
.mutation(async ({ ctx, input }) => {
if (input.id === ctx.user.id && input.role !== 'super_admin') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Tu ne peux pas retirer tes propres droits super administrateur.',
});
}
const existingUser = await db.getUserById(input.id);
if (!existingUser) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Utilisateur introuvable' });
}
if (existingUser.role === 'super_admin' && input.role !== 'super_admin') {
const adminUsers = await db.getAdminUsers();
const activeSuperAdmins = adminUsers.filter((user) => user.role === 'super_admin' && user.isActive);
if (activeSuperAdmins.length <= 1 && activeSuperAdmins.some((user) => user.id === input.id)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Le dernier super administrateur actif ne peut pas être rétrogradé.',
});
}
}
await db.updateUser(input.id, {
role: input.role,
mfaEnabled: isInternalRole(input.role) ? true : existingUser.mfaEnabled,
canManageLogistics:
input.role === "super_admin"
|| input.role === "logistique_controle"
? true
: input.role === "admin"
? existingUser.canManageLogistics
: false,
delegatedSalleSignerUserId: input.role === "directrice" ? existingUser.delegatedSalleSignerUserId ?? null : null,
});
await logAdminAction(ctx.user.id, 'changement_role', 'user', input.id, {
role: input.role,
canManageLogistics:
input.role === "super_admin"
|| input.role === "logistique_controle"
? true
: input.role === "admin"
? existingUser.canManageLogistics
: false,
});
return { success: true };
}),
toggleActive: superAdminProcedure
.input(z.object({
id: z.number(),
isActive: z.boolean(),
}))
.mutation(async ({ ctx, input }) => {
if (input.id === ctx.user.id && !input.isActive) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Tu ne peux pas désactiver ton propre compte.',
});
}
const existingUser = await db.getUserById(input.id);
if (!existingUser) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Utilisateur introuvable' });
}
if (existingUser.role === 'super_admin' && !input.isActive) {
const adminUsers = await db.getAdminUsers();
const activeSuperAdmins = adminUsers.filter((user) => user.role === 'super_admin' && user.isActive);
if (activeSuperAdmins.length <= 1 && activeSuperAdmins.some((user) => user.id === input.id)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Le dernier super administrateur actif ne peut pas être désactivé.',
});
}
}
await db.updateUser(input.id, { isActive: input.isActive });
await logAdminAction(ctx.user.id, input.isActive ? 'activation' : 'desactivation', 'user', input.id);
return { success: true };
}),
delete: superAdminProcedure
.input(z.object({
id: z.number(),
}))
.mutation(async ({ ctx, input }) => {
if (input.id === ctx.user.id) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Tu ne peux pas supprimer ton propre compte.',
});
}
const existingUser = await db.getUserById(input.id);
if (!existingUser) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Utilisateur introuvable' });
}
if (existingUser.role === 'super_admin' && existingUser.isActive) {
const adminUsers = await db.getAdminUsers();
const activeSuperAdmins = adminUsers.filter((user) => user.role === 'super_admin' && user.isActive);
if (activeSuperAdmins.length <= 1 && activeSuperAdmins.some((user) => user.id === input.id)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Le dernier super administrateur actif ne peut pas être supprimé.',
});
}
}
await db.deleteUser(input.id);
await logAdminAction(ctx.user.id, 'suppression', 'user', input.id, {
email: existingUser.email,
role: existingUser.role,
ipAddress: extractClientIp(ctx.req),
});
return { success: true };
}),
}),
// ============== AUDIT LOG ==============
auditLog: router({
list: adminProcedure
.input(z.object({
userId: z.number().optional(),
entityType: z.string().optional(),
entityId: z.number().optional(),
limit: z.number().optional(),
offset: z.number().optional(),
}))
.query(async ({ input }) => {
return db.getAuditLogs(input);
}),
}),
compliance: router({
getSnapshot: adminProcedure.query(async () => {
const [auditSummary, adminUsers, allUsers, operationalTourSettings, retentionReportRaw] = await Promise.all([
db.getAuditLogs({ limit: 1 }),
db.getAdminUsers(),
db.getAllUsers(),
getOperationalTourSettings(),
db.getPortalSetting("retention.lastReport"),
]);
const activeAdmins = adminUsers.filter((user) => user.isActive);
const internalUsers = allUsers.filter((user) => isInternalRole(user.role));
const activeInternalUsers = internalUsers.filter((user) => user.isActive);
const internalUsersWithTotp = activeInternalUsers.filter(
(user) => user.mfaMethod === "authenticator_app" && user.mfaTotpSecretEncrypted
);
const superAdminUsers = activeInternalUsers.filter((user) => user.role === "super_admin");
const superAdminUsersWithTotp = superAdminUsers.filter(
(user) => user.mfaMethod === "authenticator_app" && user.mfaTotpSecretEncrypted
);
const retentionReport = retentionReportRaw ? JSON.parse(retentionReportRaw) as {
generatedAt?: string;
purgedUsers?: number;
pendingDeletionCount?: number;
purgedTotalCount?: number;
} : null;
const secondaryTarget = operationalTourSettings.targets.secondary.baseUrl.trim();
const checks = {
technique: [
buildComplianceItem(
"audit-log",
"Journalisation des actions sensibles",
auditSummary.total > 0 ? "implemented" : "partial",
auditSummary.total > 0
? "Le portail dispose d'un journal d'audit exploitable dans l'administration."
: "Le mecanisme existe mais aucun evenement n'a encore ete confirme dans l'historique.",
`Entrees constatees: ${auditSummary.total}.`
),
buildComplianceItem(
"preprod",
"Separation production / preproduction",
secondaryTarget ? "implemented" : "partial",
secondaryTarget
? "Une cible secondaire est configuree pour les controles."
: "La production est pilotee mais la cible secondaire doit encore etre renseignee."
),
buildComplianceItem(
"password-hash",
"Hachage des mots de passe",
"partial",
"Le portail gere la migration de hachage et preferera Argon2id quand le runtime serveur le permet, avec repli securise sur scrypt pour l'environnement actuel.",
"Compatibilite legacy maintenue pour les anciens hachages scrypt."
),
buildComplianceItem(
"mfa",
"Authentification multifacteur",
superAdminUsers.length === superAdminUsersWithTotp.length && activeInternalUsers.length > 0
? "implemented"
: activeInternalUsers.length > 0
? "partial"
: "missing",
"Le MFA est impose pour les roles internes. Authenticator est la methode cible, avec repli email encore tolere pour certains roles en phase transitoire.",
`Comptes internes actifs: ${activeInternalUsers.length}. Authenticator actif: ${internalUsersWithTotp.length}. Super administrateurs alignes TOTP: ${superAdminUsersWithTotp.length}/${superAdminUsers.length}.`
),
buildComplianceItem(
"lockout",
"Blocage apres tentatives repeteees",
"implemented",
"Le portail verrouille temporairement un compte apres plusieurs echecs de connexion ou de code MFA."
),
buildComplianceItem(
"backup-governance",
"Sauvegardes et restauration",
"partial",
"La politique de sauvegarde, la restauration et les objectifs RPO/RTO sont documentes. Les revues hors application et les tests trimestriels restent a tracer regulierement.",
"Strategie cible: 3-2-1, RPO 1 heure, RTO 4 heures."
),
],
juridique: [
buildComplianceItem(
"privacy-pages",
"Pages legales de base publiees",
"implemented",
"La confidentialite, les mentions legales, les cookies et les CGU sont maintenant exposes dans le portail."
),
buildComplianceItem(
"consent",
"Recueil de consentement explicite",
"implemented",
"Le portail journalise une validation explicite de confidentialite sur plusieurs parcours critiques."
),
buildComplianceItem(
"retention",
"Politique de conservation formalisee",
"partial",
"Une politique de retention et une procedure de purge planifiee sont maintenant documentees. Le moteur de purge produit un rapport automatique minimal, mais la couverture complete par categorie reste a etendre."
,
retentionReport
? `Dernier rapport retention: ${retentionReport.generatedAt || "n/a"} • purges du run: ${retentionReport.purgedUsers ?? 0} • comptes en attente: ${retentionReport.pendingDeletionCount ?? 0}.`
: "Aucun rapport automatique de retention n'a encore ete genere."
),
buildComplianceItem(
"user-rights",
"Export et suppression en libre-service",
"implemented",
"L'espace utilisateur permet l'export JSON des donnees et une suppression planifiee avec purge differée a 30 jours."
),
],
organisation: [
buildComplianceItem(
"roles",
"Gestion des roles internes",
activeAdmins.length > 0 ? "implemented" : "partial",
"Le portail segmente deja les acces par roles et habilitations internes.",
`Comptes internes actifs observes: ${activeAdmins.length}.`
),
buildComplianceItem(
"incident-procedure",
"Procedure de gestion des incidents",
"partial",
"La procedure incidents est documentee avec classification, confinement, investigation, violation RGPD et revue post-incident. Elle doit encore etre adoptee et testee en exploitation."
),
buildComplianceItem(
"register-processing",
"Registre des traitements",
"partial",
"Le portail dispose d'un socle de registre des traitements. Les traitements réels, responsables, durées, bases légales et justificatifs doivent encore être complétés et validés par la CCDS."
),
buildComplianceItem(
"supplier-register",
"Registre des sous-traitants",
"partial",
"Le registre fournisseurs, le modele DPA et la politique de revue annuelle sont documentes. Les fournisseurs reels et justificatifs contractuels doivent encore etre completes."
),
buildComplianceItem(
"mfa-policy",
"Politique MFA obligatoire par role",
"implemented",
"Une politique MFA interne explicite distingue les roles soumis a MFA obligatoire, la methode cible Authenticator et les phases de transition.",
"Super administrateur: Authenticator uniquement cible. Autres roles internes: MFA obligatoire avec bascule progressive vers TOTP."
),
],
};
const allChecks = [...checks.technique, ...checks.juridique, ...checks.organisation];
const implementedCount = allChecks.filter((item) => item.status === "implemented").length;
const partialCount = allChecks.filter((item) => item.status === "partial").length;
const missingCount = allChecks.filter((item) => item.status === "missing").length;
return {
generatedAt: new Date().toISOString(),
platform: {
totalUsers: allUsers.length,
activeInternalUsers: activeAdmins.length,
auditEntries: auditSummary.total,
secondaryConfigured: Boolean(secondaryTarget),
},
mfaPolicy: {
targetMethod: "authenticator_app",
fallbackMethod: "email_otp",
internalRoles: activeInternalUsers.map((user) => user.role),
totpCoverage: {
activeInternalUsers: activeInternalUsers.length,
authenticatorEnabled: internalUsersWithTotp.length,
superAdmins: superAdminUsers.length,
superAdminsAligned: superAdminUsersWithTotp.length,
},
phases: [
"Phase 1 : MFA obligatoire pour tous les roles internes, Authenticator cible, email encore tolere hors super_admin.",
"Phase 2 : Authenticator obligatoire pour super_admin, admin et directrice.",
"Phase 3 : Authenticator obligatoire pour tous les roles internes.",
],
},
summary: {
implementedCount,
partialCount,
missingCount,
readinessLabel:
missingCount === 0
? "Socle avance mais encore a faire auditer"
: missingCount <= 3
? "Socle partiellement aligne"
: "Programme de mise en conformite a poursuivre",
},
checks,
nextSteps: [
"Initialiser Authenticator sur les comptes super_admin, admin et directrice avant enforcement complet en phase 2.",
"Renseigner les fournisseurs reels, signer les DPA et consigner la revue annuelle de chaque sous-traitant.",
"Etendre la retention automatisee aux autres categories documentaires et produire un rapport mensuel DPO.",
"Industrialiser les tests de restauration, les revues de sauvegarde et la revue post-incident hors application.",
],
disclaimer: "Cette vue aide au pilotage interne. Elle ne remplace ni un audit juridique RGPD ni une certification ISO 27001.",
};
}),
getOperations: adminProcedure.query(async () => {
const [operations, allUsers] = await Promise.all([
getComplianceOperations(),
db.getAllUsers(),
]);
const legalHoldCount = allUsers.filter((user) => user.legalHold).length;
const pendingPurgeCount = allUsers.filter((user) => user.deletionRequestedAt && !user.purgedAt).length;
const purgedCount = allUsers.filter((user) => user.purgedAt).length;
const sortedBackupRecords = [...operations.backupRecords]
.filter((entry) => Boolean(entry.lastSuccessAt))
.sort((a, b) => (b.lastSuccessAt || "").localeCompare(a.lastSuccessAt || "", "fr"));
const sortedRestoreTests = operations.restoreTests
.filter((entry) => entry.testedAt)
.sort((a, b) => (b.testedAt || "").localeCompare(a.testedAt || "", "fr"));
const latestBackupSuccess = sortedBackupRecords[0] || null;
const latestBackupFailure = [...operations.backupRecords]
.filter((entry) => entry.lastFailureAt)
.sort((a, b) => (b.lastFailureAt || "").localeCompare(a.lastFailureAt || "", "fr"))[0] || null;
const latestRestoreTest = sortedRestoreTests[0] || null;
const latestSuccessfulRestore = sortedRestoreTests.find((entry) => entry.result === "success") || null;
const latestBackupStatus = formatRecencyStatus(parseComplianceDate(latestBackupSuccess?.lastSuccessAt || null), 7);
const latestRestoreStatus = formatRecencyStatus(parseComplianceDate(latestRestoreTest?.testedAt || null), 90);
const proofCoverageCount = operations.backupRecords.filter((entry) => Boolean(entry.proofLabel || entry.proofUrl)).length;
const restoreProofCoverageCount = operations.restoreTests.filter((entry) => Boolean(entry.proofLabel)).length;
const confidenceStatus =
latestBackupStatus.status === "missing" || latestRestoreStatus.status === "missing"
? "partial"
: latestBackupStatus.status === "warning" || latestRestoreStatus.status === "warning"
? "warning"
: "ok";
return {
...operations,
metrics: {
legalHoldCount,
pendingPurgeCount,
purgedCount,
},
exploitation: {
latestBackupSuccess,
latestBackupFailure,
latestRestoreTest,
latestSuccessfulRestore,
latestBackupStatus,
latestRestoreStatus,
proofCoverageCount,
restoreProofCoverageCount,
confidenceStatus,
alerts: [
operations.backupRecords.length === 0
? "Aucune preuve de sauvegarde nest encore historisée dans le portail."
: null,
operations.restoreTests.length === 0
? "Aucun test de restauration nest encore historisé dans le pilotage."
: null,
latestBackupStatus.status === "warning" && latestBackupStatus.ageDays !== null
? `La dernière sauvegarde réussie visible date de ${latestBackupStatus.ageDays} jour(s).`
: null,
latestRestoreStatus.status === "warning" && latestRestoreStatus.ageDays !== null
? `Le dernier test de restauration visible date de ${latestRestoreStatus.ageDays} jour(s).`
: null,
].filter((value): value is string => Boolean(value)),
},
};
}),
bootstrapOperations: adminProcedure.mutation(async ({ ctx }) => {
const existing = await getComplianceOperations();
const bootstrap = await buildComplianceBootstrapPayload(existing);
const mergedSuppliers = mergeComplianceEntriesById(existing.suppliers, bootstrap.suppliers);
const mergedDpas = mergeComplianceEntriesById(existing.dpas, bootstrap.dpas);
const mergedEvidence = mergeComplianceEntriesById(existing.evidenceCenter, bootstrap.evidenceCenter);
await Promise.all([
db.setPortalSetting(
COMPLIANCE_SUPPLIERS_SETTING_KEY,
JSON.stringify(mergedSuppliers.entries.sort((a, b) => a.supplierName.localeCompare(b.supplierName, "fr"))),
"Registre fournisseurs et sous-traitants"
),
db.setPortalSetting(
COMPLIANCE_DPA_SETTING_KEY,
JSON.stringify(mergedDpas.entries.sort((a, b) => a.supplierName.localeCompare(b.supplierName, "fr"))),
"Suivi des DPA et obligations sous-traitants"
),
db.setPortalSetting(
COMPLIANCE_EVIDENCE_SETTING_KEY,
JSON.stringify(mergedEvidence.entries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt, "fr"))),
"Centre de conformité et preuves d'audit"
),
]);
const summary: ComplianceBootstrapSummary = {
suppliersAdded: mergedSuppliers.addedCount,
dpasAdded: mergedDpas.addedCount,
evidenceAdded: mergedEvidence.addedCount,
notes: bootstrap.notes,
};
await logAdminAction(ctx.user.id, "modification", "compliance_bootstrap", undefined, {
...summary,
ipAddress: extractClientIp(ctx.req),
});
return summary;
}),
upsertSupplier: adminProcedure
.input(complianceSupplierSchema.omit({ id: true }).extend({ id: z.string().trim().optional() }))
.mutation(async ({ ctx, input }) => {
const operations = await getComplianceOperations();
const nextEntry = complianceSupplierSchema.parse({
...input,
id: input.id?.trim() || nanoid(10),
});
const nextEntries = [
nextEntry,
...operations.suppliers.filter((entry) => entry.id !== nextEntry.id),
].sort((a, b) => a.supplierName.localeCompare(b.supplierName, "fr"));
await db.setPortalSetting(
COMPLIANCE_SUPPLIERS_SETTING_KEY,
JSON.stringify(nextEntries),
"Registre fournisseurs et sous-traitants"
);
await logAdminAction(ctx.user.id, "modification", "compliance_supplier", undefined, {
supplierId: nextEntry.id,
supplierName: nextEntry.supplierName,
ipAddress: extractClientIp(ctx.req),
});
return nextEntry;
}),
deleteSupplier: adminProcedure
.input(z.object({ id: z.string().trim().min(1) }))
.mutation(async ({ ctx, input }) => {
const operations = await getComplianceOperations();
await db.setPortalSetting(
COMPLIANCE_SUPPLIERS_SETTING_KEY,
JSON.stringify(operations.suppliers.filter((entry) => entry.id !== input.id)),
"Registre fournisseurs et sous-traitants"
);
await logAdminAction(ctx.user.id, "suppression", "compliance_supplier", undefined, {
supplierId: input.id,
ipAddress: extractClientIp(ctx.req),
});
return { success: true };
}),
upsertDpa: adminProcedure
.input(complianceDpaSchema.omit({ id: true }).extend({ id: z.string().trim().optional() }))
.mutation(async ({ ctx, input }) => {
const operations = await getComplianceOperations();
const nextEntry = complianceDpaSchema.parse({
...input,
id: input.id?.trim() || nanoid(10),
});
const nextEntries = [
nextEntry,
...operations.dpas.filter((entry) => entry.id !== nextEntry.id),
].sort((a, b) => a.supplierName.localeCompare(b.supplierName, "fr"));
await db.setPortalSetting(
COMPLIANCE_DPA_SETTING_KEY,
JSON.stringify(nextEntries),
"Suivi des DPA et obligations sous-traitants"
);
await logAdminAction(ctx.user.id, "modification", "compliance_dpa", undefined, {
dpaId: nextEntry.id,
supplierName: nextEntry.supplierName,
status: nextEntry.status,
ipAddress: extractClientIp(ctx.req),
});
return nextEntry;
}),
deleteDpa: adminProcedure
.input(z.object({ id: z.string().trim().min(1) }))
.mutation(async ({ ctx, input }) => {
const operations = await getComplianceOperations();
await db.setPortalSetting(
COMPLIANCE_DPA_SETTING_KEY,
JSON.stringify(operations.dpas.filter((entry) => entry.id !== input.id)),
"Suivi des DPA et obligations sous-traitants"
);
await logAdminAction(ctx.user.id, "suppression", "compliance_dpa", undefined, {
dpaId: input.id,
ipAddress: extractClientIp(ctx.req),
});
return { success: true };
}),
upsertBackupRecord: adminProcedure
.input(complianceBackupRecordSchema.omit({ id: true }).extend({ id: z.string().trim().optional() }))
.mutation(async ({ ctx, input }) => {
const operations = await getComplianceOperations();
const nextEntry = complianceBackupRecordSchema.parse({
...input,
id: input.id?.trim() || nanoid(10),
});
const backupRecords = [
nextEntry,
...operations.backupRecords.filter((entry) => entry.id !== nextEntry.id),
].sort((a, b) => a.scope.localeCompare(b.scope, "fr"));
await saveComplianceBackups({
backupRecords,
restoreTests: operations.restoreTests,
});
await logAdminAction(ctx.user.id, "modification", "compliance_backup", undefined, {
backupId: nextEntry.id,
scope: nextEntry.scope,
ipAddress: extractClientIp(ctx.req),
});
return nextEntry;
}),
deleteBackupRecord: adminProcedure
.input(z.object({ id: z.string().trim().min(1) }))
.mutation(async ({ ctx, input }) => {
const operations = await getComplianceOperations();
await saveComplianceBackups({
backupRecords: operations.backupRecords.filter((entry) => entry.id !== input.id),
restoreTests: operations.restoreTests,
});
await logAdminAction(ctx.user.id, "suppression", "compliance_backup", undefined, {
backupId: input.id,
ipAddress: extractClientIp(ctx.req),
});
return { success: true };
}),
upsertRestoreTest: adminProcedure
.input(complianceRestoreTestSchema.omit({ id: true }).extend({ id: z.string().trim().optional() }))
.mutation(async ({ ctx, input }) => {
const operations = await getComplianceOperations();
const nextEntry = complianceRestoreTestSchema.parse({
...input,
id: input.id?.trim() || nanoid(10),
});
const restoreTests = [
nextEntry,
...operations.restoreTests.filter((entry) => entry.id !== nextEntry.id),
].sort((a, b) => (b.testedAt || "").localeCompare(a.testedAt || "", "fr"));
await saveComplianceBackups({
backupRecords: operations.backupRecords,
restoreTests,
});
await logAdminAction(ctx.user.id, "modification", "compliance_restore_test", undefined, {
restoreTestId: nextEntry.id,
result: nextEntry.result,
ipAddress: extractClientIp(ctx.req),
});
return nextEntry;
}),
deleteRestoreTest: adminProcedure
.input(z.object({ id: z.string().trim().min(1) }))
.mutation(async ({ ctx, input }) => {
const operations = await getComplianceOperations();
await saveComplianceBackups({
backupRecords: operations.backupRecords,
restoreTests: operations.restoreTests.filter((entry) => entry.id !== input.id),
});
await logAdminAction(ctx.user.id, "suppression", "compliance_restore_test", undefined, {
restoreTestId: input.id,
ipAddress: extractClientIp(ctx.req),
});
return { success: true };
}),
upsertRetentionReport: adminProcedure
.input(complianceRetentionReportSchema.omit({ id: true }).extend({ id: z.string().trim().optional() }))
.mutation(async ({ ctx, input }) => {
const operations = await getComplianceOperations();
const nextEntry = complianceRetentionReportSchema.parse({
...input,
id: input.id?.trim() || nanoid(10),
});
const nextEntries = [
nextEntry,
...operations.retentionReports.filter((entry) => entry.id !== nextEntry.id && entry.generatedAt !== nextEntry.generatedAt),
].sort((a, b) => (b.generatedAt || "").localeCompare(a.generatedAt || "", "fr"));
await db.setPortalSetting(
COMPLIANCE_RETENTION_HISTORY_SETTING_KEY,
JSON.stringify(nextEntries),
"Historique des rapports de retention et purge"
);
await logAdminAction(ctx.user.id, "modification", "compliance_retention_report", undefined, {
reportId: nextEntry.id,
generatedAt: nextEntry.generatedAt,
ipAddress: extractClientIp(ctx.req),
});
return nextEntry;
}),
deleteRetentionReport: adminProcedure
.input(z.object({ id: z.string().trim().min(1) }))
.mutation(async ({ ctx, input }) => {
const operations = await getComplianceOperations();
await db.setPortalSetting(
COMPLIANCE_RETENTION_HISTORY_SETTING_KEY,
JSON.stringify(operations.retentionReports.filter((entry) => entry.id !== input.id)),
"Historique des rapports de retention et purge"
);
await logAdminAction(ctx.user.id, "suppression", "compliance_retention_report", undefined, {
reportId: input.id,
ipAddress: extractClientIp(ctx.req),
});
return { success: true };
}),
upsertEvidence: adminProcedure
.input(complianceEvidenceSchema.omit({ id: true }).extend({ id: z.string().trim().optional() }))
.mutation(async ({ ctx, input }) => {
const operations = await getComplianceOperations();
const nextEntry = complianceEvidenceSchema.parse({
...input,
id: input.id?.trim() || nanoid(10),
});
const nextEntries = [
nextEntry,
...operations.evidenceCenter.filter((entry) => entry.id !== nextEntry.id),
].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt, "fr"));
await db.setPortalSetting(
COMPLIANCE_EVIDENCE_SETTING_KEY,
JSON.stringify(nextEntries),
"Centre de conformité et preuves d'audit"
);
await logAdminAction(ctx.user.id, "modification", "compliance_evidence", undefined, {
evidenceId: nextEntry.id,
category: nextEntry.category,
title: nextEntry.title,
ipAddress: extractClientIp(ctx.req),
});
return nextEntry;
}),
deleteEvidence: adminProcedure
.input(z.object({ id: z.string().trim().min(1) }))
.mutation(async ({ ctx, input }) => {
const operations = await getComplianceOperations();
await db.setPortalSetting(
COMPLIANCE_EVIDENCE_SETTING_KEY,
JSON.stringify(operations.evidenceCenter.filter((entry) => entry.id !== input.id)),
"Centre de conformité et preuves d'audit"
);
await logAdminAction(ctx.user.id, "suppression", "compliance_evidence", undefined, {
evidenceId: input.id,
ipAddress: extractClientIp(ctx.req),
});
return { success: true };
}),
upsertIsoDeliverable: adminProcedure
.input(complianceIsoDeliverableSchema.omit({ id: true }).extend({ id: z.string().trim().optional() }))
.mutation(async ({ ctx, input }) => {
const operations = await getComplianceOperations();
const nextEntry = complianceIsoDeliverableSchema.parse({
...input,
id: input.id?.trim() || nanoid(10),
});
const nextEntries = [
nextEntry,
...operations.isoDeliverables.filter((entry) => entry.id !== nextEntry.id),
].sort((a, b) => a.reference.localeCompare(b.reference, "fr"));
await db.setPortalSetting(
COMPLIANCE_ISO_DELIVERABLES_SETTING_KEY,
JSON.stringify(nextEntries),
"Pilotage des livrables ISO 27001 et validation interne"
);
await logAdminAction(ctx.user.id, "modification", "compliance_iso_deliverable", undefined, {
deliverableId: nextEntry.id,
reference: nextEntry.reference,
status: nextEntry.status,
ipAddress: extractClientIp(ctx.req),
});
return nextEntry;
}),
setIsoDeliverableStatus: adminProcedure
.input(z.object({
id: z.string().trim().min(1),
status: z.enum(["a_rediger", "en_cours", "a_relire", "a_valider", "valide", "a_reviser"]),
approverName: z.string().trim().max(160).optional().or(z.literal("")),
approverEmail: z.string().trim().max(320).optional().or(z.literal("")),
note: z.string().trim().max(1000).optional().or(z.literal("")),
}))
.mutation(async ({ ctx, input }) => {
const operations = await getComplianceOperations();
const existing = operations.isoDeliverables.find((entry) => entry.id === input.id);
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Livrable ISO introuvable" });
}
const now = new Date().toISOString();
const nextEntry = complianceIsoDeliverableSchema.parse({
...existing,
status: input.status,
approverName: input.approverName || existing.approverName,
approverEmail: input.approverEmail || existing.approverEmail,
lastReviewedAt: now,
validatedAt: input.status === "valide" ? now : input.status === "a_reviser" ? "" : existing.validatedAt,
notes: [existing.notes, input.note].filter(Boolean).join(existing.notes && input.note ? "\n\n" : ""),
});
const nextEntries = [
nextEntry,
...operations.isoDeliverables.filter((entry) => entry.id !== nextEntry.id),
].sort((a, b) => a.reference.localeCompare(b.reference, "fr"));
await db.setPortalSetting(
COMPLIANCE_ISO_DELIVERABLES_SETTING_KEY,
JSON.stringify(nextEntries),
"Pilotage des livrables ISO 27001 et validation interne"
);
await logAdminAction(ctx.user.id, "modification", "compliance_iso_deliverable_status", undefined, {
deliverableId: nextEntry.id,
reference: nextEntry.reference,
status: nextEntry.status,
ipAddress: extractClientIp(ctx.req),
});
return nextEntry;
}),
}),
// ============== NOTIFICATIONS ==============
notifications: router({
getAll: accueilAdminProcedure
.input(z.object({ unreadOnly: z.boolean().optional() }))
.query(async ({ ctx, input }) => {
return db.getAdminNotifications(ctx.user.id, input.unreadOnly);
}),
getUnreadCount: accueilAdminProcedure.query(async ({ ctx }) => {
return db.getUnreadNotificationCount(ctx.user.id);
}),
markAsRead: accueilAdminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
await db.markNotificationAsRead(input.id);
return { success: true };
}),
markAllAsRead: accueilAdminProcedure.mutation(async ({ ctx }) => {
await db.markAllNotificationsAsRead(ctx.user.id);
return { success: true };
}),
}),
internalDirectory: router({
get: salleReadProcedure.query(async () => {
const storedValue = await db.getPortalSetting(INTERNAL_DIRECTORY_SETTING_KEY);
if (!storedValue) {
return null;
}
try {
return z.array(internalDirectoryDivisionSchema).parse(JSON.parse(storedValue));
} catch (error) {
console.error("Failed to parse internal directory setting:", error);
return null;
}
}),
set: superAdminProcedure
.input(z.object({
divisions: z.array(internalDirectoryDivisionSchema).min(1).max(50),
}))
.mutation(async ({ ctx, input }) => {
await db.setPortalSetting(
INTERNAL_DIRECTORY_SETTING_KEY,
JSON.stringify(input.divisions),
"Annuaire interne CCDS editable depuis l'administration"
);
await logAdminAction(ctx.user.id, "modification", "setting", undefined, {
key: INTERNAL_DIRECTORY_SETTING_KEY,
divisionCount: input.divisions.length,
ipAddress: extractClientIp(ctx.req),
});
return { success: true };
}),
}),
// ============== PORTAL SETTINGS ==============
settings: router({
getAppearance: publicProcedure.query(async () => {
const storedValue = await db.getPortalSetting(APPEARANCE_SETTING_KEY);
if (!storedValue) {
return defaultPortalAppearance;
}
try {
return sanitizePortalAppearance(JSON.parse(storedValue));
} catch {
return defaultPortalAppearance;
}
}),
getMail: adminProcedure.query(async () => {
return getAdminMailSettings();
}),
getAssociationMap: publicProcedure.query(async () => {
return getAssociationMapSettings();
}),
getUserOperatingTour: publicProcedure.query(async () => {
return getUserOperatingTourSettings();
}),
getLogistics: adminProcedure.query(async ({ ctx }) => {
assertLogisticsAccess(ctx.user);
return getLogisticsSettings();
}),
getLogisticsGroup: adminProcedure.query(async () => {
return getLogisticsGroupSettings();
}),
getSalleBilling: adminProcedure.query(async () => {
return getSalleBillingSettings();
}),
getHelloAsso: adminProcedure.query(async () => {
const settings = await getHelloAssoSettings();
return serializeHelloAssoSettingsPublic(settings);
}),
getAll: adminProcedure.query(async () => {
return db.getAllPortalSettings();
}),
get: adminProcedure
.input(z.object({ key: z.string() }))
.query(async ({ input }) => {
return db.getPortalSetting(input.key);
}),
set: superAdminProcedure
.input(z.object({
key: z.string(),
value: z.string(),
description: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
await db.setPortalSetting(input.key, input.value, input.description);
await logAdminAction(ctx.user.id, 'modification', 'setting', undefined, { key: input.key });
return { success: true };
}),
setAssociationMap: superAdminProcedure
.input(z.object({
publicStyleUrl: z.string().trim().url(),
adminStyleUrl: z.string().trim().url(),
}))
.mutation(async ({ ctx, input }) => {
const settings = sanitizeAssociationMapSettings(input);
await db.setPortalSetting(
ASSOCIATION_MAP_SETTING_KEY,
JSON.stringify(settings),
"Affichage cartographique MapLibre / tuiles vectorielles de l'annuaire public"
);
await logAdminAction(ctx.user.id, "modification", "setting", undefined, {
key: ASSOCIATION_MAP_SETTING_KEY,
publicStyleUrl: settings.publicStyleUrl,
adminStyleUrl: settings.adminStyleUrl,
ipAddress: extractClientIp(ctx.req),
});
return settings;
}),
setUserOperatingTour: superAdminProcedure
.input(userOperatingTourSettingsSchema)
.mutation(async ({ ctx, input }) => {
const settings = userOperatingTourSettingsSchema.parse(input);
await db.setPortalSetting(
USER_OPERATING_TOUR_SETTING_KEY,
JSON.stringify(settings),
"Activation globale du tour guide utilisateur"
);
await logAdminAction(ctx.user.id, "modification", "setting", undefined, {
key: USER_OPERATING_TOUR_SETTING_KEY,
enabled: settings.enabled,
ipAddress: extractClientIp(ctx.req),
});
return settings;
}),
setHelloAsso: superAdminProcedure
.input(z.object({
enabled: z.boolean(),
clientId: z.string().trim().min(1).max(200),
clientSecret: z.string().trim().max(400).optional().default(""),
}))
.mutation(async ({ ctx, input }) => {
const saved = await saveHelloAssoSettings(input);
await logAdminAction(ctx.user.id, "modification", "setting", undefined, {
key: HELLOASSO_SETTINGS_KEY,
enabled: saved.enabled,
clientId: saved.clientId,
clientSecretUpdated: Boolean(input.clientSecret?.trim()),
ipAddress: extractClientIp(ctx.req),
});
return serializeHelloAssoSettingsPublic(saved);
}),
setMail: superAdminProcedure
.input(z.object({
smtpProvider: z.enum(supportedMailProviders),
smtpHost: z.string().optional(),
smtpPort: z.string().optional(),
smtpUser: z.string().optional(),
smtpFrom: z.string().email(),
smtpSecure: z.boolean().optional(),
smtpRequireTls: z.boolean().optional(),
smtpPass: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const saved = await saveAdminMailSettings(
{
smtpProvider: input.smtpProvider,
smtpHost: input.smtpHost,
smtpPort: input.smtpPort,
smtpUser: input.smtpUser,
smtpFrom: input.smtpFrom,
smtpSecure: input.smtpSecure,
smtpRequireTls: input.smtpRequireTls,
smtpPass: input.smtpPass,
},
{ id: ctx.user.id, name: ctx.user.name }
);
await logAdminAction(ctx.user.id, "modification", "setting", undefined, {
key: "system.mail.settings",
smtpProvider: input.smtpProvider,
smtpHost: input.smtpHost,
smtpPort: input.smtpPort,
smtpFrom: input.smtpFrom,
smtpUser: input.smtpUser,
smtpSecure: input.smtpSecure,
smtpRequireTls: input.smtpRequireTls,
passwordUpdated: Boolean(input.smtpPass?.trim()),
ipAddress: extractClientIp(ctx.req),
});
return saved;
}),
setLogistics: adminProcedure
.input(z.object({
materialReturnLeadDays: z.number().int().min(1).max(30),
materialReturnGraceDays: z.number().int().min(0).max(30),
inventory: z.record(z.string(), z.number().int().min(0).nullable()).optional().default({}),
replacementValues: z.record(z.string(), z.number().int().min(0).nullable()).optional().default({}),
}))
.mutation(async ({ ctx, input }) => {
assertLogisticsAccess(ctx.user);
const saved = await saveLogisticsSettings({
materialReturnLeadDays: input.materialReturnLeadDays,
materialReturnGraceDays: input.materialReturnGraceDays,
inventory: input.inventory as Partial<Record<MaterialEventItemKey, number | null>>,
replacementValues: input.replacementValues as Partial<Record<MaterialEventItemKey, number | null>>,
});
await logAdminAction(ctx.user.id, "modification", "setting", undefined, {
key: "system.logistics.settings",
materialReturnLeadDays: saved.materialReturnLeadDays,
materialReturnGraceDays: saved.materialReturnGraceDays,
inventory: saved.inventory,
replacementValues: saved.replacementValues,
ipAddress: extractClientIp(ctx.req),
});
return saved;
}),
setLogisticsGroup: superAdminProcedure
.input(z.object({
label: z.string().trim().min(1).max(120),
memberUserIds: z.array(z.number().int().positive()),
}))
.mutation(async ({ ctx, input }) => {
const saved = await saveLogisticsGroupSettings(input);
await logAdminAction(ctx.user.id, "modification", "setting", undefined, {
key: "system.logistics.group",
label: saved.label,
memberUserIds: saved.memberUserIds,
ipAddress: extractClientIp(ctx.req),
});
return saved;
}),
sendTestMail: superAdminProcedure
.input(z.object({
to: z.string().email(),
}))
.mutation(async ({ ctx, input }) => {
const result = await sendOperationalEmail({
to: [input.to],
subject: "Test de configuration mail - Portail Associations",
text: [
"Bonjour,",
"",
"Ceci est un email de test envoyé depuis lespace dadministration du Portail Associations.",
`Configuration testée par : ${ctx.user.name || ctx.user.email || `Admin #${ctx.user.id}`}`,
`Date : ${new Date().toLocaleString("fr-FR")}`,
"",
"Si tu reçois ce message, la configuration SMTP du portail fonctionne.",
].join("\n"),
replyTo: ctx.user.email || undefined,
fromName: ctx.user.name ? `${ctx.user.name} via Portail Associations` : "Portail Associations",
});
if (!result.sent) {
throw new TRPCError({
code: "BAD_REQUEST",
message: result.reason || "Impossible denvoyer lemail de test",
});
}
await logAdminAction(ctx.user.id, "test", "setting", undefined, {
key: "system.mail.settings",
to: input.to,
ipAddress: extractClientIp(ctx.req),
});
return { success: true };
}),
setAppearance: superAdminProcedure
.input(appearanceSettingsSchema)
.mutation(async ({ ctx, input }) => {
const appearance = sanitizePortalAppearance(input);
await db.setPortalSetting(
APPEARANCE_SETTING_KEY,
JSON.stringify(appearance),
"Configuration visuelle du portail"
);
await logAdminAction(ctx.user.id, "modification", "setting", undefined, {
key: APPEARANCE_SETTING_KEY,
ipAddress: extractClientIp(ctx.req),
});
return appearance;
}),
uploadAppearanceAsset: superAdminProcedure
.input(z.object({
field: z.enum(appearanceAssetFields),
fileData: z.string(),
fileName: z.string(),
mimeType: z.string(),
}))
.mutation(async ({ ctx, input }) => {
const normalizedMimeType = input.mimeType.toLowerCase();
const isImage = normalizedMimeType.startsWith("image/");
const isIco = normalizedMimeType === "image/x-icon" || normalizedMimeType === "image/vnd.microsoft.icon";
if (!isImage && !isIco) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Le fichier doit être une image compatible" });
}
const fileBuffer = Buffer.from(input.fileData, "base64");
const storageName = `portal/appearance/${input.field}/${nanoid()}-${input.fileName}`;
const { url } = await storagePut(storageName, fileBuffer, input.mimeType);
const storedValue = await db.getPortalSetting(APPEARANCE_SETTING_KEY);
let currentAppearance = defaultPortalAppearance;
if (storedValue) {
try {
currentAppearance = sanitizePortalAppearance(JSON.parse(storedValue));
} catch {
currentAppearance = defaultPortalAppearance;
}
}
const nextAppearance = sanitizePortalAppearance({
...currentAppearance,
[input.field]: url,
...(input.field === "backgroundImageUrl" ? { backgroundEnabled: true } : {}),
});
await db.setPortalSetting(
APPEARANCE_SETTING_KEY,
JSON.stringify(nextAppearance),
"Configuration visuelle du portail"
);
await logAdminAction(ctx.user.id, "modification", "setting", undefined, {
key: APPEARANCE_SETTING_KEY,
field: input.field,
ipAddress: extractClientIp(ctx.req),
});
return nextAppearance;
}),
uploadAppearanceTargetAsset: superAdminProcedure
.input(z.object({
target: z.enum(appearanceBackgroundTargets),
fileData: z.string(),
fileName: z.string(),
mimeType: z.string(),
}))
.mutation(async ({ ctx, input }) => {
if (!input.mimeType.startsWith("image/")) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Le fichier doit etre une image" });
}
const fileBuffer = Buffer.from(input.fileData, "base64");
const storageName = `portal/appearance/background-targets/${input.target}/${nanoid()}-${input.fileName}`;
const { url } = await storagePut(storageName, fileBuffer, input.mimeType);
const storedValue = await db.getPortalSetting(APPEARANCE_SETTING_KEY);
let currentAppearance = defaultPortalAppearance;
if (storedValue) {
try {
currentAppearance = sanitizePortalAppearance(JSON.parse(storedValue));
} catch {
currentAppearance = defaultPortalAppearance;
}
}
const nextAppearance = sanitizePortalAppearance({
...currentAppearance,
backgroundEnabled: true,
backgroundTargets: Array.from(new Set([...currentAppearance.backgroundTargets, input.target])),
backgroundTargetImageUrls: {
...(currentAppearance.backgroundTargetImageUrls || {}),
[input.target]: url,
},
});
await db.setPortalSetting(
APPEARANCE_SETTING_KEY,
JSON.stringify(nextAppearance),
"Configuration visuelle du portail"
);
await logAdminAction(ctx.user.id, "modification", "setting", undefined, {
key: APPEARANCE_SETTING_KEY,
field: `backgroundTargetImageUrls.${input.target}`,
ipAddress: extractClientIp(ctx.req),
});
return nextAppearance;
}),
uploadAppearanceDecorationAsset: superAdminProcedure
.input(z.object({
decorationId: z.string().trim().min(1).max(80),
fileData: z.string(),
fileName: z.string(),
mimeType: z.string(),
decoration: z.object({
id: z.string().trim().min(1).max(80),
imageUrl: z.string().optional(),
scope: z.enum(appearanceDecorationScopes),
layer: z.enum(appearanceDecorationLayers),
widthPercent: z.number().min(6).max(38),
topPercent: z.number().min(0).max(100),
leftPercent: z.number().min(0).max(100),
opacity: z.number().min(4).max(28),
blurRadius: z.number().min(0).max(12),
cardAnchor: z.enum(appearanceCardDecorationAnchors),
cardInsetPercent: z.number().min(0).max(20),
}).optional(),
}))
.mutation(async ({ ctx, input }) => {
if (!input.mimeType.startsWith("image/")) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Le fichier doit être une image" });
}
const fileBuffer = Buffer.from(input.fileData, "base64");
const storageName = `portal/appearance/decorations/${input.decorationId}/${nanoid()}-${input.fileName}`;
const { url } = await storagePut(storageName, fileBuffer, input.mimeType);
const storedValue = await db.getPortalSetting(APPEARANCE_SETTING_KEY);
let currentAppearance = defaultPortalAppearance;
if (storedValue) {
try {
currentAppearance = sanitizePortalAppearance(JSON.parse(storedValue));
} catch {
currentAppearance = defaultPortalAppearance;
}
}
const currentDecorativeElements = currentAppearance.decorativeElements || [];
const hasExistingDecoration = currentDecorativeElements.some((entry) => entry.id === input.decorationId);
const nextDecorativeElements = hasExistingDecoration
? currentDecorativeElements.map((entry) =>
entry.id === input.decorationId ? { ...entry, imageUrl: url } : entry
)
: input.decoration
? [
...currentDecorativeElements,
{
...input.decoration,
imageUrl: url,
},
]
: currentDecorativeElements;
const nextAppearance = sanitizePortalAppearance({
...currentAppearance,
decorativeElements: nextDecorativeElements,
});
await db.setPortalSetting(
APPEARANCE_SETTING_KEY,
JSON.stringify(nextAppearance),
"Configuration visuelle du portail"
);
await logAdminAction(ctx.user.id, "modification", "setting", undefined, {
key: APPEARANCE_SETTING_KEY,
field: `decorativeElements.${input.decorationId}.imageUrl`,
ipAddress: extractClientIp(ctx.req),
});
return nextAppearance;
}),
uploadHeroImage: superAdminProcedure
.input(z.object({
fileData: z.string(),
fileName: z.string(),
mimeType: z.string(),
}))
.mutation(async ({ ctx, input }) => {
if (!input.mimeType.startsWith("image/")) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Le fichier doit être une image" });
}
const fileBuffer = Buffer.from(input.fileData, "base64");
const storageName = `portal/appearance/hero/${nanoid()}-${input.fileName}`;
const { url } = await storagePut(storageName, fileBuffer, input.mimeType);
const storedValue = await db.getPortalSetting(APPEARANCE_SETTING_KEY);
let currentAppearance = defaultPortalAppearance;
if (storedValue) {
try {
currentAppearance = sanitizePortalAppearance(JSON.parse(storedValue));
} catch {
currentAppearance = defaultPortalAppearance;
}
}
const nextAppearance = sanitizePortalAppearance({
...currentAppearance,
heroImageUrl: url,
});
await db.setPortalSetting(
APPEARANCE_SETTING_KEY,
JSON.stringify(nextAppearance),
"Configuration visuelle du portail"
);
await logAdminAction(ctx.user.id, "modification", "setting", undefined, {
key: APPEARANCE_SETTING_KEY,
field: "heroImageUrl",
ipAddress: extractClientIp(ctx.req),
});
return nextAppearance;
}),
uploadSalleRib: adminProcedure
.input(z.object({
fileData: z.string(),
fileName: z.string(),
mimeType: z.string(),
}))
.mutation(async ({ ctx, input }) => {
const allowedMimeTypes = new Set([
"application/pdf",
"image/png",
"image/jpeg",
"image/webp",
]);
if (!allowedMimeTypes.has(input.mimeType)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Le RIB doit être un PDF, PNG, JPG ou WebP" });
}
const fileBuffer = Buffer.from(input.fileData, "base64");
const storageName = `portal/salle-billing/rib/${nanoid()}-${input.fileName}`;
const { url } = await storagePut(storageName, fileBuffer, input.mimeType);
const nextSettings = sanitizeSalleBillingSettings({
ribDocumentUrl: url,
ribDocumentName: input.fileName,
ribMimeType: input.mimeType,
ribUploadedAt: new Date().toISOString(),
});
await db.setPortalSetting(
SALLE_BILLING_SETTING_KEY,
JSON.stringify(nextSettings),
"RIB MJS utilisé pour les factures salle"
);
await logAdminAction(ctx.user.id, "modification", "setting", undefined, {
key: SALLE_BILLING_SETTING_KEY,
fileName: input.fileName,
mimeType: input.mimeType,
ipAddress: extractClientIp(ctx.req),
});
return nextSettings;
}),
clearSalleRib: adminProcedure
.mutation(async ({ ctx }) => {
const nextSettings = sanitizeSalleBillingSettings(null);
await db.setPortalSetting(
SALLE_BILLING_SETTING_KEY,
JSON.stringify(nextSettings),
"RIB MJS utilisé pour les factures salle"
);
await logAdminAction(ctx.user.id, "modification", "setting", undefined, {
key: SALLE_BILLING_SETTING_KEY,
cleared: true,
ipAddress: extractClientIp(ctx.req),
});
return nextSettings;
}),
}),
// ============== STATS ROUTES (ADMIN) ==============
stats: router({
getDashboard: accueilAdminProcedure.query(async () => {
const stats = await db.getDashboardStats();
if (!stats) {
return {
totalAssociations: 0,
activeAssociations: 0,
totalRequests: 0,
pendingRequests: 0,
validatedRequests: 0,
rejectedRequests: 0,
newAssociationsThisMonth: 0,
newRequestsThisMonth: 0,
requestsThisWeek: 0,
overdueRequests: 0,
acceptanceRate: 0,
requestsByStatus: {},
};
}
return stats;
}),
getRequestsPerMonth: accueilAdminProcedure
.input(z.object({ months: z.number().optional() }))
.query(async ({ input }) => {
return db.getRequestsPerMonth(input.months || 12);
}),
getAssociationsPerMonth: accueilAdminProcedure
.input(z.object({ months: z.number().optional() }))
.query(async ({ input }) => {
return db.getAssociationsPerMonth(input.months || 12);
}),
getRequestsByType: accueilAdminProcedure.query(async () => {
return db.getRequestsByTypeStats();
}),
getAverageProcessingTime: accueilAdminProcedure.query(async () => {
return db.getAverageProcessingTime();
}),
getAutomationSettings: accueilAdminProcedure.query(async () => {
return getStatsAutomationSettings();
}),
getReportHistory: accueilAdminProcedure.query(async () => {
return getStatsReportHistory();
}),
updateAutomationSettings: adminProcedure
.input(z.object({
enabled: z.boolean(),
recipientEmails: z.array(z.string().email()).default([]),
frequencies: z.object({
weekly: z.boolean(),
month: z.boolean(),
quarter: z.boolean(),
semester: z.boolean(),
year: z.boolean(),
}),
}))
.mutation(async ({ ctx, input }) => {
const current = await getStatsAutomationSettings();
const saved = await saveStatsAutomationSettings({
...current,
enabled: input.enabled,
recipientEmails: input.recipientEmails.map((entry) => normalizeEmail(entry)),
frequencies: input.frequencies,
});
await logAdminAction(ctx.user.id, "modification", "setting", undefined, {
key: STATS_AUTOMATION_SETTING_KEY,
enabled: saved.enabled,
frequencies: saved.frequencies,
recipientEmails: saved.recipientEmails,
ipAddress: extractClientIp(ctx.req),
});
return saved;
}),
generateReportNow: adminProcedure
.input(z.object({
period: z.enum(["weekly", "month", "quarter", "semester", "year"]),
}))
.mutation(async ({ ctx, input }) => {
const analyticsPeriod = input.period === "weekly" ? "7d" : input.period;
const cycleKey = `manual-${input.period}-${new Date().toISOString()}`;
const result = await generateAndArchiveStatsReport({
period: input.period,
analyticsPeriod,
generatedBy: "manual",
cycleKey,
now: new Date(),
});
await db.createAdminNotification({
type: "systeme",
titre: `Rapport statistiques généré`,
message: `Un rapport ${result.entry.periodLabel.toLowerCase()} a été généré manuellement depuis lespace statistiques.`,
lien: "/admin?tab=analytics",
});
await logAdminAction(ctx.user.id, "generation_rapport_statistiques", "setting", undefined, {
period: input.period,
cycleKey,
ipAddress: extractClientIp(ctx.req),
});
return result.entry;
}),
getReservationAnalytics: accueilAdminProcedure
.input(z.object({
period: analyticsPeriodSchema.default("month"),
}))
.query(async ({ input }) => {
return computeReservationAnalytics(input.period);
}),
}),
operationalTour: router({
getSettings: adminProcedure.query(async () => {
const settings = await getOperationalTourSettings();
return {
...settings,
targets: {
production: {
...settings.targets.production,
associationPassword: "",
accueilPassword: "",
adminPassword: "",
},
secondary: {
...settings.targets.secondary,
associationPassword: "",
accueilPassword: "",
adminPassword: "",
},
},
};
}),
saveSettings: superAdminProcedure
.input(z.object({
defaultEnvironment: operationalTourEnvironmentSchema,
targets: z.object({
production: z.object({
baseUrl: z.string().trim(),
associationEmail: z.string().trim(),
associationPassword: z.string().trim(),
associationAuthMode: operationalTourAuthModeSchema,
accueilEmail: z.string().trim(),
accueilPassword: z.string().trim(),
accueilAuthMode: operationalTourAuthModeSchema,
adminEmail: z.string().trim(),
adminPassword: z.string().trim(),
adminAuthMode: operationalTourAuthModeSchema,
}),
secondary: z.object({
baseUrl: z.string().trim(),
associationEmail: z.string().trim(),
associationPassword: z.string().trim(),
associationAuthMode: operationalTourAuthModeSchema,
accueilEmail: z.string().trim(),
accueilPassword: z.string().trim(),
accueilAuthMode: operationalTourAuthModeSchema,
adminEmail: z.string().trim(),
adminPassword: z.string().trim(),
adminAuthMode: operationalTourAuthModeSchema,
}),
}),
}))
.mutation(async ({ ctx, input }) => {
const current = await getOperationalTourSettings();
const saved = await saveOperationalTourSettings({
defaultEnvironment: input.defaultEnvironment,
targets: {
production: {
...current.targets.production,
...input.targets.production,
associationPassword:
input.targets.production.associationAuthMode === "oauth"
? ""
: input.targets.production.associationPassword.trim() || current.targets.production.associationPassword,
accueilPassword:
input.targets.production.accueilAuthMode === "oauth"
? ""
: input.targets.production.accueilPassword.trim() || current.targets.production.accueilPassword,
adminPassword:
input.targets.production.adminAuthMode === "oauth"
? ""
: input.targets.production.adminPassword.trim() || current.targets.production.adminPassword,
},
secondary: {
...current.targets.secondary,
...input.targets.secondary,
associationPassword:
input.targets.secondary.associationAuthMode === "oauth"
? ""
: input.targets.secondary.associationPassword.trim() || current.targets.secondary.associationPassword,
accueilPassword:
input.targets.secondary.accueilAuthMode === "oauth"
? ""
: input.targets.secondary.accueilPassword.trim() || current.targets.secondary.accueilPassword,
adminPassword:
input.targets.secondary.adminAuthMode === "oauth"
? ""
: input.targets.secondary.adminPassword.trim() || current.targets.secondary.adminPassword,
},
},
});
await logAdminAction(ctx.user.id, "modification", "setting", undefined, {
key: OPERATIONAL_TOUR_SETTINGS_KEY,
defaultEnvironment: saved.defaultEnvironment,
productionBaseUrl: saved.targets.production.baseUrl,
secondaryBaseUrl: saved.targets.secondary.baseUrl,
productionAssociationConfigured: Boolean(saved.targets.production.associationEmail && (saved.targets.production.associationAuthMode === "oauth" || saved.targets.production.associationPassword)),
secondaryAssociationConfigured: Boolean(saved.targets.secondary.associationEmail && (saved.targets.secondary.associationAuthMode === "oauth" || saved.targets.secondary.associationPassword)),
productionAccueilConfigured: Boolean(saved.targets.production.accueilEmail && (saved.targets.production.accueilAuthMode === "oauth" || saved.targets.production.accueilPassword)),
secondaryAccueilConfigured: Boolean(saved.targets.secondary.accueilEmail && (saved.targets.secondary.accueilAuthMode === "oauth" || saved.targets.secondary.accueilPassword)),
productionAdminConfigured: Boolean(saved.targets.production.adminEmail && (saved.targets.production.adminAuthMode === "oauth" || saved.targets.production.adminPassword)),
secondaryAdminConfigured: Boolean(saved.targets.secondary.adminEmail && (saved.targets.secondary.adminAuthMode === "oauth" || saved.targets.secondary.adminPassword)),
ipAddress: extractClientIp(ctx.req),
});
return saved;
}),
getHistory: adminProcedure.query(async () => {
return getOperationalTourHistory();
}),
getLatest: adminProcedure.query(async () => {
const history = await getOperationalTourHistory();
return history[0] ?? null;
}),
clearHistory: superAdminProcedure.mutation(async ({ ctx }) => {
const retained = await clearOperationalTourHistory();
await logAdminAction(ctx.user.id, "nettoyage_historique_tour_operationnel", "setting", undefined, {
key: OPERATIONAL_TOUR_SETTINGS_KEY,
retainedReports: retained.map((report) => ({
id: report.id,
environment: report.environment,
finishedAt: report.finishedAt,
status: report.status,
})),
ipAddress: extractClientIp(ctx.req),
});
return retained;
}),
run: adminProcedure
.input(z.object({
environment: operationalTourEnvironmentSchema.optional(),
}))
.mutation(async ({ ctx, input }) => {
const settings = await getOperationalTourSettings();
const environment = input.environment ?? settings.defaultEnvironment;
const report = await runOperationalTour({
environment,
req: ctx.req,
currentUser: ctx.user,
});
await logAdminAction(ctx.user.id, "tour_operationnel", "setting", undefined, {
key: OPERATIONAL_TOUR_SETTINGS_KEY,
environment,
status: report.status,
summary: report.summary,
ipAddress: extractClientIp(ctx.req),
});
return report;
}),
}),
});
export type AppRouter = typeof appRouter;