feat: lancer manuellement les imports SFR
This commit is contained in:
@@ -43,6 +43,7 @@ RUN apt-get update && apt-get install -y \
|
|||||||
poppler-utils \
|
poppler-utils \
|
||||||
libvips42 \
|
libvips42 \
|
||||||
curl \
|
curl \
|
||||||
|
chromium \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Globe, Plus, Pencil, Trash2, Key, CheckCircle, XCircle, Clock, RefreshCw } from "lucide-react";
|
import { Globe, Plus, Pencil, Trash2, Key, CheckCircle, XCircle, Clock, RefreshCw, Play } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
const CONNECTOR_TYPES = [
|
const CONNECTOR_TYPES = [
|
||||||
@@ -85,6 +85,7 @@ export default function WebImportSources() {
|
|||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [editSource, setEditSource] = useState<Source | null>(null);
|
const [editSource, setEditSource] = useState<Source | null>(null);
|
||||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||||
|
const [manualImportId, setManualImportId] = useState<number | null>(null);
|
||||||
const [showToken, setShowToken] = useState<number | null>(null);
|
const [showToken, setShowToken] = useState<number | null>(null);
|
||||||
const [form, setForm] = useState<FormData>(emptyForm);
|
const [form, setForm] = useState<FormData>(emptyForm);
|
||||||
|
|
||||||
@@ -124,6 +125,21 @@ export default function WebImportSources() {
|
|||||||
onError: (e) => toast.error("Erreur", { description: e.message }),
|
onError: (e) => toast.error("Erreur", { description: e.message }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const runManualMutation = trpc.webImportSources.runManual.useMutation({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
toast.success("Import manuel terminé", {
|
||||||
|
description: `${result.imported} facture(s) importée(s), ${result.duplicates} doublon(s) ignoré(s).`,
|
||||||
|
});
|
||||||
|
setManualImportId(null);
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
onError: (e) => {
|
||||||
|
toast.error("Import manuel en erreur", { description: e.message });
|
||||||
|
setManualImportId(null);
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
setForm(emptyForm);
|
setForm(emptyForm);
|
||||||
setEditSource(null);
|
setEditSource(null);
|
||||||
@@ -253,6 +269,21 @@ export default function WebImportSources() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1 text-xs"
|
||||||
|
disabled={source.connectorType !== "sfr" || runManualMutation.isPending}
|
||||||
|
title={source.connectorType === "sfr" ? "Lancer une lecture ponctuelle" : "Ce connecteur ne dispose pas encore d’un moteur d’import manuel"}
|
||||||
|
onClick={() => setManualImportId(source.id)}
|
||||||
|
>
|
||||||
|
{runManualMutation.isPending && manualImportId === source.id ? (
|
||||||
|
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Play className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
Importer maintenant
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -388,6 +419,26 @@ export default function WebImportSources() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog open={manualImportId !== null} onOpenChange={(open) => { if (!open && !runManualMutation.isPending) setManualImportId(null); }}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Lancer l’import manuel ?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
L’application se connectera ponctuellement à l’espace client SFR, téléchargera les nouvelles factures puis les analysera via le flux normal. Cette action ne crée aucune planification automatique.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={runManualMutation.isPending}>Annuler</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
disabled={runManualMutation.isPending}
|
||||||
|
onClick={() => manualImportId !== null && runManualMutation.mutate({ id: manualImportId })}
|
||||||
|
>
|
||||||
|
{runManualMutation.isPending ? "Import en cours…" : "Lancer l’import"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
{/* Confirmation suppression */}
|
{/* Confirmation suppression */}
|
||||||
<AlertDialog open={deleteId !== null} onOpenChange={(open) => { if (!open) setDeleteId(null); }}>
|
<AlertDialog open={deleteId !== null} onOpenChange={(open) => { if (!open) setDeleteId(null); }}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|||||||
@@ -85,6 +85,7 @@
|
|||||||
"pdf-parse": "^2.4.5",
|
"pdf-parse": "^2.4.5",
|
||||||
"pdf2json": "^4.0.2",
|
"pdf2json": "^4.0.2",
|
||||||
"pdfjs-dist": "^5.4.624",
|
"pdfjs-dist": "^5.4.624",
|
||||||
|
"playwright-core": "^1.63.0",
|
||||||
"react": "^19.2.1",
|
"react": "^19.2.1",
|
||||||
"react-day-picker": "^9.11.1",
|
"react-day-picker": "^9.11.1",
|
||||||
"react-dom": "^19.2.1",
|
"react-dom": "^19.2.1",
|
||||||
|
|||||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -229,6 +229,9 @@ importers:
|
|||||||
pdfjs-dist:
|
pdfjs-dist:
|
||||||
specifier: ^5.4.624
|
specifier: ^5.4.624
|
||||||
version: 5.4.624
|
version: 5.4.624
|
||||||
|
playwright-core:
|
||||||
|
specifier: ^1.63.0
|
||||||
|
version: 1.63.0
|
||||||
react:
|
react:
|
||||||
specifier: ^19.2.1
|
specifier: ^19.2.1
|
||||||
version: 19.2.1
|
version: 19.2.1
|
||||||
@@ -4033,6 +4036,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
|
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
playwright-core@1.63.0:
|
||||||
|
resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
pnpm@10.18.0:
|
pnpm@10.18.0:
|
||||||
resolution: {integrity: sha512-6AT4ifHOzEDVctsITuw+SIFzn43sacD/ENLRvv+aTjCTg7ontbdQBZ1/TBSVNbbNDSyx7Trrc5I5pChKaPQM+g==}
|
resolution: {integrity: sha512-6AT4ifHOzEDVctsITuw+SIFzn43sacD/ENLRvv+aTjCTg7ontbdQBZ1/TBSVNbbNDSyx7Trrc5I5pChKaPQM+g==}
|
||||||
engines: {node: '>=18.12'}
|
engines: {node: '>=18.12'}
|
||||||
@@ -8601,6 +8609,8 @@ snapshots:
|
|||||||
sonic-boom: 4.2.1
|
sonic-boom: 4.2.1
|
||||||
thread-stream: 4.2.0
|
thread-stream: 4.2.0
|
||||||
|
|
||||||
|
playwright-core@1.63.0: {}
|
||||||
|
|
||||||
pnpm@10.18.0: {}
|
pnpm@10.18.0: {}
|
||||||
|
|
||||||
postcss-selector-parser@6.0.10:
|
postcss-selector-parser@6.0.10:
|
||||||
|
|||||||
28
server/manualWebImport.test.ts
Normal file
28
server/manualWebImport.test.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
getManualWebImportAvailability,
|
||||||
|
runManualWebImport,
|
||||||
|
summarizeManualWebImport,
|
||||||
|
} from "./manualWebImport";
|
||||||
|
|
||||||
|
describe("manual web import", () => {
|
||||||
|
it("autorise uniquement le connecteur SFR actuellement implémenté", () => {
|
||||||
|
expect(getManualWebImportAvailability("sfr")).toEqual({ available: true });
|
||||||
|
expect(getManualWebImportAvailability("starlink")).toMatchObject({ available: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agrège les résultats d’envoi sans exposer de données de connexion", () => {
|
||||||
|
expect(
|
||||||
|
summarizeManualWebImport([
|
||||||
|
{ imported: 2, duplicates: 1, total: 3 },
|
||||||
|
{ imported: 0, duplicates: 1, total: 1 },
|
||||||
|
]),
|
||||||
|
).toEqual({ imported: 2, duplicates: 2, total: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuse un type non pris en charge avant de démarrer le navigateur", async () => {
|
||||||
|
await expect(
|
||||||
|
runManualWebImport({ connectorType: "starlink" } as any),
|
||||||
|
).rejects.toThrow("n’est pas encore disponible");
|
||||||
|
});
|
||||||
|
});
|
||||||
129
server/manualWebImport.ts
Normal file
129
server/manualWebImport.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { chromium } from "playwright-core";
|
||||||
|
import type { WebImportSource } from "../drizzle/schema";
|
||||||
|
|
||||||
|
export type ManualWebImportResult = {
|
||||||
|
imported: number;
|
||||||
|
duplicates: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InvoicePushResult = ManualWebImportResult;
|
||||||
|
|
||||||
|
type InvoicePusher = (fileName: string, pdfBuffer: Buffer) => Promise<InvoicePushResult>;
|
||||||
|
|
||||||
|
const SFR_INVOICE_LINK_SELECTOR =
|
||||||
|
'a[href*=".pdf"], a[href*="facture"], a[href*="invoice"], a[download]';
|
||||||
|
|
||||||
|
/** Types de connecteurs dont l’automate est réellement livré dans l’image applicative. */
|
||||||
|
export function getManualWebImportAvailability(connectorType: string):
|
||||||
|
| { available: true }
|
||||||
|
| { available: false; reason: string } {
|
||||||
|
if (connectorType === "sfr") return { available: true };
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
reason: `L’import manuel n’est pas encore disponible pour le connecteur « ${connectorType} ».`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeManualWebImport(results: InvoicePushResult[]): ManualWebImportResult {
|
||||||
|
return results.reduce(
|
||||||
|
(summary, result) => ({
|
||||||
|
imported: summary.imported + result.imported,
|
||||||
|
duplicates: summary.duplicates + result.duplicates,
|
||||||
|
total: summary.total + result.total,
|
||||||
|
}),
|
||||||
|
{ imported: 0, duplicates: 0, total: 0 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApplicationBaseUrl(): string {
|
||||||
|
return process.env.APP_BASE_URL || `http://127.0.0.1:${process.env.PORT || 3000}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushInvoiceToApplication(source: WebImportSource, fileName: string, pdfBuffer: Buffer) {
|
||||||
|
const response = await fetch(`${getApplicationBaseUrl()}/api/web-import/push-invoice`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
apiToken: source.apiToken,
|
||||||
|
fileName,
|
||||||
|
fileBase64: pdfBuffer.toString("base64"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Le dépôt sécurisé du PDF a échoué (HTTP ${response.status}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (await response.json()) as Partial<InvoicePushResult>;
|
||||||
|
return {
|
||||||
|
imported: Number(result.imported || 0),
|
||||||
|
duplicates: Number(result.duplicates || 0),
|
||||||
|
total: Number(result.total || 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Télécharge les nouvelles factures SFR puis les soumet au même endpoint
|
||||||
|
* de contrôle, dédoublonnage, stockage et analyse IA que l’import automatique.
|
||||||
|
* Les identifiants restent confinés au processus serveur et ne sont jamais loggés.
|
||||||
|
*/
|
||||||
|
export async function runManualWebImport(
|
||||||
|
source: WebImportSource,
|
||||||
|
pushInvoice: InvoicePusher = (fileName, pdfBuffer) => pushInvoiceToApplication(source, fileName, pdfBuffer),
|
||||||
|
): Promise<ManualWebImportResult> {
|
||||||
|
const availability = getManualWebImportAvailability(source.connectorType);
|
||||||
|
if (!availability.available) throw new Error(availability.reason);
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
headless: true,
|
||||||
|
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE || "/usr/bin/chromium",
|
||||||
|
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const context = await browser.newContext({ acceptDownloads: true });
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
await page.goto(source.portalUrl, { waitUntil: "networkidle", timeout: 30_000 });
|
||||||
|
await page
|
||||||
|
.click('[id*="accept"], [class*="accept-cookie"], #didomi-notice-agree-button', { timeout: 3_000 })
|
||||||
|
.catch(() => undefined);
|
||||||
|
|
||||||
|
await page.fill('input[type="email"], input[name="login"], input[id*="login"], input[id*="email"]', source.loginEmail);
|
||||||
|
await page.fill('input[type="password"], input[name="password"], input[id*="password"]', source.loginPassword);
|
||||||
|
await page.click('button[type="submit"], input[type="submit"], button:has-text("Connexion"), button:has-text("Se connecter")');
|
||||||
|
await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined);
|
||||||
|
|
||||||
|
const invoicePage = new URL("factures", source.portalUrl).toString();
|
||||||
|
await page.goto(invoicePage, { waitUntil: "networkidle", timeout: 20_000 }).catch(() => undefined);
|
||||||
|
|
||||||
|
const invoiceLinks = await page.$$eval(SFR_INVOICE_LINK_SELECTOR, (links) =>
|
||||||
|
links.map((link) => ({
|
||||||
|
href: (link as HTMLAnchorElement).href,
|
||||||
|
label: link.textContent?.trim() || "facture.pdf",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const uniqueLinks = Array.from(
|
||||||
|
new Map(invoiceLinks.filter((link) => link.href).map((link) => [link.href, link])).values(),
|
||||||
|
);
|
||||||
|
const results: InvoicePushResult[] = [];
|
||||||
|
|
||||||
|
for (const link of uniqueLinks) {
|
||||||
|
// BrowserContext.request partage la session authentifiée du navigateur. Cela évite
|
||||||
|
// de dépendre du comportement du visualiseur PDF ou de l’événement download.
|
||||||
|
const response = await context.request.get(link.href, { timeout: 20_000 });
|
||||||
|
if (!response.ok()) {
|
||||||
|
throw new Error(`Téléchargement de facture indisponible (HTTP ${response.status()}).`);
|
||||||
|
}
|
||||||
|
const fileName = link.label || `sfr-facture-${Date.now()}.pdf`;
|
||||||
|
const pdfBuffer = Buffer.from(await response.body());
|
||||||
|
results.push(await pushInvoice(fileName, pdfBuffer));
|
||||||
|
}
|
||||||
|
|
||||||
|
return summarizeManualWebImport(results);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,6 +94,7 @@ import {
|
|||||||
getWebImportSourceById,
|
getWebImportSourceById,
|
||||||
createWebImportSource,
|
createWebImportSource,
|
||||||
updateWebImportSource,
|
updateWebImportSource,
|
||||||
|
updateWebImportSourceStatus,
|
||||||
deleteWebImportSource,
|
deleteWebImportSource,
|
||||||
} from "./db";
|
} from "./db";
|
||||||
import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured } from "./auth";
|
import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured } from "./auth";
|
||||||
@@ -105,9 +106,10 @@ import { localStoragePut, generateStorageKey } from "./localStorage";
|
|||||||
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
|
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
|
||||||
import { drawBapCartouche } from "./bapCartouche";
|
import { drawBapCartouche } from "./bapCartouche";
|
||||||
import { resolveBapExportDestination } from "./exportDestinationResolver";
|
import { resolveBapExportDestination } from "./exportDestinationResolver";
|
||||||
import { startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck, triggerManualEmailCheck, testImapConnection } from "./emailImportService";
|
import { createEmailImportAccountController, startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck, triggerManualEmailCheck, testImapConnection } from "./emailImportService";
|
||||||
import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService";
|
import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService";
|
||||||
import { getSafeWebImportSourceError, logWebImportSourceSaveFailure } from "./webImportSourcesError";
|
import { getSafeWebImportSourceError, logWebImportSourceSaveFailure } from "./webImportSourcesError";
|
||||||
|
import { getManualWebImportAvailability, runManualWebImport } from "./manualWebImport";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { processFreeproExcel } from "./freeproService";
|
import { processFreeproExcel } from "./freeproService";
|
||||||
import { runFreeproAutoImport, testFreeproConnection, startFreeproAutoJob, stopFreeproAutoJob, frequencyToMs } from "./freeproAutoImport";
|
import { runFreeproAutoImport, testFreeproConnection, startFreeproAutoJob, stopFreeproAutoJob, frequencyToMs } from "./freeproAutoImport";
|
||||||
@@ -1717,8 +1719,7 @@ export const appRouter = router({
|
|||||||
// ============= EMAIL IMPORT SERVICE ROUTES =============
|
// ============= EMAIL IMPORT SERVICE ROUTES =============
|
||||||
emailImportService: router({
|
emailImportService: router({
|
||||||
start: protectedProcedure.mutation(async ({ ctx }) => {
|
start: protectedProcedure.mutation(async ({ ctx }) => {
|
||||||
const started = await startEmailImportService(ctx.user.id);
|
return startEmailImportService(ctx.user.id);
|
||||||
return { success: started };
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
stop: protectedProcedure.mutation(async ({ ctx }) => {
|
stop: protectedProcedure.mutation(async ({ ctx }) => {
|
||||||
@@ -1740,6 +1741,27 @@ export const appRouter = router({
|
|||||||
return getEmailImportAccounts();
|
return getEmailImportAccounts();
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/** Démarre la réception automatique pour la boîte source sélectionnée par un administrateur. */
|
||||||
|
startAccount: adminProcedure
|
||||||
|
.input(z.object({ userId: z.number().int().positive() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
return createEmailImportAccountController().startAccount(input.userId);
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Arrête la réception automatique pour la boîte source sélectionnée par un administrateur. */
|
||||||
|
stopAccount: adminProcedure
|
||||||
|
.input(z.object({ userId: z.number().int().positive() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
return createEmailImportAccountController().stopAccount(input.userId);
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Retourne le statut en mémoire d’une boîte source administrée. */
|
||||||
|
accountStatus: adminProcedure
|
||||||
|
.input(z.object({ userId: z.number().int().positive() }))
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
return createEmailImportAccountController().accountStatus(input.userId);
|
||||||
|
}),
|
||||||
|
|
||||||
/** Déclenche une seule lecture IMAP sans activer ni planifier le service automatique. */
|
/** Déclenche une seule lecture IMAP sans activer ni planifier le service automatique. */
|
||||||
checkAccountNow: adminProcedure
|
checkAccountNow: adminProcedure
|
||||||
.input(z.object({ userId: z.number().int().positive() }))
|
.input(z.object({ userId: z.number().int().positive() }))
|
||||||
@@ -2579,7 +2601,7 @@ export const appRouter = router({
|
|||||||
saveSettings: protectedProcedure
|
saveSettings: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
portalUrl: webPortalUrlSchema.optional(),
|
portalUrl: z.string().url().optional(),
|
||||||
loginEmail: z.string().email().optional().or(z.literal('')),
|
loginEmail: z.string().email().optional().or(z.literal('')),
|
||||||
loginPassword: z.string().optional(), // vide = ne pas changer
|
loginPassword: z.string().optional(), // vide = ne pas changer
|
||||||
frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).optional(),
|
frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).optional(),
|
||||||
@@ -2747,7 +2769,7 @@ export const appRouter = router({
|
|||||||
id: z.number(),
|
id: z.number(),
|
||||||
name: z.string().min(1).max(100).optional(),
|
name: z.string().min(1).max(100).optional(),
|
||||||
connectorType: z.string().min(1).max(50).optional(),
|
connectorType: z.string().min(1).max(50).optional(),
|
||||||
portalUrl: z.string().url().optional(),
|
portalUrl: webPortalUrlSchema.optional(),
|
||||||
loginEmail: z.string().min(1).optional(),
|
loginEmail: z.string().min(1).optional(),
|
||||||
loginPassword: z.string().optional(),
|
loginPassword: z.string().optional(),
|
||||||
frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).optional(),
|
frequency: z.enum(['manual', 'daily', 'weekly', 'monthly']).optional(),
|
||||||
@@ -2783,6 +2805,38 @@ export const appRouter = router({
|
|||||||
}
|
}
|
||||||
return { apiToken: source.apiToken };
|
return { apiToken: source.apiToken };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/** Lance ponctuellement un connecteur pris en charge, sans activer de planification. */
|
||||||
|
runManual: 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" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const availability = getManualWebImportAvailability(source.connectorType);
|
||||||
|
if (!availability.available) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: availability.reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runManualWebImport(source);
|
||||||
|
return { success: true, ...result };
|
||||||
|
} catch (error) {
|
||||||
|
// Ne jamais inclure une erreur de portail dans l’interface : elle peut contenir
|
||||||
|
// des informations de session ou des sélecteurs internes.
|
||||||
|
const technicalCode = error && typeof error === "object" && "code" in error && typeof error.code === "string"
|
||||||
|
? error.code
|
||||||
|
: "unknown";
|
||||||
|
console.error("[WebImport] Échec d’import manuel", { sourceId: source.id, technicalCode });
|
||||||
|
await updateWebImportSourceStatus(source.id, "Erreur lors de l’import manuel", 0, false);
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "L’import manuel n’a pas abouti. Consultez le statut du connecteur puis réessayez.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// ============= BACKUP ROUTES =============
|
// ============= BACKUP ROUTES =============
|
||||||
|
|||||||
Reference in New Issue
Block a user