Checkpoint: Ajout du système de connecteurs web : table webImportSources, CRUD tRPC, page WebImportSources.tsx, endpoint /api/web-import/push-invoice, script cron SFR (scripts/web-import/sfr-connector.mjs)

This commit is contained in:
Manus
2026-07-30 13:53:07 +00:00
parent bcb307bde1
commit 711ce6b83a
13 changed files with 3314 additions and 2 deletions

View File

@@ -224,6 +224,56 @@ async function startServer() {
}
});
// ============= WEB IMPORT SOURCES - Endpoint pour script cron externe =============
app.post("/api/web-import/push-invoice", async (req, res) => {
try {
const { apiToken, fileName, fileBase64, mimeType } = req.body;
if (!apiToken || !fileName || !fileBase64) {
res.status(400).json({ error: "apiToken, fileName et fileBase64 sont requis" });
return;
}
const { getWebImportSourceByToken, getImportSettingsByUser, createInvoice, findDuplicateInvoice, isInvoiceBlacklisted, updateWebImportSourceStatus, createSourceFile } = await import('../db');
const source = await getWebImportSourceByToken(apiToken);
if (!source) {
res.status(401).json({ error: "Token invalide" });
return;
}
const pdfBuffer = Buffer.from(fileBase64, 'base64');
const fileMime = mimeType || 'application/pdf';
// Stocker le fichier source en DB
const sourceFile = await createSourceFile({
userId: source.userId,
fileName,
fileKey: `web-import/${source.userId}/${Date.now()}-${fileName}`,
fileUrl: '',
});
const importSettings = await getImportSettingsByUser(source.userId);
const aiSettings = {
aiProvider: importSettings?.aiProvider || 'manus',
mistralApiKey: importSettings?.mistralApiKey || undefined,
manusForgeApiUrl: importSettings?.manusForgeApiUrl || undefined,
manusForgeApiKey: importSettings?.manusForgeApiKey || undefined,
};
const { extractInvoicesWithMistral } = await import('../invoiceExtractor');
const extractResult = await extractInvoicesWithMistral(pdfBuffer, source.userId, sourceFile.id, 'mistral-large-latest', undefined, aiSettings);
let imported = 0;
let duplicates = 0;
for (const inv of extractResult.invoices || []) {
const blacklisted = await isInvoiceBlacklisted(inv.invoiceNumber || null, source.userId);
if (blacklisted) { duplicates++; continue; }
const dup = await findDuplicateInvoice(inv.invoiceNumber || null, String(inv.totalAmount ?? ''), source.userId);
if (dup) { duplicates++; continue; }
await createInvoice({ ...inv, userId: source.userId, sourceFileId: sourceFile.id } as any);
imported++;
}
await updateWebImportSourceStatus(source.id, 'success', imported, true);
res.json({ success: true, imported, duplicates, total: (extractResult.invoices || []).length });
} catch (err: any) {
console.error('[WebImport] Erreur push-invoice:', err.message);
res.status(500).json({ error: err.message });
}
});
// tRPC API
app.use(
"/api/trpc",

View File

@@ -47,7 +47,10 @@ import {
InvoiceLearning,
deletedInvoices,
InsertDeletedInvoice,
DeletedInvoice
DeletedInvoice,
webImportSources,
InsertWebImportSource,
WebImportSource
} from "../drizzle/schema";
import { ENV } from './_core/env';
@@ -1147,3 +1150,74 @@ export async function updateFreeproLastRun(
.set(update)
.where(eq(freeproSettings.userId, userId));
}
// ============= WEB IMPORT SOURCES =============
/** Génère un token API aléatoire de 64 caractères */
function generateApiToken(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let token = '';
for (let i = 0; i < 64; i++) {
token += chars.charAt(Math.floor(Math.random() * chars.length));
}
return token;
}
export async function getWebImportSourcesByUser(userId: number): Promise<WebImportSource[]> {
const db = await getDb();
if (!db) return [];
return db.select().from(webImportSources).where(eq(webImportSources.userId, userId)).orderBy(desc(webImportSources.createdAt));
}
export async function getWebImportSourceById(id: number): Promise<WebImportSource | undefined> {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(webImportSources).where(eq(webImportSources.id, id)).limit(1);
return result[0];
}
export async function getWebImportSourceByToken(token: string): Promise<WebImportSource | undefined> {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(webImportSources).where(eq(webImportSources.apiToken, token)).limit(1);
return result[0];
}
export async function createWebImportSource(data: Omit<InsertWebImportSource, 'apiToken'>): Promise<WebImportSource> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const apiToken = generateApiToken();
const result = await db.insert(webImportSources).values({ ...data, apiToken });
const insertedId = Number(result[0].insertId);
const inserted = await db.select().from(webImportSources).where(eq(webImportSources.id, insertedId)).limit(1);
return inserted[0]!;
}
export async function updateWebImportSource(id: number, data: Partial<InsertWebImportSource>): Promise<void> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(webImportSources).set({ ...data, updatedAt: new Date() }).where(eq(webImportSources.id, id));
}
export async function deleteWebImportSource(id: number): Promise<void> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(webImportSources).where(eq(webImportSources.id, id));
}
export async function updateWebImportSourceStatus(
id: number,
status: string,
importCount: number,
success: boolean
): Promise<void> {
const db = await getDb();
if (!db) return;
const update: Partial<InsertWebImportSource> = {
lastStatus: status,
lastImportCount: importCount,
updatedAt: new Date(),
};
if (success) update.lastSuccessAt = new Date();
await db.update(webImportSources).set(update).where(eq(webImportSources.id, id));
}

View File

@@ -77,6 +77,12 @@ import {
deleteLearning,
deleteAllLearnings,
getBapPdfUrlsByInvoiceIds,
getWebImportSourcesByUser,
getWebImportSourceById,
createWebImportSource,
updateWebImportSource,
deleteWebImportSource,
updateWebImportSourceStatus,
} from "./db";
import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured, generateToken } from "./auth";
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
@@ -2609,5 +2615,68 @@ export const appRouter = router({
return { success: true, webUrl: result.webUrl, fileName };
}),
}),
// ============= WEB IMPORT SOURCES =============
webImportSources: router({
list: protectedProcedure.query(async ({ ctx }) => {
return getWebImportSourcesByUser(ctx.user.id);
}),
create: protectedProcedure
.input(z.object({
name: z.string().min(1).max(100),
connectorType: z.string().min(1).max(50),
portalUrl: z.string().url(),
loginEmail: z.string().min(1),
loginPassword: z.string().min(1),
frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).default('monthly'),
autoEnabled: z.number().min(0).max(1).default(0),
}))
.mutation(async ({ input, ctx }) => {
return createWebImportSource({ ...input, userId: ctx.user.id });
}),
update: protectedProcedure
.input(z.object({
id: z.number(),
name: z.string().min(1).max(100).optional(),
connectorType: z.string().min(1).max(50).optional(),
portalUrl: z.string().url().optional(),
loginEmail: z.string().min(1).optional(),
loginPassword: z.string().optional(),
frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).optional(),
autoEnabled: z.number().min(0).max(1).optional(),
}))
.mutation(async ({ input, ctx }) => {
const source = await getWebImportSourceById(input.id);
if (!source || source.userId !== ctx.user.id) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Source introuvable' });
}
const { id, ...data } = input;
await updateWebImportSource(id, data);
return { success: true };
}),
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
const source = await getWebImportSourceById(input.id);
if (!source || source.userId !== ctx.user.id) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Source introuvable' });
}
await deleteWebImportSource(input.id);
return { success: true };
}),
getToken: protectedProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input, ctx }) => {
const source = await getWebImportSourceById(input.id);
if (!source || source.userId !== ctx.user.id) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Source introuvable' });
}
return { apiToken: source.apiToken };
}),
}),
});
export type AppRouter = typeof appRouter;