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:
58
server/db.ts
58
server/db.ts
@@ -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[]> {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 l’activation automatique. */
|
||||
export async function triggerEmailCheck(userId: number): Promise<{ success: boolean; message: string }> {
|
||||
return triggerConfiguredEmailCheck(userId, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all email import services
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 d’administrateur. */
|
||||
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 =============
|
||||
|
||||
Reference in New Issue
Block a user