Checkpoint: Audit et refactoring : centralisation robuste du suivi lu/non lu, suppression des captures SQL silencieuses, migration d’unicité avec déduplication des lectures existantes, correction du reclassement AAP via classifyAap, nettoyage de cinq composants template non utilisés, nettoyage des lectures lors des purges/fusions RSS et découpage dynamique du frontend. 27 tests Vitest, TypeScript et build de production validés.

This commit is contained in:
Manus
2026-08-17 20:21:45 +00:00
parent 1344d3c060
commit e4a389cc67
18 changed files with 1343 additions and 2348 deletions

1
.gitignore vendored
View File

@@ -108,3 +108,4 @@ temp/
# Webdev artifacts (checkpoint zips, migrations, etc.)
.webdev/
.project-config.json

View File

@@ -1,4 +1,4 @@
{
"version": "fd4e8bc0",
"timestamp": 1784275053491
"timestamp": 1786998105509,
"version": "de5930f0"
}

View File

@@ -1,22 +1,26 @@
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import NotFound from "@/pages/NotFound";
import { lazy, Suspense } from "react";
import { Route, Switch, Redirect } from "wouter";
import ErrorBoundary from "./components/ErrorBoundary";
import { ThemeProvider } from "./contexts/ThemeContext";
import { LocalAuthProvider, useLocalAuth } from "./contexts/LocalAuthContext";
import { AppLayout } from "./components/AppLayout";
import Login from "./pages/Login";
import VeilleDashboard from "./pages/VeilleDashboard";
import AAPDashboard from "./pages/AAPDashboard";
import Settings from "./pages/Settings";
import UsersAdmin from "./pages/UsersAdmin";
import ImportLogs from "./pages/ImportLogs";
import BoiteAIdees from "@/pages/BoiteAIdees";
import RssFeeds from "@/pages/RssFeeds";
import AzureCallback from "@/pages/AzureCallback";
import { Loader2 } from "lucide-react";
// Chaque page est chargée à la demande : la connexion reste rapide et les écrans
// d'administration lourds ne sont téléchargés que lorsqu'ils sont effectivement ouverts.
const NotFound = lazy(() => import("@/pages/NotFound"));
const Login = lazy(() => import("./pages/Login"));
const VeilleDashboard = lazy(() => import("./pages/VeilleDashboard"));
const AAPDashboard = lazy(() => import("./pages/AAPDashboard"));
const Settings = lazy(() => import("./pages/Settings"));
const UsersAdmin = lazy(() => import("./pages/UsersAdmin"));
const ImportLogs = lazy(() => import("./pages/ImportLogs"));
const BoiteAIdees = lazy(() => import("@/pages/BoiteAIdees"));
const RssFeeds = lazy(() => import("@/pages/RssFeeds"));
const AzureCallback = lazy(() => import("@/pages/AzureCallback"));
// ─── Guard d'authentification ─────────────────────────────────────────────────
function AuthGuard({ children }: { children: React.ReactNode }) {
@@ -150,7 +154,15 @@ function App() {
<LocalAuthProvider>
<TooltipProvider>
<Toaster richColors position="top-right" />
<Suspense
fallback={(
<div className="min-h-screen flex items-center justify-center bg-background">
<Loader2 size={32} className="animate-spin text-primary" />
</div>
)}
>
<Router />
</Suspense>
</TooltipProvider>
</LocalAuthProvider>
</ThemeProvider>

View File

@@ -1,335 +0,0 @@
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { Loader2, Send, User, Sparkles } from "lucide-react";
import { useState, useEffect, useRef } from "react";
import { Streamdown } from "streamdown";
/**
* Message type matching server-side LLM Message interface
*/
export type Message = {
role: "system" | "user" | "assistant";
content: string;
};
export type AIChatBoxProps = {
/**
* Messages array to display in the chat.
* Should match the format used by invokeLLM on the server.
*/
messages: Message[];
/**
* Callback when user sends a message.
* Typically you'll call a tRPC mutation here to invoke the LLM.
*/
onSendMessage: (content: string) => void;
/**
* Whether the AI is currently generating a response
*/
isLoading?: boolean;
/**
* Placeholder text for the input field
*/
placeholder?: string;
/**
* Custom className for the container
*/
className?: string;
/**
* Height of the chat box (default: 600px)
*/
height?: string | number;
/**
* Empty state message to display when no messages
*/
emptyStateMessage?: string;
/**
* Suggested prompts to display in empty state
* Click to send directly
*/
suggestedPrompts?: string[];
};
/**
* A ready-to-use AI chat box component that integrates with the LLM system.
*
* Features:
* - Matches server-side Message interface for seamless integration
* - Markdown rendering with Streamdown
* - Auto-scrolls to latest message
* - Loading states
* - Uses global theme colors from index.css
*
* @example
* ```tsx
* const ChatPage = () => {
* const [messages, setMessages] = useState<Message[]>([
* { role: "system", content: "You are a helpful assistant." }
* ]);
*
* const chatMutation = trpc.ai.chat.useMutation({
* onSuccess: (response) => {
* // Assuming your tRPC endpoint returns the AI response as a string
* setMessages(prev => [...prev, {
* role: "assistant",
* content: response
* }]);
* },
* onError: (error) => {
* console.error("Chat error:", error);
* // Optionally show error message to user
* }
* });
*
* const handleSend = (content: string) => {
* const newMessages = [...messages, { role: "user", content }];
* setMessages(newMessages);
* chatMutation.mutate({ messages: newMessages });
* };
*
* return (
* <AIChatBox
* messages={messages}
* onSendMessage={handleSend}
* isLoading={chatMutation.isPending}
* suggestedPrompts={[
* "Explain quantum computing",
* "Write a hello world in Python"
* ]}
* />
* );
* };
* ```
*/
export function AIChatBox({
messages,
onSendMessage,
isLoading = false,
placeholder = "Type your message...",
className,
height = "600px",
emptyStateMessage = "Start a conversation with AI",
suggestedPrompts,
}: AIChatBoxProps) {
const [input, setInput] = useState("");
const scrollAreaRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const inputAreaRef = useRef<HTMLFormElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Filter out system messages
const displayMessages = messages.filter((msg) => msg.role !== "system");
// Calculate min-height for last assistant message to push user message to top
const [minHeightForLastMessage, setMinHeightForLastMessage] = useState(0);
useEffect(() => {
if (containerRef.current && inputAreaRef.current) {
const containerHeight = containerRef.current.offsetHeight;
const inputHeight = inputAreaRef.current.offsetHeight;
const scrollAreaHeight = containerHeight - inputHeight;
// Reserve space for:
// - padding (p-4 = 32px top+bottom)
// - user message: 40px (item height) + 16px (margin-top from space-y-4) = 56px
// Note: margin-bottom is not counted because it naturally pushes the assistant message down
const userMessageReservedHeight = 56;
const calculatedHeight = scrollAreaHeight - 32 - userMessageReservedHeight;
setMinHeightForLastMessage(Math.max(0, calculatedHeight));
}
}, []);
// Scroll to bottom helper function with smooth animation
const scrollToBottom = () => {
const viewport = scrollAreaRef.current?.querySelector(
'[data-radix-scroll-area-viewport]'
) as HTMLDivElement;
if (viewport) {
requestAnimationFrame(() => {
viewport.scrollTo({
top: viewport.scrollHeight,
behavior: 'smooth'
});
});
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmedInput = input.trim();
if (!trimmedInput || isLoading) return;
onSendMessage(trimmedInput);
setInput("");
// Scroll immediately after sending
scrollToBottom();
// Keep focus on input
textareaRef.current?.focus();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
};
return (
<div
ref={containerRef}
className={cn(
"flex flex-col bg-card text-card-foreground rounded-lg border shadow-sm",
className
)}
style={{ height }}
>
{/* Messages Area */}
<div ref={scrollAreaRef} className="flex-1 overflow-hidden">
{displayMessages.length === 0 ? (
<div className="flex h-full flex-col p-4">
<div className="flex flex-1 flex-col items-center justify-center gap-6 text-muted-foreground">
<div className="flex flex-col items-center gap-3">
<Sparkles className="size-12 opacity-20" />
<p className="text-sm">{emptyStateMessage}</p>
</div>
{suggestedPrompts && suggestedPrompts.length > 0 && (
<div className="flex max-w-2xl flex-wrap justify-center gap-2">
{suggestedPrompts.map((prompt, index) => (
<button
key={index}
onClick={() => onSendMessage(prompt)}
disabled={isLoading}
className="rounded-lg border border-border bg-card px-4 py-2 text-sm transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
>
{prompt}
</button>
))}
</div>
)}
</div>
</div>
) : (
<ScrollArea className="h-full">
<div className="flex flex-col space-y-4 p-4">
{displayMessages.map((message, index) => {
// Apply min-height to last message only if NOT loading (when loading, the loading indicator gets it)
const isLastMessage = index === displayMessages.length - 1;
const shouldApplyMinHeight =
isLastMessage && !isLoading && minHeightForLastMessage > 0;
return (
<div
key={index}
className={cn(
"flex gap-3",
message.role === "user"
? "justify-end items-start"
: "justify-start items-start"
)}
style={
shouldApplyMinHeight
? { minHeight: `${minHeightForLastMessage}px` }
: undefined
}
>
{message.role === "assistant" && (
<div className="size-8 shrink-0 mt-1 rounded-full bg-primary/10 flex items-center justify-center">
<Sparkles className="size-4 text-primary" />
</div>
)}
<div
className={cn(
"max-w-[80%] rounded-lg px-4 py-2.5",
message.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted text-foreground"
)}
>
{message.role === "assistant" ? (
<div className="prose prose-sm dark:prose-invert max-w-none">
<Streamdown>{message.content}</Streamdown>
</div>
) : (
<p className="whitespace-pre-wrap text-sm">
{message.content}
</p>
)}
</div>
{message.role === "user" && (
<div className="size-8 shrink-0 mt-1 rounded-full bg-secondary flex items-center justify-center">
<User className="size-4 text-secondary-foreground" />
</div>
)}
</div>
);
})}
{isLoading && (
<div
className="flex items-start gap-3"
style={
minHeightForLastMessage > 0
? { minHeight: `${minHeightForLastMessage}px` }
: undefined
}
>
<div className="size-8 shrink-0 mt-1 rounded-full bg-primary/10 flex items-center justify-center">
<Sparkles className="size-4 text-primary" />
</div>
<div className="rounded-lg bg-muted px-4 py-2.5">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
</div>
)}
</div>
</ScrollArea>
)}
</div>
{/* Input Area */}
<form
ref={inputAreaRef}
onSubmit={handleSubmit}
className="flex gap-2 p-4 border-t bg-background/50 items-end"
>
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="flex-1 max-h-32 resize-none min-h-9"
rows={1}
/>
<Button
type="submit"
size="icon"
disabled={!input.trim() || isLoading}
className="shrink-0 h-[38px] w-[38px]"
>
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</Button>
</form>
</div>
);
}

View File

@@ -1,264 +0,0 @@
import { useAuth } from "@/_core/hooks/useAuth";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar";
import { getLoginUrl } from "@/const";
import { useIsMobile } from "@/hooks/useMobile";
import { LayoutDashboard, LogOut, PanelLeft, Users } from "lucide-react";
import { CSSProperties, useEffect, useRef, useState } from "react";
import { useLocation } from "wouter";
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
import { Button } from "./ui/button";
const menuItems = [
{ icon: LayoutDashboard, label: "Page 1", path: "/" },
{ icon: Users, label: "Page 2", path: "/some-path" },
];
const SIDEBAR_WIDTH_KEY = "sidebar-width";
const DEFAULT_WIDTH = 280;
const MIN_WIDTH = 200;
const MAX_WIDTH = 480;
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const [sidebarWidth, setSidebarWidth] = useState(() => {
const saved = localStorage.getItem(SIDEBAR_WIDTH_KEY);
return saved ? parseInt(saved, 10) : DEFAULT_WIDTH;
});
const { loading, user } = useAuth();
useEffect(() => {
localStorage.setItem(SIDEBAR_WIDTH_KEY, sidebarWidth.toString());
}, [sidebarWidth]);
if (loading) {
return <DashboardLayoutSkeleton />
}
if (!user) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="flex flex-col items-center gap-8 p-8 max-w-md w-full">
<div className="flex flex-col items-center gap-6">
<h1 className="text-2xl font-semibold tracking-tight text-center">
Sign in to continue
</h1>
<p className="text-sm text-muted-foreground text-center max-w-sm">
Access to this dashboard requires authentication. Continue to launch the login flow.
</p>
</div>
<Button
onClick={() => {
window.location.href = getLoginUrl();
}}
size="lg"
className="w-full shadow-lg hover:shadow-xl transition-all"
>
Sign in
</Button>
</div>
</div>
);
}
return (
<SidebarProvider
style={
{
"--sidebar-width": `${sidebarWidth}px`,
} as CSSProperties
}
>
<DashboardLayoutContent setSidebarWidth={setSidebarWidth}>
{children}
</DashboardLayoutContent>
</SidebarProvider>
);
}
type DashboardLayoutContentProps = {
children: React.ReactNode;
setSidebarWidth: (width: number) => void;
};
function DashboardLayoutContent({
children,
setSidebarWidth,
}: DashboardLayoutContentProps) {
const { user, logout } = useAuth();
const [location, setLocation] = useLocation();
const { state, toggleSidebar } = useSidebar();
const isCollapsed = state === "collapsed";
const [isResizing, setIsResizing] = useState(false);
const sidebarRef = useRef<HTMLDivElement>(null);
const activeMenuItem = menuItems.find(item => item.path === location);
const isMobile = useIsMobile();
useEffect(() => {
if (isCollapsed) {
setIsResizing(false);
}
}, [isCollapsed]);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isResizing) return;
const sidebarLeft = sidebarRef.current?.getBoundingClientRect().left ?? 0;
const newWidth = e.clientX - sidebarLeft;
if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
setSidebarWidth(newWidth);
}
};
const handleMouseUp = () => {
setIsResizing(false);
};
if (isResizing) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
}
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
}, [isResizing, setSidebarWidth]);
return (
<>
<div className="relative" ref={sidebarRef}>
<Sidebar
collapsible="icon"
className="border-r-0"
disableTransition={isResizing}
>
<SidebarHeader className="h-16 justify-center">
<div className="flex items-center gap-3 px-2 transition-all w-full">
<button
onClick={toggleSidebar}
className="h-8 w-8 flex items-center justify-center hover:bg-accent rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring shrink-0"
aria-label="Toggle navigation"
>
<PanelLeft className="h-4 w-4 text-muted-foreground" />
</button>
{!isCollapsed ? (
<div className="flex items-center gap-2 min-w-0">
<span className="font-semibold tracking-tight truncate">
Navigation
</span>
</div>
) : null}
</div>
</SidebarHeader>
<SidebarContent className="gap-0">
<SidebarMenu className="px-2 py-1">
{menuItems.map(item => {
const isActive = location === item.path;
return (
<SidebarMenuItem key={item.path}>
<SidebarMenuButton
isActive={isActive}
onClick={() => setLocation(item.path)}
tooltip={item.label}
className={`h-10 transition-all font-normal`}
>
<item.icon
className={`h-4 w-4 ${isActive ? "text-primary" : ""}`}
/>
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarContent>
<SidebarFooter className="p-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-3 rounded-lg px-1 py-1 hover:bg-accent/50 transition-colors w-full text-left group-data-[collapsible=icon]:justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Avatar className="h-9 w-9 border shrink-0">
<AvatarFallback className="text-xs font-medium">
{user?.name?.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 group-data-[collapsible=icon]:hidden">
<p className="text-sm font-medium truncate leading-none">
{user?.name || "-"}
</p>
<p className="text-xs text-muted-foreground truncate mt-1.5">
{user?.email || "-"}
</p>
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={logout}
className="cursor-pointer text-destructive focus:text-destructive"
>
<LogOut className="mr-2 h-4 w-4" />
<span>Sign out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarFooter>
</Sidebar>
<div
className={`absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-primary/20 transition-colors ${isCollapsed ? "hidden" : ""}`}
onMouseDown={() => {
if (isCollapsed) return;
setIsResizing(true);
}}
style={{ zIndex: 50 }}
/>
</div>
<SidebarInset>
{isMobile && (
<div className="flex border-b h-14 items-center justify-between bg-background/95 px-2 backdrop-blur supports-[backdrop-filter]:backdrop-blur sticky top-0 z-40">
<div className="flex items-center gap-2">
<SidebarTrigger className="h-9 w-9 rounded-lg bg-background" />
<div className="flex items-center gap-3">
<div className="flex flex-col gap-1">
<span className="tracking-tight text-foreground">
{activeMenuItem?.label ?? "Menu"}
</span>
</div>
</div>
</div>
</div>
)}
<main className="flex-1 p-4">{children}</main>
</SidebarInset>
</>
);
}

View File

@@ -1,46 +0,0 @@
import { Skeleton } from './ui/skeleton';
export function DashboardLayoutSkeleton() {
return (
<div className="flex min-h-screen bg-background">
{/* Sidebar skeleton */}
<div className="w-[280px] border-r border-border bg-background p-4 space-y-6">
{/* Logo area */}
<div className="flex items-center gap-3 px-2">
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-4 w-24" />
</div>
{/* Menu items */}
<div className="space-y-2 px-2">
<Skeleton className="h-10 w-full rounded-lg" />
<Skeleton className="h-10 w-full rounded-lg" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
{/* User profile area at bottom */}
<div className="absolute bottom-4 left-4 right-4">
<div className="flex items-center gap-3 px-1">
<Skeleton className="h-9 w-9 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-2 w-32" />
</div>
</div>
</div>
</div>
{/* Main content skeleton */}
<div className="flex-1 p-4 space-y-4">
{/* Content blocks */}
<Skeleton className="h-12 w-48 rounded-lg" />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Skeleton className="h-32 rounded-xl" />
<Skeleton className="h-32 rounded-xl" />
<Skeleton className="h-32 rounded-xl" />
</div>
<Skeleton className="h-64 rounded-xl" />
</div>
</div>
);
}

View File

@@ -1,155 +0,0 @@
/**
* GOOGLE MAPS FRONTEND INTEGRATION - ESSENTIAL GUIDE
*
* USAGE FROM PARENT COMPONENT:
* ======
*
* const mapRef = useRef<google.maps.Map | null>(null);
*
* <MapView
* initialCenter={{ lat: 40.7128, lng: -74.0060 }}
* initialZoom={15}
* onMapReady={(map) => {
* mapRef.current = map; // Store to control map from parent anytime, google map itself is in charge of the re-rendering, not react state.
* </MapView>
*
* ======
* Available Libraries and Core Features:
* -------------------------------
* 📍 MARKER (from `marker` library)
* - Attaches to map using { map, position }
* new google.maps.marker.AdvancedMarkerElement({
* map,
* position: { lat: 37.7749, lng: -122.4194 },
* title: "San Francisco",
* });
*
* -------------------------------
* 🏢 PLACES (from `places` library)
* - Does not attach directly to map; use data with your map manually.
* const place = new google.maps.places.Place({ id: PLACE_ID });
* await place.fetchFields({ fields: ["displayName", "location"] });
* map.setCenter(place.location);
* new google.maps.marker.AdvancedMarkerElement({ map, position: place.location });
*
* -------------------------------
* 🧭 GEOCODER (from `geocoding` library)
* - Standalone service; manually apply results to map.
* const geocoder = new google.maps.Geocoder();
* geocoder.geocode({ address: "New York" }, (results, status) => {
* if (status === "OK" && results[0]) {
* map.setCenter(results[0].geometry.location);
* new google.maps.marker.AdvancedMarkerElement({
* map,
* position: results[0].geometry.location,
* });
* }
* });
*
* -------------------------------
* 📐 GEOMETRY (from `geometry` library)
* - Pure utility functions; not attached to map.
* const dist = google.maps.geometry.spherical.computeDistanceBetween(p1, p2);
*
* -------------------------------
* 🛣️ ROUTES (from `routes` library)
* - Combines DirectionsService (standalone) + DirectionsRenderer (map-attached)
* const directionsService = new google.maps.DirectionsService();
* const directionsRenderer = new google.maps.DirectionsRenderer({ map });
* directionsService.route(
* { origin, destination, travelMode: "DRIVING" },
* (res, status) => status === "OK" && directionsRenderer.setDirections(res)
* );
*
* -------------------------------
* 🌦️ MAP LAYERS (attach directly to map)
* - new google.maps.TrafficLayer().setMap(map);
* - new google.maps.TransitLayer().setMap(map);
* - new google.maps.BicyclingLayer().setMap(map);
*
* -------------------------------
* ✅ SUMMARY
* - “map-attached” → AdvancedMarkerElement, DirectionsRenderer, Layers.
* - “standalone” → Geocoder, DirectionsService, DistanceMatrixService, ElevationService.
* - “data-only” → Place, Geometry utilities.
*/
/// <reference types="@types/google.maps" />
import { useEffect, useRef } from "react";
import { usePersistFn } from "@/hooks/usePersistFn";
import { cn } from "@/lib/utils";
declare global {
interface Window {
google?: typeof google;
}
}
const API_KEY = import.meta.env.VITE_FRONTEND_FORGE_API_KEY;
const FORGE_BASE_URL =
import.meta.env.VITE_FRONTEND_FORGE_API_URL ||
"https://forge.butterfly-effect.dev";
const MAPS_PROXY_URL = `${FORGE_BASE_URL}/v1/maps/proxy`;
function loadMapScript() {
return new Promise(resolve => {
const script = document.createElement("script");
script.src = `${MAPS_PROXY_URL}/maps/api/js?key=${API_KEY}&v=weekly&libraries=marker,places,geocoding,geometry`;
script.async = true;
script.crossOrigin = "anonymous";
script.onload = () => {
resolve(null);
script.remove(); // Clean up immediately
};
script.onerror = () => {
console.error("Failed to load Google Maps script");
};
document.head.appendChild(script);
});
}
interface MapViewProps {
className?: string;
initialCenter?: google.maps.LatLngLiteral;
initialZoom?: number;
onMapReady?: (map: google.maps.Map) => void;
}
export function MapView({
className,
initialCenter = { lat: 37.7749, lng: -122.4194 },
initialZoom = 12,
onMapReady,
}: MapViewProps) {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<google.maps.Map | null>(null);
const init = usePersistFn(async () => {
await loadMapScript();
if (!mapContainer.current) {
console.error("Map container not found");
return;
}
map.current = new window.google.maps.Map(mapContainer.current, {
zoom: initialZoom,
center: initialCenter,
mapTypeControl: true,
fullscreenControl: true,
zoomControl: true,
streetViewControl: true,
mapId: "DEMO_MAP_ID",
});
if (onMapReady) {
onMapReady(map.current);
}
});
useEffect(() => {
init();
}, [init]);
return (
<div ref={mapContainer} className={cn("w-full h-[500px]", className)} />
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
-- Conserver la première lecture de chaque article avant d'imposer l'unicité.
DELETE duplicate_read
FROM `article_reads` AS duplicate_read
INNER JOIN `article_reads` AS retained_read
ON duplicate_read.`userId` = retained_read.`userId`
AND duplicate_read.`articleType` = retained_read.`articleType`
AND duplicate_read.`articleId` = retained_read.`articleId`
AND duplicate_read.`id` > retained_read.`id`;
--> statement-breakpoint
ALTER TABLE `article_reads` ADD CONSTRAINT `article_reads_user_type_article_unique` UNIQUE(`userId`,`articleType`,`articleId`);

File diff suppressed because it is too large Load Diff

View File

@@ -85,6 +85,13 @@
"when": 1783432258494,
"tag": "0011_sharp_gambit",
"breakpoints": true
},
{
"idx": 12,
"version": "5",
"when": 1786997741455,
"tag": "0012_fluffy_boomer",
"breakpoints": true
}
]
}

View File

@@ -5,6 +5,7 @@ import {
mysqlTable,
text,
timestamp,
uniqueIndex,
varchar,
json,
} from "drizzle-orm/mysql-core";
@@ -204,13 +205,20 @@ export type InsertRssSettings = typeof rssSettings.$inferInsert;
// ─── Suivi de lecture des articles ──────────────────────────────────────────
export const articleReads = mysqlTable("article_reads", {
export const articleReads = mysqlTable(
"article_reads",
{
id: int("id").autoincrement().primaryKey(),
userId: int("userId").notNull(),
articleType: mysqlEnum("articleType", ["veille", "aap"]).notNull(),
articleId: int("articleId").notNull(),
readAt: timestamp("readAt").defaultNow().notNull(),
});
},
(table) => [
// Une lecture est une relation unique entre un utilisateur et un article.
uniqueIndex("article_reads_user_type_article_unique").on(table.userId, table.articleType, table.articleId),
],
);
export type ArticleRead = typeof articleReads.$inferSelect;
export type InsertArticleRead = typeof articleReads.$inferInsert;

View File

@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from "vitest";
import { isDuplicateEntryError, persistArticleReads, readArticleIdsFromDb } from "./db";
function createReadDb(existingIds: number[] = []) {
const insertValues = vi.fn().mockResolvedValue(undefined);
const insert = vi.fn(() => ({ values: insertValues }));
const where = vi.fn().mockResolvedValue(existingIds.map((articleId) => ({ articleId })));
const from = vi.fn(() => ({ where }));
const select = vi.fn(() => ({ from }));
return { db: { select, insert }, insertValues };
}
describe("isDuplicateEntryError", () => {
it("identifie les erreurs de contrainte unique MySQL", () => {
expect(isDuplicateEntryError({ code: "ER_DUP_ENTRY" })).toBe(true);
expect(isDuplicateEntryError({ cause: { code: "ER_DUP_ENTRY" } })).toBe(true);
});
it("ne masque jamais une erreur SQL non liée à un doublon", () => {
expect(isDuplicateEntryError(new Error("Field 'readAt' doesn't have a default value"))).toBe(false);
expect(isDuplicateEntryError({ code: "ER_NO_DEFAULT_FOR_FIELD" })).toBe(false);
expect(isDuplicateEntryError(null)).toBe(false);
});
});
describe("persistance des articles lus", () => {
it("insère uniquement les articles encore non lus et déduplique la demande", async () => {
const { db, insertValues } = createReadDb([10]);
const inserted = await persistArticleReads(db, 2, "veille", [10, 12, 12, 13]);
expect(inserted).toBe(2);
expect(insertValues).toHaveBeenCalledWith([
{ userId: 2, articleType: "veille", articleId: 12 },
{ userId: 2, articleType: "veille", articleId: 13 },
]);
});
it("restaure les identifiants lus stockés pour le bon utilisateur et le bon flux", async () => {
const { db } = createReadDb([4, 9]);
await expect(readArticleIdsFromDb(db, 2, "aap")).resolves.toEqual([4, 9]);
});
});

View File

@@ -1,4 +1,4 @@
import { eq, desc, and, like, gte, lte, or, sql } from "drizzle-orm";
import { count, desc, and, eq, inArray, like, gte, lte, or, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
import {
@@ -15,6 +15,7 @@ import {
rssFeeds,
rssSettings,
processedDedupKeys,
articleReads,
type InsertRssFeed,
type InsertRssSettings,
type ImportLog,
@@ -44,6 +45,114 @@ export async function getDb() {
return _db;
}
// ─── Lectures d'articles ────────────────────────────────────────────────────
/** Les deux collections suivies par le marquage lu/non lu. */
export type ArticleReadType = "veille" | "aap";
/**
* Un doublon est attendu lorsqu'un utilisateur rouvre rapidement le même article.
* Toute autre erreur SQL doit rester visible afin de ne jamais perdre une lecture
* silencieusement, comme cela s'était produit avec une colonne readAt invalide.
*/
export function isDuplicateEntryError(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const databaseError = error as { code?: unknown; cause?: unknown };
const cause = databaseError.cause as { code?: unknown } | undefined;
return databaseError.code === "ER_DUP_ENTRY" || cause?.code === "ER_DUP_ENTRY";
}
/** Supprime les marqueurs de lecture devenus orphelins après suppression d'articles. */
export async function removeArticleReadRecords(articleType: ArticleReadType, articleIds: number[]): Promise<void> {
const uniqueIds = Array.from(new Set(articleIds));
if (uniqueIds.length === 0) return;
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(articleReads).where(and(
eq(articleReads.articleType, articleType),
inArray(articleReads.articleId, uniqueIds),
));
}
/**
* Insère les lectures encore absentes pour un utilisateur.
* L'unicité (userId, articleType, articleId) est garantie par le schéma SQL ; le
* pré-filtrage évite néanmoins une écriture inutile sur chaque ouverture de détail.
*/
export async function markArticlesAsRead(
userId: number,
articleType: ArticleReadType,
articleIds: number[],
): Promise<number> {
const db = await getDb();
if (!db) throw new Error("Database not available");
return persistArticleReads(db, userId, articleType, articleIds);
}
/**
* Cœur testable de l'écriture des lectures. Le routeur ne lui transmet qu'une
* connexion Drizzle déjà ouverte ; aucune règle métier ne dépend de l'interface HTTP.
*/
export async function persistArticleReads(
db: any,
userId: number,
articleType: ArticleReadType,
articleIds: number[],
): Promise<number> {
const uniqueIds = Array.from(new Set(articleIds));
if (uniqueIds.length === 0) return 0;
const existingReads = await db
.select({ articleId: articleReads.articleId })
.from(articleReads)
.where(and(
eq(articleReads.userId, userId),
eq(articleReads.articleType, articleType),
inArray(articleReads.articleId, uniqueIds),
));
const existingIds = new Set(existingReads.map((read: { articleId: number }) => read.articleId));
const unreadIds = uniqueIds.filter((articleId) => !existingIds.has(articleId));
if (unreadIds.length === 0) return 0;
try {
await db.insert(articleReads).values(unreadIds.map((articleId) => ({ userId, articleType, articleId })));
return unreadIds.length;
} catch (error) {
if (isDuplicateEntryError(error)) return 0;
throw error;
}
}
/** Retourne les identifiants lus, utilisés pour restaurer l'état utilisateur après connexion. */
export async function getReadArticleIds(userId: number, articleType: ArticleReadType): Promise<number[]> {
const db = await getDb();
if (!db) throw new Error("Database not available");
return readArticleIdsFromDb(db, userId, articleType);
}
/** Cœur testable de la restitution de l'état lu/non lu après reconnexion. */
export async function readArticleIdsFromDb(db: any, userId: number, articleType: ArticleReadType): Promise<number[]> {
const rows = await db
.select({ articleId: articleReads.articleId })
.from(articleReads)
.where(and(eq(articleReads.userId, userId), eq(articleReads.articleType, articleType)));
return rows.map((read: { articleId: number }) => read.articleId);
}
/** Calcule le nombre d'articles non lus à partir d'un total métier fourni par le routeur. */
export async function getUnreadArticleCount(userId: number, articleType: ArticleReadType, totalItems: number): Promise<number> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const rows = await db
.select({ total: count() })
.from(articleReads)
.where(and(eq(articleReads.userId, userId), eq(articleReads.articleType, articleType)));
return Math.max(0, totalItems - (rows[0]?.total ?? 0));
}
// ─── Users (Manus OAuth) ─────────────────────────────────────────────────────
export async function upsertUser(user: InsertUser): Promise<void> {
@@ -495,6 +604,13 @@ export async function purgeOldArticles(retentionMonths: number): Promise<{ veill
if (!db) throw new Error("Database not available");
const cutoff = new Date();
cutoff.setMonth(cutoff.getMonth() - retentionMonths);
// Les marqueurs de lecture ne doivent jamais survivre à l'article auquel ils se rapportent.
const oldVeilleItems = await db.select({ id: veilleItems.id }).from(veilleItems).where(lte(veilleItems.importedAt, cutoff));
const oldAapItems = await db.select({ id: aapItems.id }).from(aapItems).where(lte(aapItems.importedAt, cutoff));
await removeArticleReadRecords("veille", oldVeilleItems.map((item: { id: number }) => item.id));
await removeArticleReadRecords("aap", oldAapItems.map((item: { id: number }) => item.id));
const veilleResult = await db.delete(veilleItems).where(lte(veilleItems.importedAt, cutoff));
const aapResult = await db.delete(aapItems).where(lte(aapItems.importedAt, cutoff));
// Purge des tombstones (processed_dedup_keys) de plus de 6 mois
@@ -511,6 +627,7 @@ export async function purgeOldArticles(retentionMonths: number): Promise<{ veill
export async function purgeVeilleItems(): Promise<number> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(articleReads).where(eq(articleReads.articleType, "veille"));
const result = await db.delete(veilleItems);
return (result as any).affectedRows ?? 0;
}
@@ -518,6 +635,7 @@ export async function purgeVeilleItems(): Promise<number> {
export async function purgeAapItems(): Promise<number> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(articleReads).where(eq(articleReads.articleType, "aap"));
const result = await db.delete(aapItems);
return (result as any).affectedRows ?? 0;
}

View File

@@ -31,15 +31,19 @@ import {
deleteRssFeed,
getRssSettings,
saveRssSettings,
getDb,
getReadArticleIds,
getUnreadArticleCount,
markArticlesAsRead,
removeArticleReadRecords,
} from "./db";
import { importVeille, importAAP, runFullImport, getImportConfig } from "./importer";
import { scheduleDailyImport } from "./_core/index";
import { loginLocalUser, hashPassword, ensureAdminExists } from "./localAuth";
import { isAzureAdConfigured, getAzureAuthUrl } from "./azureAuth";
import { classifyArticle } from "./aiClassifier";
import { getDb } from "./db";
import { veilleItems, aapItems, articleReads, processedDedupKeys } from "../drizzle/schema";
import { isNull, or, eq as eqDrizzle, and, inArray, count } from "drizzle-orm";
import { classifyAap, classifyArticle } from "./aiClassifier";
import { veilleItems, aapItems, processedDedupKeys } from "../drizzle/schema";
import { isNull, or, eq as eqDrizzle } from "drizzle-orm";
// ─── Middleware admin ─────────────────────────────────────────────────────────
@@ -148,6 +152,7 @@ export const appRouter = router({
);
if (!aiResult.relevant) {
// Supprimer l'article non pertinent
await removeArticleReadRecords("veille", [row.id]);
await db.delete(veilleItems).where(eqDrizzle(veilleItems.id, row.id));
// Conserver le tombstone pour éviter la réinsertion
if (row.dedupKey) {
@@ -176,64 +181,31 @@ export const appRouter = router({
return { processed, deleted, errors, total: rows.length };
}),
// ─── Marquage lu/non lu ──────────────────────────────────────────────────────────────────────
// ─── Marquage lu/non lu ─────────────────────────────────────────────────
markAsRead: protectedProcedure
.input(z.object({ articleId: z.number().int().positive() }))
.mutation(async ({ input, ctx }) => {
const db = await getDb();
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
// Insérer seulement si pas déjà lu (ignore le doublon)
try {
await db.insert(articleReads).values({
userId: ctx.user.id,
articleType: "veille",
articleId: input.articleId,
});
} catch { /* doublon = déjà lu, on ignore */ }
return { success: true };
const marked = await markArticlesAsRead(ctx.user.id, "veille", [input.articleId]);
return { success: true, marked };
}),
markAllAsRead: protectedProcedure.mutation(async ({ ctx }) => {
const db = await getDb();
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
// Récupérer tous les IDs veille
// Les IDs sont lus à l'instant de la mutation : aucun élément filtré ne peut être marqué par erreur.
const allItems = await db.select({ id: veilleItems.id }).from(veilleItems);
const allIds = allItems.map((r: { id: number }) => r.id);
// Trouver ceux déjà lus
const alreadyRead = await db
.select({ articleId: articleReads.articleId })
.from(articleReads)
.where(and(eqDrizzle(articleReads.userId, ctx.user.id), eqDrizzle(articleReads.articleType, "veille")));
const alreadyReadIds = new Set(alreadyRead.map((r: { articleId: number }) => r.articleId));
const toInsert = allIds.filter((id: number) => !alreadyReadIds.has(id)).map((id: number) => ({
userId: ctx.user.id, articleType: "veille" as const, articleId: id,
}));
if (toInsert.length > 0) {
await db.insert(articleReads).values(toInsert);
}
return { success: true, marked: toInsert.length };
const marked = await markArticlesAsRead(ctx.user.id, "veille", allItems.map((item: { id: number }) => item.id));
return { success: true, marked };
}),
unreadCount: protectedProcedure.query(async ({ ctx }) => {
const db = await getDb();
if (!db) return { count: 0 };
const totalRows = await db.select({ cnt: count() }).from(veilleItems);
const total = totalRows[0]?.cnt ?? 0;
const readRows = await db
.select({ cnt: count() })
.from(articleReads)
.where(and(eqDrizzle(articleReads.userId, ctx.user.id), eqDrizzle(articleReads.articleType, "veille")));
const read = readRows[0]?.cnt ?? 0;
return { count: Math.max(0, total - read) };
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
const totalRows = await db.select({ id: veilleItems.id }).from(veilleItems);
return { count: await getUnreadArticleCount(ctx.user.id, "veille", totalRows.length) };
}),
getReadIds: protectedProcedure.query(async ({ ctx }) => {
const db = await getDb();
if (!db) return { ids: [] as number[] };
const rows = await db
.select({ articleId: articleReads.articleId })
.from(articleReads)
.where(and(eqDrizzle(articleReads.userId, ctx.user.id), eqDrizzle(articleReads.articleType, "veille")));
return { ids: rows.map((r: { articleId: number }) => r.articleId) };
return { ids: await getReadArticleIds(ctx.user.id, "veille") };
}),
}),
// ─── AAPP ────────────────────────────────────────────────────────────────────
@@ -283,12 +255,13 @@ export const appRouter = router({
for (const row of rows) {
try {
const aiResult = await classifyArticle(
const aiResult = await classifyAap(
row.titre || "",
row.resume || "",
() => ({ typeVeille: "informationnelle" as const })
row.categorie
);
if (!aiResult.relevant) {
await removeArticleReadRecords("aap", [row.id]);
await db.delete(aapItems).where(eqDrizzle(aapItems.id, row.id));
if (row.dedupKey) {
await db.insert(processedDedupKeys)
@@ -300,7 +273,9 @@ export const appRouter = router({
await db
.update(aapItems)
.set({
categorie: aiResult.categorieAap ?? row.categorie,
iaRelevant: aiResult.relevant,
iaCategorie: aiResult.categorieAap,
iaClassifiedBy: aiResult.classifiedBy,
iaReason: aiResult.reason,
})
@@ -315,61 +290,30 @@ export const appRouter = router({
return { processed, deleted, errors, total: rows.length };
}),
// ─── Marquage lu/non lu AAP ──────────────────────────────────────────────────────────────────────
// ─── Marquage lu/non lu AAP ───────────────────────────────────────────────
markAsRead: protectedProcedure
.input(z.object({ articleId: z.number().int().positive() }))
.mutation(async ({ input, ctx }) => {
const db = await getDb();
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
try {
await db.insert(articleReads).values({
userId: ctx.user.id,
articleType: "aap",
articleId: input.articleId,
});
} catch { /* doublon = déjà lu, on ignore */ }
return { success: true };
const marked = await markArticlesAsRead(ctx.user.id, "aap", [input.articleId]);
return { success: true, marked };
}),
markAllAsRead: protectedProcedure.mutation(async ({ ctx }) => {
const db = await getDb();
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
const allItems = await db.select({ id: aapItems.id }).from(aapItems);
const allIds = allItems.map((r: { id: number }) => r.id);
const alreadyRead = await db
.select({ articleId: articleReads.articleId })
.from(articleReads)
.where(and(eqDrizzle(articleReads.userId, ctx.user.id), eqDrizzle(articleReads.articleType, "aap")));
const alreadyReadIds = new Set(alreadyRead.map((r: { articleId: number }) => r.articleId));
const toInsert = allIds.filter((id: number) => !alreadyReadIds.has(id)).map((id: number) => ({
userId: ctx.user.id, articleType: "aap" as const, articleId: id,
}));
if (toInsert.length > 0) {
await db.insert(articleReads).values(toInsert);
}
return { success: true, marked: toInsert.length };
const marked = await markArticlesAsRead(ctx.user.id, "aap", allItems.map((item: { id: number }) => item.id));
return { success: true, marked };
}),
unreadCount: protectedProcedure.query(async ({ ctx }) => {
const db = await getDb();
if (!db) return { count: 0 };
const totalRows = await db.select({ cnt: count() }).from(aapItems);
const total = totalRows[0]?.cnt ?? 0;
const readRows = await db
.select({ cnt: count() })
.from(articleReads)
.where(and(eqDrizzle(articleReads.userId, ctx.user.id), eqDrizzle(articleReads.articleType, "aap")));
const read = readRows[0]?.cnt ?? 0;
return { count: Math.max(0, total - read) };
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
const totalRows = await db.select({ id: aapItems.id }).from(aapItems);
return { count: await getUnreadArticleCount(ctx.user.id, "aap", totalRows.length) };
}),
getReadIds: protectedProcedure.query(async ({ ctx }) => {
const db = await getDb();
if (!db) return { ids: [] as number[] };
const rows = await db
.select({ articleId: articleReads.articleId })
.from(articleReads)
.where(and(eqDrizzle(articleReads.userId, ctx.user.id), eqDrizzle(articleReads.articleType, "aap")));
return { ids: rows.map((r: { articleId: number }) => r.articleId) };
return { ids: await getReadArticleIds(ctx.user.id, "aap") };
}),
}),
// ─── Import ─────────────────────────────────────────────────────────────────

View File

@@ -14,7 +14,7 @@
*/
import { XMLParser } from "fast-xml-parser";
import * as crypto from "crypto";
import { getDb } from "./db";
import { getDb, removeArticleReadRecords } from "./db";
import {
rssFeeds,
veilleItems,
@@ -775,6 +775,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
} catch (e: any) {
// Si le nouveau dedupKey existe déjà → cet article est un doublon, le supprimer
if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.cause?.message?.includes("Duplicate entry")) {
await removeArticleReadRecords("veille", [row.id]);
await db.delete(veilleItems).where(eq(veilleItems.id, row.id));
veilleMerged++;
} else {
@@ -817,6 +818,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
// Supprimer les doublons
for (const dup of duplicates) {
await removeArticleReadRecords("veille", [dup.id]);
await db.delete(veilleItems).where(eq(veilleItems.id, dup.id));
veilleMerged++;
}
@@ -825,6 +827,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
// Si le newDedupKey existe déjà → supprimer tout le groupe
if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.cause?.message?.includes("Duplicate entry")) {
for (const row of sorted) {
await removeArticleReadRecords("veille", [row.id]);
await db.delete(veilleItems).where(eq(veilleItems.id, row.id));
veilleMerged++;
}
@@ -868,6 +871,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
} catch (e: any) {
// Si le nouveau dedupKey existe déjà → cet article est un doublon, le supprimer
if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.cause?.message?.includes("Duplicate entry")) {
await removeArticleReadRecords("aap", [row.id]);
await db.delete(aapItems).where(eq(aapItems.id, row.id));
aapMerged++;
} else {
@@ -903,6 +907,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
.where(eq(aapItems.id, primary.id));
for (const dup of duplicates) {
await removeArticleReadRecords("aap", [dup.id]);
await db.delete(aapItems).where(eq(aapItems.id, dup.id));
aapMerged++;
}
@@ -911,6 +916,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
// Si le newDedupKey existe déjà → supprimer tout le groupe
if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.cause?.message?.includes("Duplicate entry")) {
for (const row of sorted) {
await removeArticleReadRecords("aap", [row.id]);
await db.delete(aapItems).where(eq(aapItems.id, row.id));
aapMerged++;
}

View File

@@ -189,3 +189,11 @@
- [x] Implémenter la purge automatique au démarrage du serveur selon la règle de rétention
- [x] Déployer en recette
- [x] Répliquer BDD recette vers production et déployer le code en production
## Audit de robustesse et nettoyage technique
- [x] Remplacer les captures derreur silencieuses sur le marquage lu par une gestion explicite des doublons et des erreurs SQL.
- [x] Centraliser le comportement du marquage lu pour les flux Veille et AAP afin déviter les divergences fonctionnelles.
- [x] Ajouter des commentaires techniques sur les invariants métier sensibles (lectures, classification IA et déduplication RSS).
- [x] Ajouter des tests unitaires couvrant la persistance et la restitution des articles lus.
- [x] Supprimer les composants non référencés et les artefacts de développement du dépôt applicatif.
- [x] Vérifier TypeScript, Vitest et le build de production après refactoring.

View File

@@ -167,6 +167,19 @@ export default defineConfig({
build: {
outDir: path.resolve(import.meta.dirname, "dist/public"),
emptyOutDir: true,
// Conserver un socle initial léger : les bibliothèques d'interface et de données
// sont séparées pour être mises en cache indépendamment des écrans métier.
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes("node_modules")) return undefined;
if (id.includes("/@trpc/") || id.includes("/@tanstack/")) return "vendor-data";
if (id.includes("/react/") || id.includes("/react-dom/") || id.includes("/scheduler/")) return "vendor-react";
if (id.includes("/@radix-ui/") || id.includes("/lucide-react/")) return "vendor-ui";
return "vendor";
},
},
},
},
server: {
host: true,