Compare commits
25 Commits
465e33c3b8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
290e3709e2 | ||
|
|
fcafbf1965 | ||
|
|
1821937106 | ||
|
|
720d42cf71 | ||
|
|
9acc52da7d | ||
|
|
6f201dd498 | ||
|
|
e4a389cc67 | ||
|
|
1344d3c060 | ||
|
|
727b7c6436 | ||
|
|
4b493bc67c | ||
|
|
fac964e0ba | ||
|
|
5203458865 | ||
|
|
cb3f36699b | ||
|
|
ae4953cc82 | ||
|
|
cc00b2263a | ||
|
|
80e93aa194 | ||
|
|
9164ceb22f | ||
|
|
0f0e40b149 | ||
|
|
62751cd4ad | ||
|
|
24b7dbf128 | ||
|
|
0a63cc6797 | ||
|
|
9b11f0e998 | ||
|
|
f6aa445732 | ||
|
|
d283675230 | ||
|
|
8782883bea |
0
.gitea/workflows/.keep
Normal file
0
.gitea/workflows/.keep
Normal file
31
.gitea/workflows/deploy-prod.yml
Normal file
31
.gitea/workflows/deploy-prod.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
name: Deploy Production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
# Une copie de ce dépôt existe sur les deux instances Gitea : la production ne doit
|
||||
# jamais exécuter le workflow de recette, ni inversement.
|
||||
if: ${{ gitea.server_url == 'https://git.santinova-soft.org' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy on production server
|
||||
run: |
|
||||
cd /opt/manus-deploy/apps/veille-reglementaire
|
||||
git pull origin main
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
echo "✅ Déploiement production terminé"
|
||||
|
||||
- name: Health check
|
||||
run: |
|
||||
sleep 30
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://veille.santinova-soft.org)
|
||||
echo "HTTP status: $STATUS"
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
echo "❌ Health check failed (HTTP $STATUS)"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Health check OK"
|
||||
31
.gitea/workflows/deploy-recette.yml
Normal file
31
.gitea/workflows/deploy-recette.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
name: Deploy Recette
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
# Une copie de ce dépôt existe sur les deux instances Gitea : la recette ne doit
|
||||
# jamais exécuter le workflow de production, ni inversement.
|
||||
if: ${{ gitea.server_url == 'https://git.recette.santinova-soft.org' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy on recette server
|
||||
run: |
|
||||
cd /opt/manus-deploy/apps/veille-reglementaire
|
||||
git pull origin main
|
||||
docker compose -f docker-compose.recette.yml up -d --build
|
||||
echo "✅ Déploiement recette terminé"
|
||||
|
||||
- name: Health check
|
||||
run: |
|
||||
sleep 30
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://veille.recette.santinova-soft.org)
|
||||
echo "HTTP status: $STATUS"
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
echo "❌ Health check failed (HTTP $STATUS)"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Health check OK"
|
||||
43
.gitea/workflows/validate.yml
Normal file
43
.gitea/workflows/validate.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
name: Validation applicative
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "docs/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "docs/**"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: TypeScript, tests et build
|
||||
runs-on: ci-node22
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Calculer la clé de cache pnpm
|
||||
id: pnpm-cache-key
|
||||
shell: bash
|
||||
run: |
|
||||
echo "store=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
echo "lock=$(sha256sum pnpm-lock.yaml | cut -d ' ' -f 1)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restaurer le store pnpm
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache-key.outputs.store }}
|
||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-cache-key.outputs.lock }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
|
||||
- name: Installer les dépendances verrouillées
|
||||
run: pnpm install --frozen-lockfile --prefer-offline
|
||||
|
||||
- name: Vérifier TypeScript, tests et build
|
||||
run: pnpm verify
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -108,3 +108,4 @@ temp/
|
||||
|
||||
# Webdev artifacts (checkpoint zips, migrations, etc.)
|
||||
.webdev/
|
||||
.project-config.json
|
||||
|
||||
16
app.json
Normal file
16
app.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "veille-reglementaire",
|
||||
"name": "Veille Réglementaire",
|
||||
"category": "ITINOVA",
|
||||
"urls": {
|
||||
"recette": "https://veille.recette.santinova-soft.org",
|
||||
"prod": "https://veille.santinova-soft.org"
|
||||
},
|
||||
"containerName": "veille-reglementaire-recette",
|
||||
"image": "images/veille-reglementaire.png",
|
||||
"giteaRepo": "veille-reglementaire",
|
||||
"giteaOwner": "manus-admin",
|
||||
"ci": {
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "f8f8affc",
|
||||
"timestamp": 1783434543359
|
||||
"timestamp": 1787039351851,
|
||||
"version": "85944a51"
|
||||
}
|
||||
@@ -1,22 +1,27 @@
|
||||
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 ClassificationErrors = lazy(() => import("./pages/ClassificationErrors"));
|
||||
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 }) {
|
||||
@@ -100,6 +105,16 @@ function LogsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function ClassificationErrorsPage() {
|
||||
return (
|
||||
<AuthGuard>
|
||||
<DashboardWrapper>
|
||||
<ClassificationErrors />
|
||||
</DashboardWrapper>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function BoiteAIdeesPage() {
|
||||
return (
|
||||
<AuthGuard>
|
||||
@@ -135,6 +150,7 @@ function Router() {
|
||||
<Route path="/admin/settings" component={SettingsPage} />
|
||||
<Route path="/admin/users" component={UsersPage} />
|
||||
<Route path="/admin/logs" component={LogsPage} />
|
||||
<Route path="/admin/classification-errors" component={ClassificationErrorsPage} />
|
||||
<Route path="/boite-a-idees" component={BoiteAIdeesPage} />
|
||||
<Route path="/admin/rss" component={RssFeedsPage} />
|
||||
<Route path="/404" component={NotFound} />
|
||||
@@ -150,7 +166,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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
X,
|
||||
Lightbulb,
|
||||
Rss,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -64,6 +65,7 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
defaultOpen: false,
|
||||
items: [
|
||||
{ label: "Logs d'import", href: "/admin/logs", icon: <Activity size={16} />, adminOnly: true },
|
||||
{ label: "Erreurs IA", href: "/admin/classification-errors", icon: <AlertTriangle size={16} />, adminOnly: true },
|
||||
{ label: "Utilisateurs", href: "/admin/users", icon: <Users size={16} />, adminOnly: true },
|
||||
{ label: "Flux RSS", href: "/admin/rss", icon: <Rss size={16} />, adminOnly: true },
|
||||
{ label: "Paramètres", href: "/admin/settings", icon: <Settings size={16} />, adminOnly: true },
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)} />
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { useLocalAuth } from "@/contexts/LocalAuthContext";
|
||||
import { toast } from "sonner";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
@@ -117,13 +117,23 @@ export default function AAPDashboard() {
|
||||
|
||||
// Marquage lu/non lu
|
||||
const [readIds, setReadIds] = useState<Set<number>>(new Set());
|
||||
const readIdsQuery = trpc.aap.getReadIds.useQuery(undefined, { enabled: !!user });
|
||||
useEffect(() => {
|
||||
if (readIdsQuery.data?.ids) {
|
||||
setReadIds(new Set(readIdsQuery.data.ids));
|
||||
}
|
||||
}, [readIdsQuery.data]);
|
||||
const markAsReadMutation = trpc.aap.markAsRead.useMutation({
|
||||
onSuccess: (_, vars) => {
|
||||
setReadIds((prev) => { const next = new Set(prev); next.add(vars.articleId); return next; });
|
||||
utils.aap.getReadIds.invalidate();
|
||||
},
|
||||
});
|
||||
const markAllAsReadMutation = trpc.aap.markAllAsRead.useMutation({
|
||||
onSuccess: () => { utils.aap.unreadCount.invalidate(); },
|
||||
onSuccess: () => {
|
||||
utils.aap.unreadCount.invalidate();
|
||||
utils.aap.getReadIds.invalidate();
|
||||
},
|
||||
});
|
||||
const unreadCountQuery = trpc.aap.unreadCount.useQuery();
|
||||
const unreadCount = unreadCountQuery.data?.count ?? 0;
|
||||
|
||||
145
client/src/pages/ClassificationErrors.tsx
Normal file
145
client/src/pages/ClassificationErrors.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useState } from "react";
|
||||
import { AlertTriangle, ChevronLeft, ChevronRight, ExternalLink, Loader2, RefreshCw, Rss } from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ClassificationError {
|
||||
id: number;
|
||||
feedName: string;
|
||||
feedType: "veille" | "aap";
|
||||
articleTitle: string;
|
||||
articleUrl: string | null;
|
||||
errorMessage: string;
|
||||
occurredAt: Date;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
const typeConfig = {
|
||||
veille: { label: "Veille", className: "bg-blue-100 text-blue-800 border-blue-200" },
|
||||
aap: { label: "AAP", className: "bg-violet-100 text-violet-800 border-violet-200" },
|
||||
};
|
||||
|
||||
function formatDate(value: Date) {
|
||||
return new Intl.DateTimeFormat("fr-FR", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
/** Rapport des erreurs LLM ayant déclenché le fallback de classification RSS. */
|
||||
export default function ClassificationErrors() {
|
||||
const [page, setPage] = useState(1);
|
||||
const errorsQuery = trpc.rss.classificationErrors.useQuery({ page, pageSize: PAGE_SIZE });
|
||||
const errors = (errorsQuery.data?.errors ?? []) as ClassificationError[];
|
||||
const total = errorsQuery.data?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 animate-fade-up">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<AlertTriangle size={22} className="text-amber-600" />
|
||||
<h1 className="text-2xl font-bold text-foreground">Erreurs de classification</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Fallbacks IA détectés pendant la lecture des flux RSS. Les articles concernés ont été importés avec les règles de repli.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="gap-2" onClick={() => errorsQuery.refetch()} disabled={errorsQuery.isFetching}>
|
||||
<RefreshCw size={15} className={cn(errorsQuery.isFetching && "animate-spin")} />
|
||||
Actualiser
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Card className="border-amber-200 bg-amber-50/50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-amber-700 mb-1">
|
||||
<AlertTriangle size={16} />
|
||||
<span className="text-xs font-medium">Fallbacks enregistrés</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-amber-800">{total}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-border/50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-primary mb-1">
|
||||
<Rss size={16} />
|
||||
<span className="text-xs font-medium text-muted-foreground">Comportement de sécurité</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-foreground">Import maintenu via règles de repli</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{errorsQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center py-16"><Loader2 size={28} className="animate-spin text-primary" /></div>
|
||||
) : errorsQuery.isError ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center gap-2">
|
||||
<AlertTriangle size={40} className="text-destructive/60" />
|
||||
<p className="font-medium text-foreground">Le rapport ne peut pas être chargé</p>
|
||||
<p className="text-sm text-muted-foreground">{errorsQuery.error.message}</p>
|
||||
</div>
|
||||
) : errors.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<AlertTriangle size={40} className="text-emerald-500/60 mb-3" />
|
||||
<p className="font-medium text-foreground">Aucune erreur de classification</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Les prochains fallbacks IA apparaîtront ici.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/30">
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground whitespace-nowrap">Date</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-24">Flux</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground">Article</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground">Cause technique</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{errors.map((error) => {
|
||||
const type = typeConfig[error.feedType];
|
||||
return (
|
||||
<tr key={error.id} className="hover:bg-muted/20 transition-colors align-top">
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">{formatDate(error.occurredAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="space-y-1">
|
||||
<Badge variant="outline" className={cn("text-xs", type.className)}>{type.label}</Badge>
|
||||
<p className="text-xs text-muted-foreground max-w-40 truncate" title={error.feedName}>{error.feedName}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 max-w-sm">
|
||||
{error.articleUrl ? (
|
||||
<a href={error.articleUrl} target="_blank" rel="noreferrer" className="inline-flex items-start gap-1 font-medium text-primary hover:underline">
|
||||
<span>{error.articleTitle}</span><ExternalLink size={13} className="mt-0.5 shrink-0" />
|
||||
</a>
|
||||
) : <span className="font-medium text-foreground">{error.articleTitle}</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-destructive max-w-md break-words">{error.errorMessage}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setPage((value) => Math.max(1, value - 1))} disabled={page === 1}><ChevronLeft size={14} /></Button>
|
||||
<span className="text-sm text-muted-foreground px-2">Page {page} / {totalPages}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setPage((value) => Math.min(totalPages, value + 1))} disabled={page === totalPages}><ChevronRight size={14} /></Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { useLocalAuth } from "@/contexts/LocalAuthContext";
|
||||
import { toast } from "sonner";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
@@ -234,14 +234,22 @@ export default function VeilleDashboard() {
|
||||
|
||||
// Marquage lu/non lu
|
||||
const [readIds, setReadIds] = useState<Set<number>>(new Set());
|
||||
const readIdsQuery = trpc.veille.getReadIds.useQuery(undefined, { enabled: !!user });
|
||||
useEffect(() => {
|
||||
if (readIdsQuery.data?.ids) {
|
||||
setReadIds(new Set(readIdsQuery.data.ids));
|
||||
}
|
||||
}, [readIdsQuery.data]);
|
||||
const markAsReadMutation = trpc.veille.markAsRead.useMutation({
|
||||
onSuccess: (_, vars) => {
|
||||
setReadIds((prev) => { const next = new Set(prev); next.add(vars.articleId); return next; });
|
||||
utils.veille.getReadIds.invalidate();
|
||||
},
|
||||
});
|
||||
const markAllAsReadMutation = trpc.veille.markAllAsRead.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.veille.unreadCount.invalidate();
|
||||
utils.veille.getReadIds.invalidate();
|
||||
},
|
||||
});
|
||||
const unreadCountQuery = trpc.veille.unreadCount.useQuery();
|
||||
|
||||
67
docker-compose.recette.yml
Normal file
67
docker-compose.recette.yml
Normal file
@@ -0,0 +1,67 @@
|
||||
services:
|
||||
veille-reglementaire-recette:
|
||||
image: veille-reglementaire-app:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: veille-reglementaire-recette
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- DATABASE_URL=mysql://veille_user:VeilleDB2026Secure!@veille-db-recette:3306/veille_reglementaire
|
||||
- JWT_SECRET=veille-jwt-secret-2026-recette
|
||||
- VITE_APP_ID=veille-reglementaire-recette
|
||||
- OAUTH_SERVER_URL=https://api.manus.im
|
||||
- VITE_OAUTH_PORTAL_URL=https://manus.im
|
||||
- OWNER_OPEN_ID=admin
|
||||
- OWNER_NAME=Administrateur Itinova
|
||||
- BUILT_IN_FORGE_API_URL=
|
||||
- BUILT_IN_FORGE_API_KEY=
|
||||
- VITE_FRONTEND_FORGE_API_KEY=
|
||||
- VITE_FRONTEND_FORGE_API_URL=
|
||||
- VITE_ANALYTICS_ENDPOINT=
|
||||
- VITE_ANALYTICS_WEBSITE_ID=
|
||||
- AZURE_AD_CLIENT_ID=f496da82-e18f-4567-bf05-8551ae6669b2
|
||||
- AZURE_AD_CLIENT_SECRET=d.T8Q~RR9iobI9bin2hqecoBWDjvyD7I11U5QcPR
|
||||
- AZURE_AD_TENANT_ID=487d0a81-de35-44ce-8847-03bb74ec553e
|
||||
- AZURE_AD_REDIRECT_URI=https://veille.recette.santinova-soft.org/api/auth/azure/callback
|
||||
depends_on:
|
||||
veille-db-recette:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- web
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=web"
|
||||
- "traefik.http.routers.veille-recette-secure.entrypoints=websecure"
|
||||
- "traefik.http.routers.veille-recette-secure.rule=Host(`veille.recette.santinova-soft.org`)"
|
||||
- "traefik.http.routers.veille-recette-secure.service=veille-recette-svc"
|
||||
- "traefik.http.routers.veille-recette-secure.tls=true"
|
||||
- "traefik.http.routers.veille-recette-secure.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.veille-recette-svc.loadbalancer.server.port=3000"
|
||||
veille-db-recette:
|
||||
image: mysql:8.0
|
||||
container_name: veille-db-recette
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: VeilleRootDB2026!
|
||||
MYSQL_DATABASE: veille_reglementaire
|
||||
MYSQL_USER: veille_user
|
||||
MYSQL_PASSWORD: VeilleDB2026Secure!
|
||||
volumes:
|
||||
- veille-db-recette-data:/var/lib/mysql
|
||||
networks:
|
||||
- web
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
veille-db-recette-data:
|
||||
|
||||
networks:
|
||||
web:
|
||||
external: true
|
||||
10
drizzle/0012_fluffy_boomer.sql
Normal file
10
drizzle/0012_fluffy_boomer.sql
Normal 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`);
|
||||
11
drizzle/0013_odd_grim_reaper.sql
Normal file
11
drizzle/0013_odd_grim_reaper.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE `classification_errors` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`feedId` int,
|
||||
`feedName` varchar(255) NOT NULL,
|
||||
`feedType` enum('veille','aap') NOT NULL,
|
||||
`articleTitle` text NOT NULL,
|
||||
`articleUrl` text,
|
||||
`errorMessage` text NOT NULL,
|
||||
`occurredAt` timestamp NOT NULL DEFAULT (now()),
|
||||
CONSTRAINT `classification_errors_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
1061
drizzle/meta/0012_snapshot.json
Normal file
1061
drizzle/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1135
drizzle/meta/0013_snapshot.json
Normal file
1135
drizzle/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,20 @@
|
||||
"when": 1783432258494,
|
||||
"tag": "0011_sharp_gambit",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "5",
|
||||
"when": 1786997741455,
|
||||
"tag": "0012_fluffy_boomer",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "5",
|
||||
"when": 1787039231200,
|
||||
"tag": "0013_odd_grim_reaper",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
mysqlTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
varchar,
|
||||
json,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
@@ -136,6 +137,25 @@ export const importLogs = mysqlTable("import_logs", {
|
||||
export type ImportLog = typeof importLogs.$inferSelect;
|
||||
export type InsertImportLog = typeof importLogs.$inferInsert;
|
||||
|
||||
// ─── Erreurs de classification RSS ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Journal technique destiné à l'administration. Il conserve les fallbacks IA
|
||||
* article par article, sans bloquer l'import ni exposer l'erreur aux utilisateurs.
|
||||
*/
|
||||
export const classificationErrors = mysqlTable("classification_errors", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
feedId: int("feedId"),
|
||||
feedName: varchar("feedName", { length: 255 }).notNull(),
|
||||
feedType: mysqlEnum("feedType", ["veille", "aap"]).notNull(),
|
||||
articleTitle: text("articleTitle").notNull(),
|
||||
articleUrl: text("articleUrl"),
|
||||
errorMessage: text("errorMessage").notNull(),
|
||||
occurredAt: timestamp("occurredAt").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type ClassificationError = typeof classificationErrors.$inferSelect;
|
||||
|
||||
// ─── Boîte à idées ───────────────────────────────────────────────────────────
|
||||
|
||||
export const ideas = mysqlTable("ideas", {
|
||||
@@ -204,13 +224,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;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"check": "tsc --noEmit",
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest run",
|
||||
"verify": "pnpm check && pnpm test && pnpm build",
|
||||
"db:push": "drizzle-kit generate && drizzle-kit migrate"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -98,8 +98,8 @@ export async function scheduleDailyImport() {
|
||||
const retentionMonths = retentionStr ? parseInt(retentionStr, 10) : 0;
|
||||
if (retentionMonths > 0) {
|
||||
const purged = await purgeOldArticles(retentionMonths);
|
||||
if (purged.veille > 0 || purged.aap > 0) {
|
||||
console.log(`[Cron] Purge rétention (${retentionMonths} mois) — Veille: -${purged.veille} | AAP: -${purged.aap}`);
|
||||
if (purged.veille > 0 || purged.aap > 0 || purged.tombstones > 0) {
|
||||
console.log(`[Cron] Purge rétention (${retentionMonths} mois) — Veille: -${purged.veille} | AAP: -${purged.aap} | Tombstones: -${purged.tombstones}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -236,8 +236,8 @@ async function startServer() {
|
||||
const retentionMonths = retentionStr ? parseInt(retentionStr, 10) : 0;
|
||||
if (retentionMonths > 0) {
|
||||
const purged = await purgeOldArticles(retentionMonths);
|
||||
if (purged.veille > 0 || purged.aap > 0) {
|
||||
console.log(`[Init] Purge rétention (${retentionMonths} mois) — Veille: -${purged.veille} | AAP: -${purged.aap}`);
|
||||
if (purged.veille > 0 || purged.aap > 0 || purged.tombstones > 0) {
|
||||
console.log(`[Init] Purge rétention (${retentionMonths} mois) — Veille: -${purged.veille} | AAP: -${purged.aap} | Tombstones: -${purged.tombstones}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -246,4 +246,8 @@ async function startServer() {
|
||||
});
|
||||
}
|
||||
|
||||
startServer().catch(console.error);
|
||||
// Les routeurs importent certaines fonctions de ce module. Vitest ne doit jamais
|
||||
// démarrer un serveur HTTP à cet effet, sinon les suites parallèles se disputent un port.
|
||||
if (!process.env.VITEST) {
|
||||
startServer().catch(console.error);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ describe("classifyArticle", () => {
|
||||
expect(result.typeVeille).toBe("informationnelle");
|
||||
expect(result.relevant).toBe(true); // on suppose pertinent par défaut
|
||||
expect(result.reason).toContain("règles");
|
||||
expect(result.technicalError).toBe("LLM timeout");
|
||||
});
|
||||
|
||||
it("retourne le fallback (rules) si le LLM retourne un JSON malformé", async () => {
|
||||
|
||||
@@ -38,33 +38,36 @@ export interface AiClassificationResult {
|
||||
reason: string;
|
||||
/** Indique si la classification a été faite par l'IA ou par les règles (fallback) */
|
||||
classifiedBy: "ia" | "rules";
|
||||
/** Cause technique du fallback, réservée au journal d'administration. */
|
||||
technicalError: string | null;
|
||||
}
|
||||
|
||||
function getTechnicalErrorMessage(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.slice(0, 1000);
|
||||
}
|
||||
|
||||
// ─── Prompt veille stratégique : pertinence + type de veille ─────────────────
|
||||
|
||||
const PROMPT_CLASSIFICATION = `Tu es un expert des établissements et services sociaux et médico-sociaux (ESMS) en France.
|
||||
Analyse attentivement le texte fourni. Détermine si le contenu est pertinent au regard du périmètre d'intervention de l'association, s'il a un impact professionnel direct ou indirect sur les activités, les services, la gestion, la réglementation, le financement, les professionnels, les usagers ou l'environnement stratégique de l'association.
|
||||
|
||||
Un article est pertinent UNIQUEMENT s'il traite explicitement d'un sujet en lien avec l'un des 5 secteurs d'activité de l'association :
|
||||
1. Handicap (enfant et adulte) : services, dispositifs politiques et financement liés au handicap ; ESAT, IME, ITEP, SESSAD, MAS, FAM, SAVS, SAMSAH, MDPH, RQTH, inclusion scolaire, emploi des personnes handicapées
|
||||
2. Précarité / Logement : les problématiques et situations de précarité, d'exclusion, de vulnérabilité, d'accès aux droits ou d'insertion ; CHRS, SIAO, hébergement d'urgence, insertion par le logement, RSA, sans-abrisme, expulsions
|
||||
3. Protection de l'enfance : services de protection de l'enfance ; ASE, MECS, famille d'accueil, pupilles de l'État, prévention spécialisée, mineurs non accompagnés
|
||||
4. Personnes âgées : l'accompagnement des personnes âgées, les soins associés et la perte d'autonomie ; EHPAD, SSIAD, résidences autonomie, APA, dépendance, plan grand âge
|
||||
5. Sanitaire : la gestion sanitaire et les questions de santé ; SMR (Soins de Suite et de Réadaptation), HAD, cliniques de réadaptation, parcours de soins post-aigus
|
||||
Pose-toi cette unique question : "Un directeur d'ESMS ou un cadre de direction d'une association médico-sociale (Itinova) devrait-il lire cet article pour faire son travail ?"
|
||||
|
||||
Un article est NON PERTINENT s'il porte principalement sur l'un des sujets suivants, même s'il mentionne des termes administratifs ou régionaux :
|
||||
- Sylviculture, forêts, gestion forestière, bois, chasse, pêche
|
||||
- Agriculture, élevage, viticulture, pêche maritime, filières agroalimentaires
|
||||
- Urbanisme, construction, permis de construire, plans locaux d'urbanisme (PLU), logement neuf (hors hébergement social)
|
||||
- Transports, mobilité, infrastructures routières, ferroviaires ou aéroportuaires
|
||||
- Environnement, biodiversité, eau, énergie, transition écologique (sauf si lien direct avec les ESMS)
|
||||
- Économie générale, fiscalité des entreprises, commerce, industrie, artisanat
|
||||
- Défense nationale, sécurité intérieure, police, justice (sauf protection de l'enfance)
|
||||
- Sport, culture, tourisme (sauf si lien direct avec des usagers ESMS)
|
||||
- Enseignement supérieur, recherche (sauf si lien direct avec le secteur médico-social)
|
||||
- Nominations administratives sans lien avec les secteurs ci-dessus
|
||||
Un article est pertinent s'il concerne directement ou indirectement :
|
||||
• Handicap (enfant et adulte) : services, dispositifs politiques et financement liés au handicap ; ESAT, IME, ITEP, SESSAD, MAS, FAM, SAVS, SAMSAH, MDPH, RQTH, inclusion scolaire, emploi des personnes handicapées
|
||||
• Précarité / Logement : les problématiques et situations de précarité, d'exclusion, de vulnérabilité, d'accès aux droits ou d'insertion ; CHRS, SIAO, hébergement d'urgence, insertion par le logement, RSA, sans-abrisme, expulsion
|
||||
• Protection de l'enfance : services de protection de l'enfance ; ASE, MECS, famille d'accueil, pupilles de l'État, prévention spécialisée, mineurs non accompagnés
|
||||
• Personnes âgées : l'accompagnement des personnes âgées, les soins associés et la perte d'autonomie ; EHPAD, SSIAD, résidences autonomie, APA, dépendance, plan grand âge
|
||||
• Sanitaire : la gestion sanitaire et les questions de santé ; SMR (Soins de Suite et de Réadaptation), HAD, cliniques de réadaptation, parcours de soins post-aigus
|
||||
• Réglementation applicable aux ESMS (lois, décrets, circulaires, instructions)
|
||||
• Financement des ESMS (dotations, tarification, appels à projets, CPOM)
|
||||
• Pratiques professionnelles ou droits des usagers accompagnés (personnes handicapées, personnes âgées, enfants protégés, personnes en précarité, patients en SSR/HAD)
|
||||
• Gestion, ressources humaines, numérique ou stratégie d'une association gestionnaire d'ESMS
|
||||
• Environnement concurrentiel ou partenarial du secteur médico-social
|
||||
|
||||
En cas de doute, réponds pertinent: false — il vaut mieux rejeter un article ambigu que d'en laisser passer un hors périmètre.
|
||||
Un article est NON PERTINENT si, même en le lisant en entier, un directeur d'ESMS n'en tirerait aucune information utile à son activité professionnelle — qu'il porte sur l'environnement, l'agriculture, les transports, la politique générale, le sport, ou tout autre sujet sans lien opérationnel avec les ESMS.
|
||||
|
||||
En cas de doute, réponds pertinent: false.
|
||||
|
||||
Si l'article est pertinent, classe-le dans l'un des types de veille suivants :
|
||||
- "reglementaire" : textes de loi, décrets, circulaires, obligations légales, réformes institutionnelles, instructions ministérielles
|
||||
@@ -80,37 +83,29 @@ Si pertinent est false, typeVeille doit être null.`;
|
||||
// ─── Prompt AAP : pertinence + catégorie secteur ─────────────────────────────
|
||||
|
||||
const PROMPT_CLASSIFICATION_AAP = `Tu es un expert des établissements et services sociaux et médico-sociaux (ESMS) en France.
|
||||
Analyse attentivement le texte fourni. Détermine si le contenu est pertinent au regard du périmètre d'intervention de l'association, s'il a un impact professionnel direct ou indirect sur les activités, les services, la gestion, la réglementation, le financement, les professionnels, les usagers ou l'environnement stratégique de l'association.
|
||||
|
||||
Un article est pertinent UNIQUEMENT s'il traite explicitement d'un sujet en lien avec l'un des 5 secteurs d'activité de l'association :
|
||||
1. Handicap (enfant et adulte) : services, dispositifs politiques et financement liés au handicap ; ESAT, IME, ITEP, SESSAD, MAS, FAM, SAVS, SAMSAH, MDPH, RQTH, inclusion scolaire, emploi des personnes handicapées
|
||||
2. Précarité / Logement : les problématiques et situations de précarité, d'exclusion, de vulnérabilité, d'accès aux droits ou d'insertion ; CHRS, SIAO, hébergement d'urgence, insertion par le logement, RSA, sans-abrisme, expulsions
|
||||
3. Protection de l'enfance : services de protection de l'enfance ; ASE, MECS, famille d'accueil, pupilles de l'État, prévention spécialisée, mineurs non accompagnés
|
||||
4. Personnes âgées : l'accompagnement des personnes âgées, les soins associés et la perte d'autonomie ; EHPAD, SSIAD, résidences autonomie, APA, dépendance, plan grand âge
|
||||
5. Sanitaire : la gestion sanitaire et les questions de santé ; SMR (Soins de Suite et de Réadaptation), HAD, cliniques de réadaptation, parcours de soins post-aigus
|
||||
Pose-toi cette unique question : "Un directeur d'ESMS ou un cadre de direction d'une association médico-sociale (Itinova) devrait-il répondre à cet appel à projets ou à cet appel à candidatures ?"
|
||||
|
||||
Un article est NON PERTINENT s'il porte principalement sur l'un des sujets suivants, même s'il mentionne des termes administratifs ou régionaux :
|
||||
- Sylviculture, forêts, gestion forestière, bois, chasse, pêche
|
||||
- Agriculture, élevage, viticulture, pêche maritime, filières agroalimentaires
|
||||
- Urbanisme, construction, permis de construire, plans locaux d'urbanisme (PLU), logement neuf (hors hébergement social)
|
||||
- Transports, mobilité, infrastructures routières, ferroviaires ou aéroportuaires
|
||||
- Environnement, biodiversité, eau, énergie, transition écologique (sauf si lien direct avec les ESMS)
|
||||
- Économie générale, fiscalité des entreprises, commerce, industrie, artisanat
|
||||
- Défense nationale, sécurité intérieure, police, justice (sauf protection de l'enfance)
|
||||
- Sport, culture, tourisme (sauf si lien direct avec des usagers ESMS)
|
||||
- Enseignement supérieur, recherche (sauf si lien direct avec le secteur médico-social)
|
||||
- Nominations administratives sans lien avec les secteurs ci-dessus
|
||||
Un appel à projets est pertinent s'il vise à financer ou développer des actions concernant directement ou indirectement :
|
||||
• Handicap (enfant et adulte) : services, dispositifs et financement liés au handicap ; ESAT, IME, ITEP, SESSAD, MAS, FAM, SAVS, SAMSAH, MDPH, RQTH, inclusion scolaire, emploi des personnes handicapées
|
||||
• Précarité / Logement : les situations de précarité, d'exclusion, de vulnérabilité, d'accès aux droits, aux soins ou à l'insertion ; CHRS, SIAO, hébergement d'urgence, insertion par le logement, RSA, sans-abrisme, expulsion, personnes isolées ou en situation de vulnérabilité sociale
|
||||
• Protection de l'enfance : ASE, MECS, famille d'accueil, pupilles de l'État, prévention spécialisée, mineurs non accompagnés
|
||||
• Personnes âgées : accompagnement des personnes âgées, perte d'autonomie, soins associés ; EHPAD, SSIAD, résidences autonomie, APA, dépendance, plan grand âge
|
||||
• Sanitaire : santé, soins, réadaptation ; SMR, HAD, cliniques de réadaptation, parcours de soins post-aigus
|
||||
• Actions transversales bénéficiant aux usagers des ESMS : accès aux soins, inclusion numérique, lutte contre l'isolement, innovation sociale, bien-être des professionnels du secteur
|
||||
|
||||
En cas de doute, réponds pertinent: false — il vaut mieux rejeter un article ambigu que d'en laisser passer un hors périmètre.
|
||||
Un appel à projets est NON PERTINENT si, même en le lisant en entier, un directeur d'ESMS ne pourrait pas y répondre ni en bénéficier — qu'il porte sur l'agriculture, l'environnement, les transports, la recherche fondamentale, le sport de haut niveau, ou tout autre domaine sans lien opérationnel avec les ESMS ou leurs usagers.
|
||||
|
||||
Si l'article est pertinent, classe-le dans l'une des 5 catégories suivantes (utilise exactement ces valeurs) :
|
||||
- "Handicap" : lien avec le secteur handicap (enfant ou adulte)
|
||||
- "Précarité" : lien avec la précarité, l'exclusion, le logement social ou l'insertion
|
||||
- "Enfance" : lien avec la protection de l'enfance
|
||||
- "PA" : lien avec les personnes âgées ou la perte d'autonomie
|
||||
- "Sanitaire" : lien avec la santé, les soins ou le sanitaire
|
||||
En cas de doute, réponds pertinent: false.
|
||||
|
||||
Si l'article est pertinent mais ne correspond clairement à aucune des 5 catégories, utilise "Autre".
|
||||
Si l'appel à projets est pertinent, identifie le secteur principal concerné parmi les 5 suivants (utilise exactement ces valeurs) :
|
||||
- "Handicap" : bénéficiaires principaux = personnes en situation de handicap (enfant ou adulte)
|
||||
- "Précarité" : bénéficiaires principaux = personnes en situation de précarité, d'exclusion, de vulnérabilité sociale ou d'isolement
|
||||
- "Enfance" : bénéficiaires principaux = enfants et jeunes relevant de la protection de l'enfance
|
||||
- "PA" : bénéficiaires principaux = personnes âgées ou en perte d'autonomie
|
||||
- "Sanitaire" : bénéficiaires principaux = patients ou professionnels du sanitaire
|
||||
|
||||
Si l'appel à projets est pertinent mais concerne plusieurs secteurs à égalité, choisis celui qui représente le plus grand volume d'activité d'Itinova. Si aucune catégorie ne convient clairement, utilise "Autre".
|
||||
|
||||
Réponds UNIQUEMENT avec un objet JSON valide, sans texte autour, sans balises markdown :
|
||||
{"pertinent": true/false, "categorie": "Handicap"|"Précarité"|"Enfance"|"PA"|"Sanitaire"|"Autre"|null, "raison": "explication courte en une phrase"}
|
||||
@@ -206,9 +201,11 @@ export async function classifyArticle(
|
||||
categorieAap: null,
|
||||
reason: parsed.raison,
|
||||
classifiedBy: "ia",
|
||||
technicalError: null,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("[AI Classifier] Erreur classification veille:", (e as Error).message);
|
||||
const technicalError = getTechnicalErrorMessage(e);
|
||||
console.error("[AI Classifier] Erreur classification veille:", technicalError);
|
||||
const fb = fallbackFn();
|
||||
return {
|
||||
relevant: true,
|
||||
@@ -216,6 +213,7 @@ export async function classifyArticle(
|
||||
categorieAap: null,
|
||||
reason: "Classification par règles (erreur LLM)",
|
||||
classifiedBy: "rules",
|
||||
technicalError,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -277,15 +275,18 @@ export async function classifyAap(
|
||||
categorieAap: parsed.pertinent ? (parsed.categorie ?? "Autre") : null,
|
||||
reason: parsed.raison,
|
||||
classifiedBy: "ia",
|
||||
technicalError: null,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("[AI Classifier] Erreur classification AAP:", (e as Error).message);
|
||||
const technicalError = getTechnicalErrorMessage(e);
|
||||
console.error("[AI Classifier] Erreur classification AAP:", technicalError);
|
||||
return {
|
||||
relevant: true,
|
||||
typeVeille: null,
|
||||
categorieAap: fallbackCategorie,
|
||||
reason: "Classification par règles (erreur LLM)",
|
||||
classifiedBy: "rules",
|
||||
technicalError,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
44
server/articleReads.test.ts
Normal file
44
server/articleReads.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,47 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isAzureAdConfigured, getAzureAuthUrl } from "./azureAuth";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const azureTestConfig = {
|
||||
tenantId: "00000000-0000-0000-0000-000000000001",
|
||||
clientId: "00000000-0000-0000-0000-000000000002",
|
||||
clientSecret: "test-secret-not-used-for-url",
|
||||
};
|
||||
|
||||
const originalAzureEnv = {
|
||||
tenantId: process.env.AZURE_AD_TENANT_ID,
|
||||
clientId: process.env.AZURE_AD_CLIENT_ID,
|
||||
clientSecret: process.env.AZURE_AD_CLIENT_SECRET,
|
||||
redirectUri: process.env.AZURE_AD_REDIRECT_URI,
|
||||
};
|
||||
|
||||
describe("Azure AD configuration", () => {
|
||||
it("should detect Azure AD as configured when env vars are set", () => {
|
||||
// Les variables sont injectées via webdev_request_secrets
|
||||
beforeEach(() => {
|
||||
// Chaque test utilise une instance MSAL neuve et ne dépend jamais des secrets CI.
|
||||
vi.resetModules();
|
||||
process.env.AZURE_AD_TENANT_ID = azureTestConfig.tenantId;
|
||||
process.env.AZURE_AD_CLIENT_ID = azureTestConfig.clientId;
|
||||
process.env.AZURE_AD_CLIENT_SECRET = azureTestConfig.clientSecret;
|
||||
process.env.AZURE_AD_REDIRECT_URI = "https://example.test/api/auth/azure/callback";
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env.AZURE_AD_TENANT_ID = originalAzureEnv.tenantId;
|
||||
process.env.AZURE_AD_CLIENT_ID = originalAzureEnv.clientId;
|
||||
process.env.AZURE_AD_CLIENT_SECRET = originalAzureEnv.clientSecret;
|
||||
process.env.AZURE_AD_REDIRECT_URI = originalAzureEnv.redirectUri;
|
||||
});
|
||||
|
||||
it("détecte Azure AD lorsqu’une configuration complète est fournie", async () => {
|
||||
const { isAzureAdConfigured } = await import("./azureAuth");
|
||||
const configured = isAzureAdConfigured();
|
||||
expect(configured).toBe(true);
|
||||
});
|
||||
|
||||
it("should generate a valid Azure AD auth URL", async () => {
|
||||
if (!isAzureAdConfigured()) {
|
||||
console.warn("Azure AD not configured, skipping URL test");
|
||||
return;
|
||||
}
|
||||
it("génère une URL d’autorisation Azure AD à partir de la configuration de test", async () => {
|
||||
const { getAzureAuthUrl } = await import("./azureAuth");
|
||||
const url = await getAzureAuthUrl();
|
||||
expect(url).toContain("login.microsoftonline.com");
|
||||
expect(url).toContain("oauth2/v2.0/authorize");
|
||||
expect(url).toContain("f496da82-e18f-4567-bf05-8551ae6669b2"); // client_id
|
||||
expect(url).toContain(azureTestConfig.tenantId);
|
||||
expect(url).toContain(azureTestConfig.clientId);
|
||||
});
|
||||
});
|
||||
|
||||
173
server/db.ts
173
server/db.ts
@@ -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 {
|
||||
@@ -14,9 +14,13 @@ import {
|
||||
InsertIdea,
|
||||
rssFeeds,
|
||||
rssSettings,
|
||||
processedDedupKeys,
|
||||
articleReads,
|
||||
classificationErrors,
|
||||
type InsertRssFeed,
|
||||
type InsertRssSettings,
|
||||
type ImportLog,
|
||||
type ClassificationError,
|
||||
type RssFeed,
|
||||
type RssSettings,
|
||||
} from "../drizzle/schema";
|
||||
@@ -43,6 +47,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> {
|
||||
@@ -379,6 +491,49 @@ export async function getImportStats() {
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Rapport d'erreurs de classification RSS ──────────────────────────────────
|
||||
|
||||
export interface ClassificationErrorInput {
|
||||
feedId: number | null;
|
||||
feedName: string;
|
||||
feedType: "veille" | "aap";
|
||||
articleTitle: string;
|
||||
articleUrl: string | null;
|
||||
errorMessage: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre un fallback IA sans interrompre le traitement du flux RSS.
|
||||
* La collecte reste opérationnelle : l'erreur est visible dans l'administration
|
||||
* tandis que l'article est classé par la règle de repli prévue.
|
||||
*/
|
||||
export async function recordClassificationError(input: ClassificationErrorInput): Promise<void> {
|
||||
const db = await getDb();
|
||||
if (!db) {
|
||||
console.error("[Classification errors] Base indisponible : erreur non journalisée");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await db.insert(classificationErrors).values(input);
|
||||
} catch (error) {
|
||||
console.error("[Classification errors] Échec de journalisation :", error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Liste paginée des fallbacks IA, réservée aux administrateurs via le routeur. */
|
||||
export async function getClassificationErrors(page: number, pageSize: number): Promise<{ errors: ClassificationError[]; total: number }> {
|
||||
const db = await getDb();
|
||||
if (!db) return { errors: [], total: 0 };
|
||||
|
||||
const offset = (page - 1) * pageSize;
|
||||
const [errors, totals] = await Promise.all([
|
||||
db.select().from(classificationErrors).orderBy(desc(classificationErrors.occurredAt)).limit(pageSize).offset(offset),
|
||||
db.select({ total: count() }).from(classificationErrors),
|
||||
]);
|
||||
return { errors, total: Number(totals[0]?.total ?? 0) };
|
||||
}
|
||||
|
||||
// ─── Boîte à idées ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createIdea(data: InsertIdea) {
|
||||
@@ -489,22 +644,35 @@ export async function saveRssSettings(data: Partial<Omit<InsertRssSettings, "id"
|
||||
}
|
||||
|
||||
// ─── Purge ───────────────────────────────────────────────────────────────────
|
||||
export async function purgeOldArticles(retentionMonths: number): Promise<{ veille: number; aap: number }> {
|
||||
export async function purgeOldArticles(retentionMonths: number): Promise<{ veille: number; aap: number; tombstones: number }> {
|
||||
const db = await getDb();
|
||||
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
|
||||
const tombstoneCutoff = new Date();
|
||||
tombstoneCutoff.setMonth(tombstoneCutoff.getMonth() - 6);
|
||||
const tombstoneResult = await db.delete(processedDedupKeys).where(lte(processedDedupKeys.processedAt, tombstoneCutoff));
|
||||
return {
|
||||
veille: (veilleResult as any).affectedRows ?? 0,
|
||||
aap: (aapResult as any).affectedRows ?? 0,
|
||||
tombstones: (tombstoneResult as any).affectedRows ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -512,6 +680,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;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
setSettings,
|
||||
getImportLogs,
|
||||
getImportStats,
|
||||
getClassificationErrors,
|
||||
getLocalUsers,
|
||||
createLocalUser,
|
||||
updateLocalUser,
|
||||
@@ -31,15 +32,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 +153,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,55 +182,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 }) => {
|
||||
return { ids: await getReadArticleIds(ctx.user.id, "veille") };
|
||||
}),
|
||||
}),
|
||||
// ─── AAPP ────────────────────────────────────────────────────────────────────
|
||||
@@ -274,12 +256,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)
|
||||
@@ -291,7 +274,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,
|
||||
})
|
||||
@@ -306,55 +291,33 @@ 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 }) => {
|
||||
return { ids: await getReadArticleIds(ctx.user.id, "aap") };
|
||||
}),
|
||||
}),
|
||||
// ─── Importt ─────────────────────────────────────────────────────────────────
|
||||
// ─── Import ─────────────────────────────────────────────────────────────────
|
||||
import: router({
|
||||
run: adminProcedure
|
||||
.input(z.object({ type: z.enum(["veille", "aap", "all"]).default("all") }))
|
||||
@@ -558,6 +521,11 @@ export const appRouter = router({
|
||||
return getRssFeeds();
|
||||
}),
|
||||
|
||||
// Consulter les fallbacks IA par article, sans exposer les erreurs aux utilisateurs standards.
|
||||
classificationErrors: adminProcedure
|
||||
.input(z.object({ page: z.number().int().min(1).default(1), pageSize: z.number().int().min(1).max(100).default(25) }))
|
||||
.query(async ({ input }) => getClassificationErrors(input.page, input.pageSize)),
|
||||
|
||||
// Créer un flux
|
||||
create: adminProcedure
|
||||
.input(z.object({
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
*/
|
||||
import { XMLParser } from "fast-xml-parser";
|
||||
import * as crypto from "crypto";
|
||||
import { getDb } from "./db";
|
||||
import { getDb, recordClassificationError, removeArticleReadRecords } from "./db";
|
||||
import {
|
||||
rssFeeds,
|
||||
veilleItems,
|
||||
@@ -563,6 +563,16 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
|
||||
},
|
||||
contenuPage
|
||||
);
|
||||
if (aiResult.technicalError) {
|
||||
await recordClassificationError({
|
||||
feedId: feed.id,
|
||||
feedName: feed.name,
|
||||
feedType: "veille",
|
||||
articleTitle: title,
|
||||
articleUrl: link || null,
|
||||
errorMessage: aiResult.technicalError,
|
||||
});
|
||||
}
|
||||
|
||||
const typeVeille = (aiResult.typeVeille ?? feed.defaultTypeVeille ?? "informationnelle") as
|
||||
"reglementaire" | "concurrentielle" | "technologique" | "informationnelle";
|
||||
@@ -636,6 +646,16 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
|
||||
(feed.defaultCategorieAap ?? "Autre") as "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre",
|
||||
contenuPageAap
|
||||
);
|
||||
if (aiResult.technicalError) {
|
||||
await recordClassificationError({
|
||||
feedId: feed.id,
|
||||
feedName: feed.name,
|
||||
feedType: "aap",
|
||||
articleTitle: title,
|
||||
articleUrl: link || null,
|
||||
errorMessage: aiResult.technicalError,
|
||||
});
|
||||
}
|
||||
const categorie = (aiResult.categorieAap ?? feed.defaultCategorieAap ?? "Autre") as
|
||||
"Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre";
|
||||
|
||||
@@ -775,6 +795,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 +838,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 +847,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 +891,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 +927,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 +936,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++;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
// Les tests de contrat tRPC ne doivent jamais dépendre d'une base ou d'une migration
|
||||
// disponible dans l'environnement CI. Les quatre lectures publiques sont donc isolées.
|
||||
vi.mock("./db", async () => {
|
||||
const actual = await vi.importActual<typeof import("./db")>("./db");
|
||||
return {
|
||||
...actual,
|
||||
getVeilleItems: vi.fn(async () => ({ items: [], total: 0 })),
|
||||
getVeilleDistinctValues: vi.fn(async () => ({ categories: [], niveaux: [], territoires: [] })),
|
||||
getAapItems: vi.fn(async () => ({ items: [], total: 0 })),
|
||||
getAapDistinctValues: vi.fn(async () => ({ regions: [], departements: [] })),
|
||||
};
|
||||
});
|
||||
|
||||
import { appRouter } from "./routers";
|
||||
import type { TrpcContext } from "./_core/context";
|
||||
|
||||
@@ -108,6 +122,12 @@ describe("protection admin", () => {
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
await expect(caller.users.list()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("refuse le rapport d’erreurs de classification pour un non admin", async () => {
|
||||
const ctx = makeUserCtx();
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
await expect(caller.rss.classificationErrors({ page: 1, pageSize: 25 })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests accès public ───────────────────────────────────────────────────────
|
||||
|
||||
15
todo.md
15
todo.md
@@ -189,3 +189,18 @@
|
||||
- [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 d’erreur 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.
|
||||
|
||||
## Déploiement recette et supervision de classification
|
||||
- [x] Empêcher le workflow de production de s’exécuter depuis le Gitea et le runner de recette.
|
||||
- [x] Déployer le refactoring et appliquer la migration d’unicité des lectures en recette.
|
||||
- [ ] Tester en recette la persistance des articles lus après déconnexion et reconnexion.
|
||||
- [x] Ajouter un rapport administrateur des erreurs de classification RSS avec date, flux, article et cause.
|
||||
- [ ] Corriger le découpage frontend qui empêchait React de s’afficher après le build de recette.
|
||||
|
||||
Reference in New Issue
Block a user