10846 lines
428 KiB
TypeScript
10846 lines
428 KiB
TypeScript
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 11–17 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 d’hé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é n’a é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 l’application.",
|
||
});
|
||
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 l’application.",
|
||
});
|
||
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 d’inscription et d’enrichissement 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 l’inté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 d’accord 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 n’a encore été trouvé pour l’archivage 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 n’a encore été trouvé pour l’archivage 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 n’a pas pu être interprété automatiquement.");
|
||
}
|
||
} else {
|
||
notes.push("Aucun rapport automatique de rétention n’a encore été détecté.");
|
||
}
|
||
|
||
if (existing.backupRecords.length === 0) {
|
||
notes.push("Les preuves d’exécution des sauvegardes restent à saisir manuellement ou à brancher depuis l’exploitation.");
|
||
}
|
||
if (existing.restoreTests.length === 0) {
|
||
notes.push("Aucun test de restauration historisé n’est 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é à l’accueil, 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 à l’heure 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: 'L’heure de fin doit être postérieure à l’heure 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 d’enrichissement 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 d’enrichissement 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é à l’hôtesse par la Directrice : ${comment}`
|
||
: "Dossier renvoyé à l’hôtesse par la Directrice",
|
||
});
|
||
|
||
await db.createAdminNotification({
|
||
type: "systeme",
|
||
titre: `Retour Directrice : ${request.titre}`,
|
||
message: comment
|
||
? `La Directrice a renvoyé le dossier "${request.titre}" à l’hôtesse avec la note suivante : ${comment}`
|
||
: `La Directrice a renvoyé le dossier "${request.titre}" à l’hô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é à l’hôtesse pour confirmation finale",
|
||
});
|
||
|
||
await db.createAdminNotification({
|
||
type: "systeme",
|
||
titre: `Dossier signé : ${request.titre}`,
|
||
message: `La Direction a signé le dossier "${request.titre}". L’hôtesse retrouve maintenant le devis signé, la décision signée, le formulaire signé et la facture générée pour préparer l’envoi final à l’association.`,
|
||
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 l’envoi 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 l’association',
|
||
});
|
||
|
||
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 n’est encore historisée dans le portail."
|
||
: null,
|
||
operations.restoreTests.length === 0
|
||
? "Aucun test de restauration n’est 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 l’espace d’administration 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 d’envoyer l’email 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 l’espace 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;
|