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:
@@ -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",
|
||||
|
||||
76
server/db.ts
76
server/db.ts
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user