Checkpoint: Historique des imports enrichi avec le compte, la source, le déclencheur, les résultats, les erreurs et les informations du fichier source. Les administrateurs peuvent déclencher une lecture e-mail ponctuelle pour une boîte configurée, avec confirmation explicite, sans activer ni créer de planification. Migration importTrigger appliquée en sandbox ; TypeScript, 48 tests, build et rendus bureau/mobile validés.

This commit is contained in:
Manus
2026-09-02 11:43:23 +00:00
parent f53bb82256
commit 0b2f1b781d
12 changed files with 2834 additions and 421 deletions

View File

@@ -1,4 +1,4 @@
import { eq, and, desc, sql, inArray } from "drizzle-orm";
import { eq, and, desc, sql, inArray, getTableColumns } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import {
InsertUser,
@@ -538,6 +538,44 @@ export async function getAllImportLogs(): Promise<ImportLog[]> {
return db.select().from(importLogs).orderBy(desc(importLogs.importedAt));
}
/**
* Retourne les informations nécessaires à l'audit d'un import sans exposer
* les paramètres sensibles de messagerie. L'administrateur peut ainsi relier
* chaque import à son compte, fichier source et déclencheur.
*/
export async function getImportLogsWithDetailsByUser(userId: number) {
const db = await getDb();
if (!db) return [];
return db.select({
...getTableColumns(importLogs),
userName: users.name,
userEmail: users.email,
sourceCreatedAt: sourceFiles.createdAt,
sourceStatus: sourceFiles.processingStatus,
})
.from(importLogs)
.leftJoin(users, eq(importLogs.userId, users.id))
.leftJoin(sourceFiles, eq(importLogs.sourceFileId, sourceFiles.id))
.where(eq(importLogs.userId, userId))
.orderBy(desc(importLogs.importedAt));
}
export async function getAllImportLogsWithDetails() {
const db = await getDb();
if (!db) return [];
return db.select({
...getTableColumns(importLogs),
userName: users.name,
userEmail: users.email,
sourceCreatedAt: sourceFiles.createdAt,
sourceStatus: sourceFiles.processingStatus,
})
.from(importLogs)
.leftJoin(users, eq(importLogs.userId, users.id))
.leftJoin(sourceFiles, eq(importLogs.sourceFileId, sourceFiles.id))
.orderBy(desc(importLogs.importedAt));
}
export async function deleteAllImportLogs(userId: number | null): Promise<void> {
const db = await getDb();
if (!db) throw new Error("Database not available");
@@ -611,6 +649,24 @@ export async function upsertImportSettings(data: InsertImportSettings): Promise<
}
}
/** Liste administrative des comptes e-mail configurés, sans secret IMAP ni Azure AD. */
export async function getEmailImportAccounts() {
const db = await getDb();
if (!db) return [];
return db.select({
userId: importSettings.userId,
userName: users.name,
userEmail: users.email,
emailAddress: importSettings.emailImportAddress,
authMode: importSettings.emailImportAuthMode,
automaticEnabled: importSettings.emailImportEnabled,
isConfigured: sql<number>`CASE WHEN ${importSettings.emailImportAddress} IS NOT NULL AND ${importSettings.emailImportAddress} <> '' AND ${importSettings.emailImportHost} IS NOT NULL AND ${importSettings.emailImportHost} <> '' THEN 1 ELSE 0 END`.as("isConfigured"),
})
.from(importSettings)
.innerJoin(users, eq(importSettings.userId, users.id))
.orderBy(users.name, users.email);
}
// ============= DEPARTMENT LIST OPERATIONS =============
export async function getDepartmentsByUser(_userId?: number): Promise<Department[]> {

View File

@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { createImapFlowOptions, type EmailImportConfig } from "./emailImportService";
import {
createImapFlowOptions,
type EmailImportConfig,
validateEmailImportConfiguration,
} from "./emailImportService";
const baseConfig: EmailImportConfig = {
userId: 2,
@@ -36,3 +40,39 @@ describe("createImapFlowOptions", () => {
expect(options.auth).not.toHaveProperty("accessToken");
});
});
describe("validateEmailImportConfiguration", () => {
it("accepte une boîte OAuth2 configurée même lorsque la planification automatique est désactivée", () => {
const error = validateEmailImportConfiguration({
emailImportEnabled: 0,
emailImportAddress: "compta@example.org",
emailImportPassword: null,
emailImportHost: "outlook.office365.com",
emailImportPort: 993,
emailImportSinceDate: null,
emailImportAuthMode: "oauth2",
azureTenantId: "tenant",
azureClientId: "client",
azureClientSecret: "secret",
});
expect(error).toBeNull();
});
it("refuse une boîte dont la configuration IMAP est incomplète", () => {
const error = validateEmailImportConfiguration({
emailImportEnabled: 0,
emailImportAddress: "compta@example.org",
emailImportPassword: null,
emailImportHost: null,
emailImportPort: 993,
emailImportSinceDate: null,
emailImportAuthMode: "oauth2",
azureTenantId: "tenant",
azureClientId: "client",
azureClientSecret: "secret",
});
expect(error).toBe("Configuration IMAP incomplète");
});
});

View File

@@ -32,6 +32,40 @@ export interface EmailImportConfig {
azureClientSecret?: string;
}
export type EmailImportTrigger = "manual" | "automatic";
type EmailImportSettingsSnapshot = {
emailImportEnabled: number;
emailImportAddress: string | null;
emailImportPassword: string | null;
emailImportHost: string | null;
emailImportPort: number | null;
emailImportSinceDate: number | null;
emailImportAuthMode: "basic" | "oauth2";
azureTenantId: string | null;
azureClientId: string | null;
azureClientSecret: string | null;
};
/** Vérifie une configuration IMAP sans tenir compte de l'activation de la planification. */
export function validateEmailImportConfiguration(
settings: EmailImportSettingsSnapshot | null | undefined,
): string | null {
if (!settings || !settings.emailImportAddress || !settings.emailImportHost) {
return "Configuration IMAP incomplète";
}
if (settings.emailImportAuthMode === "basic" && !settings.emailImportPassword) {
return "Mot de passe IMAP manquant";
}
if (
settings.emailImportAuthMode === "oauth2" &&
(!settings.azureTenantId || !settings.azureClientId || !settings.azureClientSecret)
) {
return "Configuration OAuth2 IMAP incomplète";
}
return null;
}
/**
* Construit les options ImapFlow sans effectuer d'appel réseau.
* ImapFlow reçoit le jeton brut et construit lui-même SASL XOAUTH2.
@@ -67,14 +101,17 @@ const activeIntervals = new Map<number, NodeJS.Timeout>();
// qu'un second cycle IMAP traite les mêmes messages avant la fin du premier.
const activeChecks = new Map<number, Promise<void>>();
function runEmailCheckExclusive(config: EmailImportConfig): Promise<void> {
function runEmailCheckExclusive(
config: EmailImportConfig,
trigger: EmailImportTrigger = "automatic",
): Promise<void> {
const runningCheck = activeChecks.get(config.userId);
if (runningCheck) {
console.log(`[EmailImport] Vérification déjà en cours pour user ${config.userId}, cycle ignoré`);
return runningCheck;
}
const check = checkEmailsForPDFs(config).finally(() => {
const check = checkEmailsForPDFs(config, trigger).finally(() => {
if (activeChecks.get(config.userId) === check) activeChecks.delete(config.userId);
});
activeChecks.set(config.userId, check);
@@ -88,7 +125,8 @@ function runEmailCheckExclusive(config: EmailImportConfig): Promise<void> {
async function processEmailAttachment(
userId: number,
attachment: Attachment,
emailSubject: string
emailSubject: string,
trigger: EmailImportTrigger,
): Promise<{ success: boolean; totalInvoices: number; imported: number; duplicates: number; errors: number; quotaError?: boolean }> {
const fileName = attachment.filename || `email-attachment-${Date.now()}.pdf`;
console.log(`[EmailImport] Processing attachment: ${fileName} from email: ${emailSubject}`);
@@ -323,6 +361,7 @@ async function processEmailAttachment(
errorDetails: errorDetails.length > 0 ? JSON.stringify(errorDetails) : null,
warningMessage,
importSource: "email",
importTrigger: trigger,
});
console.log(`[EmailImport] Successfully processed attachment: ${fileName}`);
@@ -386,7 +425,10 @@ async function buildImapConfig(config: EmailImportConfig): Promise<ImapFlowOptio
/**
* Connect to IMAP and process unread emails with PDF attachments
*/
async function checkEmailsForPDFs(config: EmailImportConfig): Promise<void> {
async function checkEmailsForPDFs(
config: EmailImportConfig,
trigger: EmailImportTrigger,
): Promise<void> {
const imapConfig = await buildImapConfig(config);
const client = new ImapFlow(imapConfig);
client.on("error", (error) => {
@@ -450,6 +492,7 @@ async function checkEmailsForPDFs(config: EmailImportConfig): Promise<void> {
config.userId,
attachment,
parsed.subject || "No subject",
trigger,
);
allAttachmentsSucceeded = allAttachmentsSucceeded && result.success;
@@ -615,29 +658,28 @@ export function isEmailImportServiceRunning(userId: number): boolean {
return activeIntervals.has(userId);
}
/**
* Manually trigger an immediate email check for a user
*/
export async function triggerEmailCheck(userId: number): Promise<{ success: boolean; message: string }> {
async function triggerConfiguredEmailCheck(
userId: number,
requireAutomaticEnabled: boolean,
): Promise<{ success: boolean; message: string }> {
try {
// Get user's import settings
const settings = await getImportSettingsByUser(userId);
if (!settings || settings.emailImportEnabled !== 1) {
if (!settings || (requireAutomaticEnabled && settings.emailImportEnabled !== 1)) {
return { success: false, message: "Import par email non activé" };
}
if (!settings.emailImportAddress || !settings.emailImportHost) {
return { success: false, message: "Configuration IMAP incomplète" };
}
const validationError = validateEmailImportConfiguration(settings);
if (validationError) return { success: false, message: validationError };
const authMode = (settings as any).emailImportAuthMode as "basic" | "oauth2" || "basic";
const config: EmailImportConfig = {
userId,
emailAddress: settings.emailImportAddress,
// validateEmailImportConfiguration garantit ces deux valeurs avant ce point.
emailAddress: settings.emailImportAddress!,
password: settings.emailImportPassword || "",
host: settings.emailImportHost,
host: settings.emailImportHost!,
port: settings.emailImportPort || 993,
sinceDate: settings.emailImportSinceDate ?? undefined,
authMode,
@@ -647,7 +689,8 @@ export async function triggerEmailCheck(userId: number): Promise<{ success: bool
};
console.log(`[EmailImport] Manual check triggered for user ${userId}`);
await runEmailCheckExclusive(config);
// Cette voie exécute une vérification unique : elle ne crée pas de setInterval.
await runEmailCheckExclusive(config, "manual");
return { success: true, message: "Vérification terminée avec succès" };
} catch (error: any) {
@@ -656,6 +699,16 @@ export async function triggerEmailCheck(userId: number): Promise<{ success: bool
}
}
/** Déclenchement manuel réservé aux appels administrateurs, même si le planificateur est désactivé. */
export async function triggerManualEmailCheck(userId: number): Promise<{ success: boolean; message: string }> {
return triggerConfiguredEmailCheck(userId, false);
}
/** Compatibilité avec le bouton existant : la vérification personnelle exige toujours lactivation automatique. */
export async function triggerEmailCheck(userId: number): Promise<{ success: boolean; message: string }> {
return triggerConfiguredEmailCheck(userId, true);
}
/**
* Stop all email import services
*/

View File

@@ -211,6 +211,7 @@ async function processFolderFile(
duplicateDetails: duplicateDetails.length > 0 ? JSON.stringify(duplicateDetails) : null,
errorDetails: errorDetails.length > 0 ? JSON.stringify(errorDetails) : null,
importSource: "folder",
importTrigger: "automatic",
});
// Move file to processed folder

View File

@@ -40,13 +40,16 @@ import {
isInvoiceBlacklisted,
getAllInvoices,
getAllImportLogs,
getAllImportLogsWithDetails,
getAllBapHistory,
createImportLog,
getImportLogsByUser,
getImportLogsWithDetailsByUser,
deleteAllImportLogs,
getLlmLogsBySourceFile,
getLlmLogsByInvoice,
getImportSettingsByUser,
getEmailImportAccounts,
upsertImportSettings,
getDepartmentsByUser,
createDepartment,
@@ -94,7 +97,7 @@ import { calculateFileSha256 } from "./fileFingerprint";
import { localStoragePut, generateStorageKey } from "./localStorage";
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
import { drawBapCartouche } from "./bapCartouche";
import { startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck, testImapConnection } from "./emailImportService";
import { startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck, triggerManualEmailCheck, testImapConnection } from "./emailImportService";
import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService";
import { TRPCError } from "@trpc/server";
import { processFreeproExcel } from "./freeproService";
@@ -409,6 +412,7 @@ export const appRouter = router({
duplicateDetails: JSON.stringify(duplicateDetails),
errorDetails: JSON.stringify(errorDetails),
importSource: "file",
importTrigger: "manual",
});
} catch (error: any) {
@@ -1646,9 +1650,9 @@ export const appRouter = router({
getByUser: protectedProcedure.query(async ({ ctx }) => {
// Les admins voient tous les logs d'import
if (ctx.user.role === 'admin') {
return getAllImportLogs();
return getAllImportLogsWithDetails();
}
return getImportLogsByUser(ctx.user.id);
return getImportLogsWithDetailsByUser(ctx.user.id);
}),
deleteAll: protectedProcedure.mutation(async ({ ctx }) => {
@@ -1715,6 +1719,18 @@ export const appRouter = router({
const result = await triggerEmailCheck(ctx.user.id);
return result;
}),
/** Liste sans secrets les boîtes IMAP disponibles pour une action manuelle dadministrateur. */
getConfiguredAccounts: adminProcedure.query(async () => {
return getEmailImportAccounts();
}),
/** Déclenche une seule lecture IMAP sans activer ni planifier le service automatique. */
checkAccountNow: adminProcedure
.input(z.object({ userId: z.number().int().positive() }))
.mutation(async ({ input }) => {
return triggerManualEmailCheck(input.userId);
}),
}),
// ============= IMPORT SETTINGS ROUTES =============