Compare commits
45 Commits
dde3d8b6b8
...
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 | ||
|
|
465e33c3b8 | ||
|
|
2d0de2a30d | ||
|
|
b3d1e9e6ed | ||
|
|
7261a7d33e | ||
|
|
55f3d5a5d4 | ||
|
|
f0be15d432 | ||
|
|
b0550f3298 | ||
|
|
326e8b7bcf | ||
|
|
b6d89e1087 | ||
|
|
c6b89c5b46 | ||
|
|
53514b0540 | ||
|
|
1d7fa332ea | ||
|
|
288bcf982c | ||
|
|
cffb3d4201 | ||
|
|
1f1a479029 | ||
|
|
9eb5849504 | ||
|
|
6b8b3ce9d7 | ||
|
|
1c425f25ad | ||
|
|
a7cf918fa8 | ||
|
|
3613e26034 |
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 artifacts (checkpoint zips, migrations, etc.)
|
||||||
.webdev/
|
.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": "6a49fe67",
|
"timestamp": 1787039351851,
|
||||||
"timestamp": 1781875679562
|
"version": "85944a51"
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,27 @@
|
|||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import NotFound from "@/pages/NotFound";
|
import { lazy, Suspense } from "react";
|
||||||
import { Route, Switch, Redirect } from "wouter";
|
import { Route, Switch, Redirect } from "wouter";
|
||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||||
import { LocalAuthProvider, useLocalAuth } from "./contexts/LocalAuthContext";
|
import { LocalAuthProvider, useLocalAuth } from "./contexts/LocalAuthContext";
|
||||||
import { AppLayout } from "./components/AppLayout";
|
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 { Loader2 } from "lucide-react";
|
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 ─────────────────────────────────────────────────
|
// ─── Guard d'authentification ─────────────────────────────────────────────────
|
||||||
|
|
||||||
function AuthGuard({ children }: { children: React.ReactNode }) {
|
function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||||
@@ -99,6 +105,16 @@ function LogsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ClassificationErrorsPage() {
|
||||||
|
return (
|
||||||
|
<AuthGuard>
|
||||||
|
<DashboardWrapper>
|
||||||
|
<ClassificationErrors />
|
||||||
|
</DashboardWrapper>
|
||||||
|
</AuthGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function BoiteAIdeesPage() {
|
function BoiteAIdeesPage() {
|
||||||
return (
|
return (
|
||||||
<AuthGuard>
|
<AuthGuard>
|
||||||
@@ -125,6 +141,7 @@ function Router() {
|
|||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/login" component={Login} />
|
<Route path="/login" component={Login} />
|
||||||
|
<Route path="/azure-callback" component={AzureCallback} />
|
||||||
<Route path="/">
|
<Route path="/">
|
||||||
<Redirect to="/veille" />
|
<Redirect to="/veille" />
|
||||||
</Route>
|
</Route>
|
||||||
@@ -133,6 +150,7 @@ function Router() {
|
|||||||
<Route path="/admin/settings" component={SettingsPage} />
|
<Route path="/admin/settings" component={SettingsPage} />
|
||||||
<Route path="/admin/users" component={UsersPage} />
|
<Route path="/admin/users" component={UsersPage} />
|
||||||
<Route path="/admin/logs" component={LogsPage} />
|
<Route path="/admin/logs" component={LogsPage} />
|
||||||
|
<Route path="/admin/classification-errors" component={ClassificationErrorsPage} />
|
||||||
<Route path="/boite-a-idees" component={BoiteAIdeesPage} />
|
<Route path="/boite-a-idees" component={BoiteAIdeesPage} />
|
||||||
<Route path="/admin/rss" component={RssFeedsPage} />
|
<Route path="/admin/rss" component={RssFeedsPage} />
|
||||||
<Route path="/404" component={NotFound} />
|
<Route path="/404" component={NotFound} />
|
||||||
@@ -148,7 +166,15 @@ function App() {
|
|||||||
<LocalAuthProvider>
|
<LocalAuthProvider>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Toaster richColors position="top-right" />
|
<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 />
|
<Router />
|
||||||
|
</Suspense>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</LocalAuthProvider>
|
</LocalAuthProvider>
|
||||||
</ThemeProvider>
|
</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,
|
X,
|
||||||
Lightbulb,
|
Lightbulb,
|
||||||
Rss,
|
Rss,
|
||||||
|
AlertTriangle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -64,6 +65,7 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
defaultOpen: false,
|
defaultOpen: false,
|
||||||
items: [
|
items: [
|
||||||
{ label: "Logs d'import", href: "/admin/logs", icon: <Activity size={16} />, adminOnly: true },
|
{ 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: "Utilisateurs", href: "/admin/users", icon: <Users size={16} />, adminOnly: true },
|
||||||
{ label: "Flux RSS", href: "/admin/rss", icon: <Rss 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 },
|
{ 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)} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -14,6 +14,7 @@ interface LocalAuthContextType {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
login: (identifier: string, password: string) => Promise<void>;
|
login: (identifier: string, password: string) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
|
hydrateUser: (user: LocalUser) => void;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +58,11 @@ export function LocalAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
localStorage.removeItem(LOCAL_USER_KEY);
|
localStorage.removeItem(LOCAL_USER_KEY);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hydrateUser = (u: LocalUser) => {
|
||||||
|
setUser(u);
|
||||||
|
localStorage.setItem(LOCAL_USER_KEY, JSON.stringify(u));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LocalAuthContext.Provider
|
<LocalAuthContext.Provider
|
||||||
value={{
|
value={{
|
||||||
@@ -64,6 +70,7 @@ export function LocalAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
loading,
|
loading,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
|
hydrateUser,
|
||||||
isAuthenticated: !!user,
|
isAuthenticated: !!user,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo, useEffect } from "react";
|
||||||
import { useLocalAuth } from "@/contexts/LocalAuthContext";
|
import { useLocalAuth } from "@/contexts/LocalAuthContext";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { trpc } from "@/lib/trpc";
|
import { trpc } from "@/lib/trpc";
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
List,
|
List,
|
||||||
Eye,
|
Eye,
|
||||||
|
EyeOff,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Calendar,
|
Calendar,
|
||||||
MapPin,
|
MapPin,
|
||||||
@@ -39,6 +40,7 @@ import { format, isPast, differenceInDays } from "date-fns";
|
|||||||
import { fr } from "date-fns/locale";
|
import { fr } from "date-fns/locale";
|
||||||
|
|
||||||
type AAPCategorie = "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre";
|
type AAPCategorie = "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre";
|
||||||
|
type ReadFilter = "unread" | "all" | "read";
|
||||||
|
|
||||||
interface AAPItem {
|
interface AAPItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -115,13 +117,23 @@ export default function AAPDashboard() {
|
|||||||
|
|
||||||
// Marquage lu/non lu
|
// Marquage lu/non lu
|
||||||
const [readIds, setReadIds] = useState<Set<number>>(new Set());
|
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({
|
const markAsReadMutation = trpc.aap.markAsRead.useMutation({
|
||||||
onSuccess: (_, vars) => {
|
onSuccess: (_, vars) => {
|
||||||
setReadIds((prev) => { const next = new Set(prev); next.add(vars.articleId); return next; });
|
setReadIds((prev) => { const next = new Set(prev); next.add(vars.articleId); return next; });
|
||||||
|
utils.aap.getReadIds.invalidate();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const markAllAsReadMutation = trpc.aap.markAllAsRead.useMutation({
|
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 unreadCountQuery = trpc.aap.unreadCount.useQuery();
|
||||||
const unreadCount = unreadCountQuery.data?.count ?? 0;
|
const unreadCount = unreadCountQuery.data?.count ?? 0;
|
||||||
@@ -136,10 +148,17 @@ export default function AAPDashboard() {
|
|||||||
toast.error(`Erreur lors de la purge : ${err.message}`);
|
toast.error(`Erreur lors de la purge : ${err.message}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
|
||||||
|
// ── État UI ─────────────────────────────────────────────────────────────────
|
||||||
|
// Mode vignette par défaut
|
||||||
|
const [viewMode, setViewMode] = useState<"list" | "grid">("grid");
|
||||||
const [activeTab, setActiveTab] = useState<AAPCategorie | "all">("all");
|
const [activeTab, setActiveTab] = useState<AAPCategorie | "all">("all");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
||||||
|
// Filtre Lu/Non lu — Non lu par défaut
|
||||||
|
const [readFilter, setReadFilter] = useState<ReadFilter>("unread");
|
||||||
|
// Ligne sélectionnée en mode liste
|
||||||
|
const [selectedRowId, setSelectedRowId] = useState<number | null>(null);
|
||||||
|
|
||||||
const filtersQuery = trpc.aap.filters.useQuery();
|
const filtersQuery = trpc.aap.filters.useQuery();
|
||||||
|
|
||||||
@@ -168,10 +187,17 @@ export default function AAPDashboard() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const items = (itemsQuery.data?.items ?? []) as AAPItem[];
|
const allItems = (itemsQuery.data?.items ?? []) as AAPItem[];
|
||||||
const total = itemsQuery.data?.total ?? 0;
|
const total = itemsQuery.data?.total ?? 0;
|
||||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||||
|
|
||||||
|
// Filtrage Lu/Non lu côté client
|
||||||
|
const items = useMemo(() => {
|
||||||
|
if (readFilter === "unread") return allItems.filter((i) => !readIds.has(i.id));
|
||||||
|
if (readFilter === "read") return allItems.filter((i) => readIds.has(i.id));
|
||||||
|
return allItems;
|
||||||
|
}, [allItems, readIds, readFilter]);
|
||||||
|
|
||||||
const filterOptions = [
|
const filterOptions = [
|
||||||
{ key: "region", label: "Région", options: filtersQuery.data?.regions ?? [] },
|
{ key: "region", label: "Région", options: filtersQuery.data?.regions ?? [] },
|
||||||
{ key: "departement", label: "Département", options: filtersQuery.data?.departements ?? [] },
|
{ key: "departement", label: "Département", options: filtersQuery.data?.departements ?? [] },
|
||||||
@@ -183,6 +209,13 @@ export default function AAPDashboard() {
|
|||||||
|
|
||||||
const categories: AAPCategorie[] = ["Handicap", "PA", "Enfance", "Précarité", "Sanitaire", "Autre"];
|
const categories: AAPCategorie[] = ["Handicap", "PA", "Enfance", "Précarité", "Sanitaire", "Autre"];
|
||||||
|
|
||||||
|
const READ_FILTER_LABELS: Record<ReadFilter, string> = {
|
||||||
|
unread: "Non lus",
|
||||||
|
all: "Tous",
|
||||||
|
read: "Lus",
|
||||||
|
};
|
||||||
|
const READ_FILTER_ORDER: ReadFilter[] = ["unread", "all", "read"];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6 animate-fade-up">
|
<div className="p-6 space-y-6 animate-fade-up">
|
||||||
{/* En-tête */}
|
{/* En-tête */}
|
||||||
@@ -199,12 +232,14 @@ export default function AAPDashboard() {
|
|||||||
Handicap, Personnes Âgées, Enfance, Précarité, Sanitaire et Autre
|
Handicap, Personnes Âgées, Enfance, Précarité, Sanitaire et Autre
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<Button variant="outline" size="sm" onClick={() => markAllAsReadMutation.mutate()} disabled={markAllAsReadMutation.isPending} className="gap-2 text-muted-foreground">
|
<Button variant="outline" size="sm" onClick={() => markAllAsReadMutation.mutate()} disabled={markAllAsReadMutation.isPending} className="gap-2 text-muted-foreground">
|
||||||
|
<Eye size={15} />
|
||||||
Tout marquer comme lu
|
Tout marquer comme lu
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{/* Boutons mode d'affichage */}
|
||||||
<Button variant={viewMode === "list" ? "default" : "outline"} size="sm" onClick={() => setViewMode("list")} className="gap-2">
|
<Button variant={viewMode === "list" ? "default" : "outline"} size="sm" onClick={() => setViewMode("list")} className="gap-2">
|
||||||
<List size={15} />Liste
|
<List size={15} />Liste
|
||||||
</Button>
|
</Button>
|
||||||
@@ -227,8 +262,8 @@ export default function AAPDashboard() {
|
|||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription asChild>
|
<AlertDialogDescription asChild>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p>Cette action va <strong>supprimer définitivement</strong> tous les appels à projets (Handicap, PA, Enfance, Précarité, Sanitaire et Autre).</p>
|
<p>Cette action va <strong>supprimer définitivement</strong> tous les appels à projets.</p>
|
||||||
<p className="text-destructive font-medium">Cette opération est irréversible. Les données ne pourront pas être récupérées.</p>
|
<p className="text-destructive font-medium">Cette opération est irréversible.</p>
|
||||||
</div>
|
</div>
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
@@ -248,7 +283,8 @@ export default function AAPDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Onglets */}
|
{/* Onglets catégories + bouton Lu/Non lu */}
|
||||||
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
<Tabs value={activeTab} onValueChange={(v) => { setActiveTab(v as AAPCategorie | "all"); setPage(1); }}>
|
<Tabs value={activeTab} onValueChange={(v) => { setActiveTab(v as AAPCategorie | "all"); setPage(1); }}>
|
||||||
<TabsList className="bg-muted/50 flex-wrap h-auto gap-1">
|
<TabsList className="bg-muted/50 flex-wrap h-auto gap-1">
|
||||||
<TabsTrigger value="all">Tous</TabsTrigger>
|
<TabsTrigger value="all">Tous</TabsTrigger>
|
||||||
@@ -258,6 +294,30 @@ export default function AAPDashboard() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Bouton segmenté Lu / Non lu */}
|
||||||
|
<div className="flex items-center rounded-lg border border-border overflow-hidden shadow-sm">
|
||||||
|
{READ_FILTER_ORDER.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => setReadFilter(f)}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 text-xs font-medium transition-colors flex items-center gap-1.5",
|
||||||
|
readFilter === f
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-background text-muted-foreground hover:bg-muted/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{f === "unread" && <EyeOff size={12} />}
|
||||||
|
{f === "read" && <Eye size={12} />}
|
||||||
|
{READ_FILTER_LABELS[f]}
|
||||||
|
{f === "unread" && unreadCount > 0 && (
|
||||||
|
<span className="ml-0.5 inline-flex items-center justify-center min-w-[16px] h-4 px-1 rounded-full bg-primary-foreground/20 text-[10px] font-bold">{unreadCount}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Filtres */}
|
{/* Filtres */}
|
||||||
<FilterBar
|
<FilterBar
|
||||||
filters={filterOptions}
|
filters={filterOptions}
|
||||||
@@ -276,11 +336,23 @@ export default function AAPDashboard() {
|
|||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||||
<Target size={48} className="text-muted-foreground/30 mb-4" />
|
<Target size={48} className="text-muted-foreground/30 mb-4" />
|
||||||
<p className="text-muted-foreground font-medium">Aucun appel à projets trouvé</p>
|
<p className="text-muted-foreground font-medium">
|
||||||
<p className="text-muted-foreground/60 text-sm mt-1">Modifiez vos filtres ou importez des données</p>
|
{readFilter === "unread" ? "Aucun appel à projets non lu" : "Aucun appel à projets trouvé"}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground/60 text-sm mt-1">
|
||||||
|
{readFilter === "unread"
|
||||||
|
? "Tous les appels à projets ont été lus, ou modifiez le filtre Lu/Non lu"
|
||||||
|
: "Modifiez vos filtres ou importez des données"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : viewMode === "list" ? (
|
) : viewMode === "list" ? (
|
||||||
<AAPListView items={items} readIds={readIds} onMarkRead={(id) => markAsReadMutation.mutate({ articleId: id })} />
|
<AAPListView
|
||||||
|
items={items}
|
||||||
|
readIds={readIds}
|
||||||
|
onMarkRead={(id) => markAsReadMutation.mutate({ articleId: id })}
|
||||||
|
selectedRowId={selectedRowId}
|
||||||
|
onSelectRow={setSelectedRowId}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AAPGridView items={items} readIds={readIds} onMarkRead={(id) => markAsReadMutation.mutate({ articleId: id })} />
|
<AAPGridView items={items} readIds={readIds} onMarkRead={(id) => markAsReadMutation.mutate({ articleId: id })} />
|
||||||
)}
|
)}
|
||||||
@@ -303,13 +375,25 @@ export default function AAPDashboard() {
|
|||||||
|
|
||||||
// ─── Vue Liste ────────────────────────────────────────────────────────────────
|
// ─── Vue Liste ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function AAPListView({ items, readIds, onMarkRead }: { items: AAPItem[]; readIds: Set<number>; onMarkRead: (id: number) => void }) {
|
function AAPListView({
|
||||||
|
items,
|
||||||
|
readIds,
|
||||||
|
onMarkRead,
|
||||||
|
selectedRowId,
|
||||||
|
onSelectRow,
|
||||||
|
}: {
|
||||||
|
items: AAPItem[];
|
||||||
|
readIds: Set<number>;
|
||||||
|
onMarkRead: (id: number) => void;
|
||||||
|
selectedRowId: number | null;
|
||||||
|
onSelectRow: (id: number | null) => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border overflow-hidden shadow-sm">
|
<div className="rounded-xl border border-border overflow-hidden shadow-sm">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-muted/50 border-b border-border">
|
<tr className="bg-muted/60 border-b border-border">
|
||||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-8">#</th>
|
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-8">#</th>
|
||||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground">Titre</th>
|
<th className="text-left px-4 py-3 font-semibold text-muted-foreground">Titre</th>
|
||||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-28">Catégorie</th>
|
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-28">Catégorie</th>
|
||||||
@@ -320,46 +404,85 @@ function AAPListView({ items, readIds, onMarkRead }: { items: AAPItem[]; readIds
|
|||||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-16">Lien</th>
|
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-16">Lien</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody>
|
||||||
{items.map((item, idx) => (
|
{items.map((item, idx) => {
|
||||||
<tr key={item.id} className={cn("hover:bg-muted/30 transition-colors border-l-4", CAT_ACCENT[item.iaCategorie || item.categorie] || "border-l-transparent", !readIds.has(item.id) && "bg-blue-50/30")}>
|
const isSelected = selectedRowId === item.id;
|
||||||
|
const isRead = readIds.has(item.id);
|
||||||
|
const isEven = idx % 2 === 0;
|
||||||
|
const catKey = item.iaCategorie || item.categorie;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => onSelectRow(isSelected ? null : item.id)}
|
||||||
|
className={cn(
|
||||||
|
"border-b border-border/50 border-l-4 cursor-pointer transition-colors",
|
||||||
|
CAT_ACCENT[catKey] || "border-l-transparent",
|
||||||
|
isSelected
|
||||||
|
? "bg-primary/10 hover:bg-primary/15"
|
||||||
|
: isEven
|
||||||
|
? "bg-white hover:bg-primary/5"
|
||||||
|
: "bg-slate-50/80 hover:bg-primary/5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<td className="px-4 py-3 text-muted-foreground/50 text-xs">{idx + 1}</td>
|
<td className="px-4 py-3 text-muted-foreground/50 text-xs">{idx + 1}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-start gap-2 max-w-sm">
|
<div className="flex items-start gap-2 max-w-sm">
|
||||||
{!readIds.has(item.id) && (
|
{!isRead && (
|
||||||
<span className="mt-1.5 w-2 h-2 rounded-full bg-primary flex-shrink-0" title="Non lu" />
|
<span className="mt-1.5 w-2 h-2 rounded-full bg-primary flex-shrink-0" title="Non lu" />
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<p className={cn("font-medium line-clamp-2 leading-snug", readIds.has(item.id) ? "text-muted-foreground" : "text-foreground")}>{item.titre}</p>
|
<p className={cn(
|
||||||
{item.iaResume && <p className="text-xs text-muted-foreground mt-1 line-clamp-2">{item.iaResume}</p>}
|
"font-medium line-clamp-2 leading-snug",
|
||||||
|
isRead ? "text-muted-foreground" : "text-foreground",
|
||||||
|
isSelected && "text-primary font-semibold"
|
||||||
|
)}>
|
||||||
|
{item.titre}
|
||||||
|
</p>
|
||||||
|
{item.iaResume && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{item.iaResume}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<Badge variant="outline" className={cn("text-xs", CAT_COLORS[item.iaCategorie || item.categorie])}>
|
<Badge variant="outline" className={cn("text-xs", CAT_COLORS[catKey])}>
|
||||||
{item.iaCategorie || item.categorie}
|
{catKey}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-muted-foreground text-xs">{item.region || "—"}</td>
|
<td className="px-4 py-3 text-muted-foreground text-xs">{item.region || "—"}</td>
|
||||||
<td className="px-4 py-3 text-muted-foreground text-xs">{item.departement || "—"}</td>
|
<td className="px-4 py-3 text-muted-foreground text-xs">{item.departement || "—"}</td>
|
||||||
<td className="px-4 py-3 text-muted-foreground text-xs whitespace-nowrap">{formatDate(item.datePublication) || "—"}</td>
|
<td className="px-4 py-3 text-muted-foreground text-xs whitespace-nowrap">
|
||||||
|
{formatDate(item.datePublication) || "—"}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3"><ClotureStatus date={item.dateCloture} /></td>
|
<td className="px-4 py-3"><ClotureStatus date={item.dateCloture} /></td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{!readIds.has(item.id) && (
|
{!isRead && (
|
||||||
<button onClick={() => onMarkRead(item.id)} className="text-muted-foreground hover:text-primary transition-colors" title="Marquer comme lu">
|
<button
|
||||||
|
onClick={() => onMarkRead(item.id)}
|
||||||
|
className="inline-flex items-center justify-center w-7 h-7 rounded-md bg-blue-50 text-blue-600 hover:bg-blue-100 hover:text-blue-700 transition-colors border border-blue-200"
|
||||||
|
title="Marquer comme lu"
|
||||||
|
>
|
||||||
<Eye size={13} />
|
<Eye size={13} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{item.lien && (
|
{item.lien && (
|
||||||
<a href={item.lien} target="_blank" rel="noopener noreferrer" className="text-accent hover:text-accent/80 transition-colors">
|
<a
|
||||||
<ExternalLink size={15} />
|
href={item.lien}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center justify-center w-7 h-7 rounded-md bg-emerald-50 text-emerald-600 hover:bg-emerald-100 hover:text-emerald-700 transition-colors border border-emerald-200"
|
||||||
|
title="Ouvrir la source"
|
||||||
|
>
|
||||||
|
<ExternalLink size={13} />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -369,28 +492,71 @@ function AAPListView({ items, readIds, onMarkRead }: { items: AAPItem[]; readIds
|
|||||||
|
|
||||||
// ─── Vue Vignettes ────────────────────────────────────────────────────────────
|
// ─── Vue Vignettes ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function AAPGridView({ items, readIds, onMarkRead }: { items: AAPItem[]; readIds: Set<number>; onMarkRead: (id: number) => void }) {
|
function AAPGridView({
|
||||||
|
items,
|
||||||
|
readIds,
|
||||||
|
onMarkRead,
|
||||||
|
}: {
|
||||||
|
items: AAPItem[];
|
||||||
|
readIds: Set<number>;
|
||||||
|
onMarkRead: (id: number) => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
{items.map((item) => (
|
{items.map((item) => {
|
||||||
<Card key={item.id} className={cn("group hover:shadow-md transition-all duration-200 border-border overflow-hidden border-l-4", CAT_ACCENT[item.iaCategorie || item.categorie] || "", !readIds.has(item.id) && "ring-1 ring-primary/20")}>
|
const catKey = item.iaCategorie || item.categorie;
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={item.id}
|
||||||
|
className={cn(
|
||||||
|
"group hover:shadow-md transition-all duration-200 border-border overflow-hidden border-l-4",
|
||||||
|
CAT_ACCENT[catKey] || "",
|
||||||
|
!readIds.has(item.id) && "ring-1 ring-primary/20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<CardHeader className="pb-2 pt-4 px-4">
|
<CardHeader className="pb-2 pt-4 px-4">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<Badge variant="outline" className={cn("text-xs flex-shrink-0", CAT_COLORS[item.iaCategorie || item.categorie])}>
|
<Badge variant="outline" className={cn("text-xs flex-shrink-0", CAT_COLORS[catKey])}>
|
||||||
{item.iaCategorie || item.categorie}
|
{catKey}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
{!readIds.has(item.id) && (
|
||||||
|
<button
|
||||||
|
onClick={() => onMarkRead(item.id)}
|
||||||
|
className="text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
title="Marquer comme lu"
|
||||||
|
>
|
||||||
|
<Eye size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{item.lien && (
|
{item.lien && (
|
||||||
<a href={item.lien} target="_blank" rel="noopener noreferrer" className="text-muted-foreground hover:text-accent transition-colors flex-shrink-0">
|
<a
|
||||||
|
href={item.lien}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-muted-foreground hover:text-accent transition-colors"
|
||||||
|
>
|
||||||
<ExternalLink size={14} />
|
<ExternalLink size={14} />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex items-start gap-1.5 mt-2">
|
<div className="flex items-start gap-1.5 mt-2">
|
||||||
{!readIds.has(item.id) && <span className="mt-1 w-2 h-2 rounded-full bg-primary flex-shrink-0" title="Non lu" />}
|
{!readIds.has(item.id) && (
|
||||||
<h3 className={cn("font-semibold text-sm leading-snug line-clamp-3", readIds.has(item.id) ? "text-muted-foreground" : "text-foreground")}>{item.titre}</h3>
|
<span className="mt-1 w-2 h-2 rounded-full bg-primary flex-shrink-0" title="Non lu" />
|
||||||
|
)}
|
||||||
|
<h3 className={cn(
|
||||||
|
"font-semibold text-sm leading-snug line-clamp-3",
|
||||||
|
readIds.has(item.id) ? "text-muted-foreground" : "text-foreground"
|
||||||
|
)}>
|
||||||
|
{item.titre}
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-4 space-y-2">
|
<CardContent className="px-4 pb-4 space-y-2">
|
||||||
|
{item.iaResume && (
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-3 leading-relaxed">{item.iaResume}</p>
|
||||||
|
)}
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{item.region && (
|
{item.region && (
|
||||||
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-violet-50 text-violet-700 border border-violet-200">
|
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-violet-50 text-violet-700 border border-violet-200">
|
||||||
@@ -414,7 +580,8 @@ function AAPGridView({ items, readIds, onMarkRead }: { items: AAPItem[]; readIds
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
40
client/src/pages/AzureCallback.tsx
Normal file
40
client/src/pages/AzureCallback.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useLocation } from "wouter";
|
||||||
|
import { useLocalAuth } from "@/contexts/LocalAuthContext";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page intermédiaire appelée après le callback Azure AD.
|
||||||
|
* Elle récupère les infos user depuis le query param, hydrate LocalAuthContext
|
||||||
|
* (localStorage + state), puis redirige vers /veille.
|
||||||
|
*/
|
||||||
|
export default function AzureCallback() {
|
||||||
|
const [, navigate] = useLocation();
|
||||||
|
const { hydrateUser } = useLocalAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const userParam = params.get("user");
|
||||||
|
|
||||||
|
if (userParam) {
|
||||||
|
try {
|
||||||
|
const user = JSON.parse(decodeURIComponent(userParam));
|
||||||
|
hydrateUser(user);
|
||||||
|
navigate("/veille");
|
||||||
|
} catch {
|
||||||
|
navigate("/login?error=" + encodeURIComponent("Erreur lors de la connexion Microsoft"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
navigate("/login?error=" + encodeURIComponent("Réponse Azure invalide"));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-4 text-muted-foreground">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
<p className="text-sm">Connexion Microsoft en cours…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { useLocalAuth } from "@/contexts/LocalAuthContext";
|
import { useLocalAuth } from "@/contexts/LocalAuthContext";
|
||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -11,14 +12,40 @@ import { toast } from "sonner";
|
|||||||
const ITINOVA_LOGO = "https://d2xsxph8kpxj0f.cloudfront.net/310519663070627318/VepzDyqR8YkJNcqpZ729Bw/itinova-logo_8e653b24.jpg";
|
const ITINOVA_LOGO = "https://d2xsxph8kpxj0f.cloudfront.net/310519663070627318/VepzDyqR8YkJNcqpZ729Bw/itinova-logo_8e653b24.jpg";
|
||||||
const SANTINOVA_LOGO = "https://d2xsxph8kpxj0f.cloudfront.net/310519663070627318/VepzDyqR8YkJNcqpZ729Bw/santinova-logo_b8de54c4.webp";
|
const SANTINOVA_LOGO = "https://d2xsxph8kpxj0f.cloudfront.net/310519663070627318/VepzDyqR8YkJNcqpZ729Bw/santinova-logo_b8de54c4.webp";
|
||||||
|
|
||||||
|
function MicrosoftLogo() {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 21 21" width="18" height="18">
|
||||||
|
<rect x="1" y="1" width="9" height="9" fill="#f25022"/>
|
||||||
|
<rect x="11" y="1" width="9" height="9" fill="#7fba00"/>
|
||||||
|
<rect x="1" y="11" width="9" height="9" fill="#00a4ef"/>
|
||||||
|
<rect x="11" y="11" width="9" height="9" fill="#ffb900"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [azureLoading, setAzureLoading] = useState(false);
|
||||||
const { login } = useLocalAuth();
|
const { login } = useLocalAuth();
|
||||||
const [, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
|
|
||||||
|
const azureAvailableQuery = trpc.auth.isAzureAdAvailable.useQuery();
|
||||||
|
const azureLoginQuery = trpc.auth.getAzureLoginUrl.useQuery(undefined, { enabled: false, retry: false });
|
||||||
|
const azureAvailable = azureAvailableQuery.data?.available ?? false;
|
||||||
|
|
||||||
|
// Afficher les erreurs depuis query param (callback Azure)
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const error = params.get("error");
|
||||||
|
if (error) {
|
||||||
|
toast.error(decodeURIComponent(error));
|
||||||
|
window.history.replaceState({}, "", "/login");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!email || !password) return;
|
if (!email || !password) return;
|
||||||
@@ -34,6 +61,22 @@ export default function Login() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMicrosoftLogin = async () => {
|
||||||
|
setAzureLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await azureLoginQuery.refetch();
|
||||||
|
if (result.data?.url) {
|
||||||
|
window.location.href = result.data.url;
|
||||||
|
} else {
|
||||||
|
toast.error("Impossible d'obtenir l'URL de connexion Microsoft");
|
||||||
|
setAzureLoading(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Erreur lors de la connexion Microsoft");
|
||||||
|
setAzureLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||||
{/* Décoration de fond */}
|
{/* Décoration de fond */}
|
||||||
@@ -67,6 +110,34 @@ export default function Login() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
{/* Bouton Microsoft 365 — affiché uniquement si Azure AD est configuré */}
|
||||||
|
{azureAvailable && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full flex items-center gap-3 border-slate-300 bg-white hover:bg-slate-50 text-slate-700 font-medium mb-4"
|
||||||
|
onClick={handleMicrosoftLogin}
|
||||||
|
disabled={azureLoading}
|
||||||
|
>
|
||||||
|
{azureLoading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<MicrosoftLogo />
|
||||||
|
)}
|
||||||
|
Se connecter avec Microsoft 365
|
||||||
|
</Button>
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<span className="w-full border-t border-slate-200" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-xs uppercase">
|
||||||
|
<span className="bg-card px-2 text-muted-foreground">ou</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">Identifiant ou e-mail</Label>
|
<Label htmlFor="email">Identifiant ou e-mail</Label>
|
||||||
|
|||||||
@@ -539,127 +539,31 @@ function FeedCard({
|
|||||||
|
|
||||||
// ─── Panneau de configuration ─────────────────────────────────────────────────
|
// ─── Panneau de configuration ─────────────────────────────────────────────────
|
||||||
|
|
||||||
function SettingsPanel({ isAdmin }: { isAdmin: boolean }) {
|
function SettingsPanel({ isAdmin: _isAdmin }: { isAdmin: boolean }) {
|
||||||
const { data: settings, refetch } = trpc.rss.getSettings.useQuery();
|
const { data: settings } = trpc.rss.getSettings.useQuery();
|
||||||
const saveMutation = trpc.rss.saveSettings.useMutation({
|
const importTime = settings?.scheduledTime ?? "06:00";
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Paramètres RSS sauvegardés");
|
|
||||||
refetch();
|
|
||||||
},
|
|
||||||
onError: (e) => toast.error("Erreur", { description: e.message }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const [fetchMode, setFetchMode] = useState<"interval" | "scheduled">(settings?.fetchMode ?? "scheduled");
|
|
||||||
const [fetchIntervalMinutes, setFetchIntervalMinutes] = useState(settings?.fetchIntervalMinutes ?? 360);
|
|
||||||
const [scheduledTime, setScheduledTime] = useState(settings?.scheduledTime ?? "06:00");
|
|
||||||
const [autoFetchEnabled, setAutoFetchEnabled] = useState(settings?.autoFetchEnabled ?? true);
|
|
||||||
|
|
||||||
// Sync state when settings load
|
|
||||||
if (settings && settings.fetchMode !== fetchMode && !saveMutation.isPending) {
|
|
||||||
setFetchMode(settings.fetchMode);
|
|
||||||
setFetchIntervalMinutes(settings.fetchIntervalMinutes);
|
|
||||||
setScheduledTime(settings.scheduledTime ?? "06:00");
|
|
||||||
setAutoFetchEnabled(settings.autoFetchEnabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
saveMutation.mutate({
|
|
||||||
fetchMode,
|
|
||||||
fetchIntervalMinutes,
|
|
||||||
scheduledTime,
|
|
||||||
autoFetchEnabled,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-2 border-dashed border-primary/20">
|
<Card className="border-2 border-dashed border-primary/20">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<Settings2 className="h-4 w-4 text-primary" />
|
<Settings2 className="h-4 w-4 text-primary" />
|
||||||
Paramètres de lecture automatique
|
Planification de la lecture RSS
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Configurez la fréquence et le mode de lecture des flux RSS.
|
La lecture des flux RSS est déclenchée automatiquement par le cron d’import quotidien.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-5">
|
<CardContent>
|
||||||
{/* Activation */}
|
<div className="flex items-center gap-3 p-4 rounded-lg bg-muted/50 border border-border">
|
||||||
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
|
<Clock className="h-5 w-5 text-primary flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sm">Lecture automatique</p>
|
<p className="font-medium text-sm">Lecture automatique chaque jour à <span className="text-primary font-bold">{importTime}</span></p>
|
||||||
<p className="text-xs text-muted-foreground">Active la récupération périodique des flux</p>
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
</div>
|
Pour modifier l’heure, rendez-vous dans <strong>Paramètres → Import & Planification</strong>.
|
||||||
<Switch
|
</p>
|
||||||
checked={autoFetchEnabled}
|
|
||||||
onCheckedChange={setAutoFetchEnabled}
|
|
||||||
disabled={!isAdmin}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mode */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm font-medium">Mode de planification</Label>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{(["scheduled", "interval"] as const).map((mode) => (
|
|
||||||
<button
|
|
||||||
key={mode}
|
|
||||||
type="button"
|
|
||||||
disabled={!isAdmin}
|
|
||||||
onClick={() => setFetchMode(mode)}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 p-3 rounded-lg border-2 text-left transition-all text-sm",
|
|
||||||
fetchMode === mode ? "border-primary bg-primary/5" : "border-border hover:border-primary/40",
|
|
||||||
!isAdmin && "opacity-50 cursor-not-allowed"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Clock className={cn("h-4 w-4", fetchMode === mode ? "text-primary" : "text-muted-foreground")} />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{mode === "scheduled" ? "Heure fixe" : "Intervalle"}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">{mode === "scheduled" ? "Chaque jour à une heure précise" : "Toutes les N minutes"}</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Paramètre selon le mode */}
|
|
||||||
{fetchMode === "scheduled" ? (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-sm">Heure de lecture quotidienne</Label>
|
|
||||||
<Input
|
|
||||||
type="time"
|
|
||||||
value={scheduledTime}
|
|
||||||
onChange={(e) => setScheduledTime(e.target.value)}
|
|
||||||
disabled={!isAdmin}
|
|
||||||
className="w-36"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-sm">Intervalle de lecture</Label>
|
|
||||||
<Select
|
|
||||||
value={String(fetchIntervalMinutes)}
|
|
||||||
onValueChange={(v) => setFetchIntervalMinutes(Number(v))}
|
|
||||||
disabled={!isAdmin}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-48">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{INTERVAL_OPTIONS.map((opt) => (
|
|
||||||
<SelectItem key={opt.value} value={String(opt.value)}>{opt.label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isAdmin && (
|
|
||||||
<Button onClick={handleSave} disabled={saveMutation.isPending} size="sm" className="gap-2">
|
|
||||||
<Save className="h-3.5 w-3.5" />
|
|
||||||
{saveMutation.isPending ? "Enregistrement…" : "Sauvegarder les paramètres"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||||
import { trpc } from "@/lib/trpc";
|
import { trpc } from "@/lib/trpc";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
CalendarClock,
|
||||||
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -43,7 +45,96 @@ const SOURCE_LABELS: Record<SourceType, string> = {
|
|||||||
sharepoint: "SharePoint",
|
sharepoint: "SharePoint",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Composant UploadZone (réutilisé depuis ImportLogs) ───────────────────────
|
const DAYS_FR = ["Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"];
|
||||||
|
const DAYS_SHORT = ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"];
|
||||||
|
|
||||||
|
// ─── Calcul de la prochaine date d'exécution ─────────────────────────────────
|
||||||
|
|
||||||
|
function computeNextRun(
|
||||||
|
mode: string,
|
||||||
|
importTime: string,
|
||||||
|
dayOfWeek: number,
|
||||||
|
dayOfMonth: number,
|
||||||
|
intervalMinutes: number
|
||||||
|
): Date {
|
||||||
|
const now = new Date();
|
||||||
|
const [hour, minute] = importTime.split(":").map(Number);
|
||||||
|
|
||||||
|
if (mode === "interval") {
|
||||||
|
const intervalMin = Math.max(60, intervalMinutes);
|
||||||
|
const next = new Date(now);
|
||||||
|
next.setSeconds(0, 0);
|
||||||
|
if (intervalMin % 60 === 0) {
|
||||||
|
const hours = intervalMin / 60;
|
||||||
|
if (hours >= 24) {
|
||||||
|
// cron `0 0 * * *` — prochain minuit
|
||||||
|
next.setHours(0, 0);
|
||||||
|
next.setDate(next.getDate() + 1);
|
||||||
|
} else {
|
||||||
|
// cron `0 */N * * *` — prochaine heure alignée
|
||||||
|
const currentHour = now.getHours();
|
||||||
|
const nextHour = Math.ceil((currentHour * 60 + now.getMinutes() + 1) / 60 / hours) * hours;
|
||||||
|
next.setHours(nextHour, 0);
|
||||||
|
if (next <= now) next.setHours(next.getHours() + hours);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// cron `*/N * * * *` — prochain multiple de N minutes
|
||||||
|
const totalMin = now.getHours() * 60 + now.getMinutes();
|
||||||
|
const nextMin = Math.ceil((totalMin + 1) / intervalMin) * intervalMin;
|
||||||
|
next.setHours(Math.floor(nextMin / 60), nextMin % 60);
|
||||||
|
if (next <= now) next.setMinutes(next.getMinutes() + intervalMin);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "weekly") {
|
||||||
|
const next = new Date(now);
|
||||||
|
next.setSeconds(0, 0);
|
||||||
|
next.setHours(hour ?? 6, minute ?? 0);
|
||||||
|
// Avancer jusqu'au prochain jour de la semaine voulu
|
||||||
|
const diff = (dayOfWeek - next.getDay() + 7) % 7;
|
||||||
|
next.setDate(next.getDate() + (diff === 0 && next <= now ? 7 : diff));
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "monthly") {
|
||||||
|
const next = new Date(now);
|
||||||
|
next.setSeconds(0, 0);
|
||||||
|
next.setHours(hour ?? 6, minute ?? 0);
|
||||||
|
next.setDate(dayOfMonth);
|
||||||
|
if (next <= now) {
|
||||||
|
next.setMonth(next.getMonth() + 1);
|
||||||
|
next.setDate(dayOfMonth);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// scheduled (quotidien)
|
||||||
|
const next = new Date(now);
|
||||||
|
next.setSeconds(0, 0);
|
||||||
|
next.setHours(hour ?? 6, minute ?? 0);
|
||||||
|
if (next <= now) next.setDate(next.getDate() + 1);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNextRun(date: Date, mode: string, intervalMinutes: number): string {
|
||||||
|
if (mode === "interval") {
|
||||||
|
const mins = Math.max(60, intervalMinutes);
|
||||||
|
if (mins < 60) return `dans ${mins} minutes`;
|
||||||
|
const h = mins / 60;
|
||||||
|
return `dans ${h}h (à ${date.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })})`;
|
||||||
|
}
|
||||||
|
const dayName = DAYS_FR[date.getDay()];
|
||||||
|
const dayNum = date.getDate();
|
||||||
|
const monthName = date.toLocaleDateString("fr-FR", { month: "long" });
|
||||||
|
const timeStr = date.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" });
|
||||||
|
const isToday = new Date().toDateString() === date.toDateString();
|
||||||
|
const isTomorrow = new Date(Date.now() + 86400000).toDateString() === date.toDateString();
|
||||||
|
const dayLabel = isToday ? "aujourd'hui" : isTomorrow ? "demain" : `${dayName} ${dayNum} ${monthName}`;
|
||||||
|
return `${dayLabel} à ${timeStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Composant UploadZone ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface UploadResult {
|
interface UploadResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -220,6 +311,11 @@ export default function SettingsPage() {
|
|||||||
sharepoint_token: "",
|
sharepoint_token: "",
|
||||||
auth_mode: "local",
|
auth_mode: "local",
|
||||||
import_time: "06:00",
|
import_time: "06:00",
|
||||||
|
fetch_mode: "scheduled",
|
||||||
|
fetch_interval_minutes: "1440",
|
||||||
|
fetch_day_of_week: "1",
|
||||||
|
fetch_day_of_month: "1",
|
||||||
|
retention_months: "0",
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -232,6 +328,21 @@ export default function SettingsPage() {
|
|||||||
const handleSave = () => saveMutation.mutate(form as Parameters<typeof saveMutation.mutate>[0]);
|
const handleSave = () => saveMutation.mutate(form as Parameters<typeof saveMutation.mutate>[0]);
|
||||||
const sourceType = (form.source_type || "local") as SourceType;
|
const sourceType = (form.source_type || "local") as SourceType;
|
||||||
|
|
||||||
|
// ─── Calcul de la prochaine date d'exécution ──────────────────────────────
|
||||||
|
const nextRun = useMemo(() => {
|
||||||
|
return computeNextRun(
|
||||||
|
form.fetch_mode || "scheduled",
|
||||||
|
form.import_time || "06:00",
|
||||||
|
parseInt(form.fetch_day_of_week || "1", 10),
|
||||||
|
parseInt(form.fetch_day_of_month || "1", 10),
|
||||||
|
parseInt(form.fetch_interval_minutes || "1440", 10)
|
||||||
|
);
|
||||||
|
}, [form.fetch_mode, form.import_time, form.fetch_day_of_week, form.fetch_day_of_month, form.fetch_interval_minutes]);
|
||||||
|
|
||||||
|
const nextRunLabel = useMemo(() => {
|
||||||
|
return formatNextRun(nextRun, form.fetch_mode || "scheduled", parseInt(form.fetch_interval_minutes || "1440", 10));
|
||||||
|
}, [nextRun, form.fetch_mode, form.fetch_interval_minutes]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6 max-w-4xl animate-fade-up">
|
<div className="p-6 space-y-6 max-w-4xl animate-fade-up">
|
||||||
{/* En-tête */}
|
{/* En-tête */}
|
||||||
@@ -460,20 +571,179 @@ export default function SettingsPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base flex items-center gap-2"><Clock size={16} />Planification de l'import</CardTitle>
|
<CardTitle className="text-base flex items-center gap-2"><Clock size={16} />Planification de l'import</CardTitle>
|
||||||
<CardDescription>L'import automatique s'exécute quotidiennement à l'heure configurée</CardDescription>
|
<CardDescription>Configurez la fréquence et l'heure de déclenchement des mises à jour automatiques (import Excel + lecture RSS).</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-5">
|
||||||
|
{/* Mode */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">Fréquence de mise à jour</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-3 max-w-xl">
|
||||||
|
{([
|
||||||
|
{ value: "scheduled", label: "Quotidienne", desc: "Chaque jour à une heure précise" },
|
||||||
|
{ value: "weekly", label: "Hebdomadaire", desc: "Un jour précis de la semaine" },
|
||||||
|
{ value: "monthly", label: "Mensuelle", desc: "Un jour précis du mois" },
|
||||||
|
{ value: "interval", label: "Intervalle", desc: "Toutes les N heures" },
|
||||||
|
] as const).map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => set("fetch_mode", mode.value)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 p-3 rounded-lg border-2 text-left transition-all text-sm",
|
||||||
|
(form.fetch_mode || "scheduled") === mode.value
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:border-primary/40"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Clock className={cn("h-4 w-4", (form.fetch_mode || "scheduled") === mode.value ? "text-primary" : "text-muted-foreground")} />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{mode.label}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{mode.desc}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Heure (quotidien, hebdo, mensuel) */}
|
||||||
|
{["scheduled", "weekly", "monthly"].includes(form.fetch_mode || "scheduled") && (
|
||||||
<div className="space-y-2 max-w-xs">
|
<div className="space-y-2 max-w-xs">
|
||||||
<Label>Heure d'import quotidien</Label>
|
<Label>Heure de déclenchement</Label>
|
||||||
<Input
|
<Input
|
||||||
type="time"
|
type="time"
|
||||||
value={form.import_time || "06:00"}
|
value={form.import_time || "06:00"}
|
||||||
onChange={(e) => set("import_time", e.target.value)}
|
onChange={(e) => set("import_time", e.target.value)}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
</div>
|
||||||
Prochain import : demain à {form.import_time || "06:00"}
|
)}
|
||||||
|
|
||||||
|
{/* Jour de la semaine (hebdo) */}
|
||||||
|
{(form.fetch_mode || "scheduled") === "weekly" && (
|
||||||
|
<div className="space-y-2 max-w-xs">
|
||||||
|
<Label>Jour de la semaine</Label>
|
||||||
|
<div className="grid grid-cols-7 gap-1">
|
||||||
|
{DAYS_SHORT.map((day, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => set("fetch_day_of_week", String(i))}
|
||||||
|
className={cn(
|
||||||
|
"py-1.5 rounded-md border-2 text-xs font-medium transition-all",
|
||||||
|
(form.fetch_day_of_week || "1") === String(i)
|
||||||
|
? "border-primary bg-primary/5 text-primary"
|
||||||
|
: "border-border hover:border-primary/40 text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Jour du mois (mensuel) */}
|
||||||
|
{(form.fetch_mode || "scheduled") === "monthly" && (
|
||||||
|
<div className="space-y-2 max-w-xs">
|
||||||
|
<Label>Jour du mois (1–28)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={28}
|
||||||
|
value={form.fetch_day_of_month || "1"}
|
||||||
|
onChange={(e) => set("fetch_day_of_month", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Intervalle */}
|
||||||
|
{(form.fetch_mode || "scheduled") === "interval" && (
|
||||||
|
<div className="space-y-2 max-w-xs">
|
||||||
|
<Label>Intervalle de mise à jour</Label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{[
|
||||||
|
{ label: "1h", value: "60" },
|
||||||
|
{ label: "2h", value: "120" },
|
||||||
|
{ label: "4h", value: "240" },
|
||||||
|
{ label: "6h", value: "360" },
|
||||||
|
{ label: "12h", value: "720" },
|
||||||
|
{ label: "24h", value: "1440" },
|
||||||
|
].map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => set("fetch_interval_minutes", opt.value)}
|
||||||
|
className={cn(
|
||||||
|
"py-2 px-3 rounded-lg border-2 text-sm font-medium transition-all",
|
||||||
|
(form.fetch_interval_minutes || "1440") === opt.value
|
||||||
|
? "border-primary bg-primary/5 text-primary"
|
||||||
|
: "border-border hover:border-primary/40 text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Prochaine date d'exécution ──────────────────────────── */}
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-primary/5 border border-primary/20 max-w-xl">
|
||||||
|
<CalendarClock size={16} className="text-primary shrink-0" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-muted-foreground">Prochain import prévu : </span>
|
||||||
|
<span className="font-semibold text-foreground">{nextRunLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Rétention des articles ──────────────────────────────────── */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Trash2 size={16} className="text-muted-foreground" />
|
||||||
|
Rétention des articles
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Supprimez automatiquement les articles plus anciens que la durée choisie.
|
||||||
|
La purge s'exécute au démarrage du serveur et après chaque import automatique.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2 max-w-xl">
|
||||||
|
{[
|
||||||
|
{ label: "Illimité", value: "0" },
|
||||||
|
{ label: "3 mois", value: "3" },
|
||||||
|
{ label: "6 mois", value: "6" },
|
||||||
|
{ label: "12 mois", value: "12" },
|
||||||
|
{ label: "18 mois", value: "18" },
|
||||||
|
{ label: "24 mois", value: "24" },
|
||||||
|
].map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => set("retention_months", opt.value)}
|
||||||
|
className={cn(
|
||||||
|
"py-2 px-3 rounded-lg border-2 text-sm font-medium transition-all",
|
||||||
|
(form.retention_months || "0") === opt.value
|
||||||
|
? opt.value === "0"
|
||||||
|
? "border-emerald-500 bg-emerald-50 text-emerald-700"
|
||||||
|
: "border-primary bg-primary/5 text-primary"
|
||||||
|
: "border-border hover:border-primary/40 text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{(form.retention_months || "0") !== "0" && (
|
||||||
|
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-50 border border-amber-200 max-w-xl">
|
||||||
|
<AlertCircle size={14} className="text-amber-600 mt-0.5 shrink-0" />
|
||||||
|
<p className="text-xs text-amber-700">
|
||||||
|
Les articles importés il y a plus de <strong>{form.retention_months} mois</strong> seront supprimés définitivement lors du prochain démarrage ou import automatique.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
Calendar,
|
Calendar,
|
||||||
MapPin,
|
MapPin,
|
||||||
Tag,
|
|
||||||
Layers,
|
Layers,
|
||||||
FileSearch,
|
FileSearch,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -30,6 +29,7 @@ import {
|
|||||||
BookOpen,
|
BookOpen,
|
||||||
Trash2,
|
Trash2,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
EyeOff,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -47,6 +47,7 @@ import { format } from "date-fns";
|
|||||||
import { fr } from "date-fns/locale";
|
import { fr } from "date-fns/locale";
|
||||||
|
|
||||||
type TypeVeille = "reglementaire" | "concurrentielle" | "technologique" | "informationnelle";
|
type TypeVeille = "reglementaire" | "concurrentielle" | "technologique" | "informationnelle";
|
||||||
|
type ReadFilter = "unread" | "all" | "read";
|
||||||
|
|
||||||
interface VeilleItem {
|
interface VeilleItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -150,7 +151,6 @@ function VeilleDetailDialog({
|
|||||||
|
|
||||||
{/* Métadonnées en grille */}
|
{/* Métadonnées en grille */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
|
|
||||||
{item.niveau && (
|
{item.niveau && (
|
||||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-violet-50 border border-violet-200">
|
<div className="flex items-start gap-2 p-3 rounded-lg bg-violet-50 border border-violet-200">
|
||||||
<Layers size={14} className="text-violet-500 mt-0.5 shrink-0" />
|
<Layers size={14} className="text-violet-500 mt-0.5 shrink-0" />
|
||||||
@@ -205,7 +205,6 @@ function VeilleDetailDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* Lien externe */}
|
{/* Lien externe */}
|
||||||
{item.lien && (
|
{item.lien && (
|
||||||
<div className="pt-1 border-t border-border/50">
|
<div className="pt-1 border-t border-border/50">
|
||||||
@@ -235,14 +234,22 @@ export default function VeilleDashboard() {
|
|||||||
|
|
||||||
// Marquage lu/non lu
|
// Marquage lu/non lu
|
||||||
const [readIds, setReadIds] = useState<Set<number>>(new Set());
|
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({
|
const markAsReadMutation = trpc.veille.markAsRead.useMutation({
|
||||||
onSuccess: (_, vars) => {
|
onSuccess: (_, vars) => {
|
||||||
setReadIds((prev) => { const next = new Set(prev); next.add(vars.articleId); return next; });
|
setReadIds((prev) => { const next = new Set(prev); next.add(vars.articleId); return next; });
|
||||||
|
utils.veille.getReadIds.invalidate();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const markAllAsReadMutation = trpc.veille.markAllAsRead.useMutation({
|
const markAllAsReadMutation = trpc.veille.markAllAsRead.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.veille.unreadCount.invalidate();
|
utils.veille.unreadCount.invalidate();
|
||||||
|
utils.veille.getReadIds.invalidate();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const unreadCountQuery = trpc.veille.unreadCount.useQuery();
|
const unreadCountQuery = trpc.veille.unreadCount.useQuery();
|
||||||
@@ -258,12 +265,19 @@ export default function VeilleDashboard() {
|
|||||||
toast.error(`Erreur lors de la purge : ${err.message}`);
|
toast.error(`Erreur lors de la purge : ${err.message}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
|
||||||
|
// ── État UI ─────────────────────────────────────────────────────────────────
|
||||||
|
// Mode vignette par défaut
|
||||||
|
const [viewMode, setViewMode] = useState<"list" | "grid">("grid");
|
||||||
const [activeTab, setActiveTab] = useState<TypeVeille | "all">("all");
|
const [activeTab, setActiveTab] = useState<TypeVeille | "all">("all");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
||||||
const [selectedItem, setSelectedItem] = useState<VeilleItem | null>(null);
|
const [selectedItem, setSelectedItem] = useState<VeilleItem | null>(null);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
// Filtre Lu/Non lu — Non lu par défaut
|
||||||
|
const [readFilter, setReadFilter] = useState<ReadFilter>("unread");
|
||||||
|
// Ligne sélectionnée en mode liste
|
||||||
|
const [selectedRowId, setSelectedRowId] = useState<number | null>(null);
|
||||||
|
|
||||||
const filtersQuery = trpc.veille.filters.useQuery();
|
const filtersQuery = trpc.veille.filters.useQuery();
|
||||||
|
|
||||||
@@ -294,16 +308,22 @@ export default function VeilleDashboard() {
|
|||||||
const openDetail = (item: VeilleItem) => {
|
const openDetail = (item: VeilleItem) => {
|
||||||
setSelectedItem(item);
|
setSelectedItem(item);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
// Marquer comme lu automatiquement à l'ouverture du détail
|
|
||||||
if (!readIds.has(item.id)) {
|
if (!readIds.has(item.id)) {
|
||||||
markAsReadMutation.mutate({ articleId: item.id });
|
markAsReadMutation.mutate({ articleId: item.id });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const items = (itemsQuery.data?.items ?? []) as VeilleItem[];
|
const allItems = (itemsQuery.data?.items ?? []) as VeilleItem[];
|
||||||
const total = itemsQuery.data?.total ?? 0;
|
const total = itemsQuery.data?.total ?? 0;
|
||||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||||
|
|
||||||
|
// Filtrage Lu/Non lu côté client (readIds est local à la session)
|
||||||
|
const items = useMemo(() => {
|
||||||
|
if (readFilter === "unread") return allItems.filter((i) => !readIds.has(i.id));
|
||||||
|
if (readFilter === "read") return allItems.filter((i) => readIds.has(i.id));
|
||||||
|
return allItems;
|
||||||
|
}, [allItems, readIds, readFilter]);
|
||||||
|
|
||||||
const filterOptions = [
|
const filterOptions = [
|
||||||
{ key: "niveau", label: "Niveau", options: filtersQuery.data?.niveaux ?? [] },
|
{ key: "niveau", label: "Niveau", options: filtersQuery.data?.niveaux ?? [] },
|
||||||
{ key: "territoire", label: "Territoire", options: filtersQuery.data?.territoires ?? [] },
|
{ key: "territoire", label: "Territoire", options: filtersQuery.data?.territoires ?? [] },
|
||||||
@@ -311,6 +331,14 @@ export default function VeilleDashboard() {
|
|||||||
{ key: "dateTo", label: "Date jusqu'à", type: "date" as const },
|
{ key: "dateTo", label: "Date jusqu'à", type: "date" as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Labels et styles du bouton Lu/Non lu
|
||||||
|
const READ_FILTER_LABELS: Record<ReadFilter, string> = {
|
||||||
|
unread: "Non lus",
|
||||||
|
all: "Tous",
|
||||||
|
read: "Lus",
|
||||||
|
};
|
||||||
|
const READ_FILTER_ORDER: ReadFilter[] = ["unread", "all", "read"];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6 animate-fade-up">
|
<div className="p-6 space-y-6 animate-fade-up">
|
||||||
{/* En-tête */}
|
{/* En-tête */}
|
||||||
@@ -327,13 +355,14 @@ export default function VeilleDashboard() {
|
|||||||
Suivi réglementaire, concurrentiel, technologique et informationnel
|
Suivi réglementaire, concurrentiel, technologique et informationnel
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<Button variant="outline" size="sm" onClick={() => markAllAsReadMutation.mutate()} disabled={markAllAsReadMutation.isPending} className="gap-2 text-muted-foreground">
|
<Button variant="outline" size="sm" onClick={() => markAllAsReadMutation.mutate()} disabled={markAllAsReadMutation.isPending} className="gap-2 text-muted-foreground">
|
||||||
<Eye size={15} />
|
<Eye size={15} />
|
||||||
Tout marquer comme lu
|
Tout marquer comme lu
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{/* Boutons mode d'affichage */}
|
||||||
<Button variant={viewMode === "list" ? "default" : "outline"} size="sm" onClick={() => setViewMode("list")} className="gap-2">
|
<Button variant={viewMode === "list" ? "default" : "outline"} size="sm" onClick={() => setViewMode("list")} className="gap-2">
|
||||||
<List size={15} />Liste
|
<List size={15} />Liste
|
||||||
</Button>
|
</Button>
|
||||||
@@ -341,7 +370,6 @@ export default function VeilleDashboard() {
|
|||||||
<LayoutGrid size={15} />Vignettes
|
<LayoutGrid size={15} />Vignettes
|
||||||
</Button>
|
</Button>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="outline" size="sm" className="gap-2 border-destructive/50 text-destructive hover:bg-destructive hover:text-destructive-foreground ml-2">
|
<Button variant="outline" size="sm" className="gap-2 border-destructive/50 text-destructive hover:bg-destructive hover:text-destructive-foreground ml-2">
|
||||||
@@ -357,8 +385,8 @@ export default function VeilleDashboard() {
|
|||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription asChild>
|
<AlertDialogDescription asChild>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p>Cette action va <strong>supprimer définitivement</strong> toutes les entrées de la veille stratégique (réglementaire, concurrentielle, technologique et générale).</p>
|
<p>Cette action va <strong>supprimer définitivement</strong> toutes les entrées de la veille stratégique.</p>
|
||||||
<p className="text-destructive font-medium">Cette opération est irréversible. Les données ne pourront pas être récupérées.</p>
|
<p className="text-destructive font-medium">Cette opération est irréversible.</p>
|
||||||
</div>
|
</div>
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
@@ -374,11 +402,12 @@ export default function VeilleDashboard() {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Onglets */}
|
|
||||||
|
{/* Onglets type de veille + bouton Lu/Non lu */}
|
||||||
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
<Tabs value={activeTab} onValueChange={(v) => { setActiveTab(v as TypeVeille | "all"); setPage(1); }}>
|
<Tabs value={activeTab} onValueChange={(v) => { setActiveTab(v as TypeVeille | "all"); setPage(1); }}>
|
||||||
<TabsList className="bg-muted/50">
|
<TabsList className="bg-muted/50">
|
||||||
<TabsTrigger value="all">Tous</TabsTrigger>
|
<TabsTrigger value="all">Tous</TabsTrigger>
|
||||||
@@ -388,6 +417,30 @@ export default function VeilleDashboard() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Bouton segmenté Lu / Non lu */}
|
||||||
|
<div className="flex items-center rounded-lg border border-border overflow-hidden shadow-sm">
|
||||||
|
{READ_FILTER_ORDER.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => setReadFilter(f)}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1.5 text-xs font-medium transition-colors flex items-center gap-1.5",
|
||||||
|
readFilter === f
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-background text-muted-foreground hover:bg-muted/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{f === "unread" && <EyeOff size={12} />}
|
||||||
|
{f === "read" && <Eye size={12} />}
|
||||||
|
{READ_FILTER_LABELS[f]}
|
||||||
|
{f === "unread" && unreadCount > 0 && (
|
||||||
|
<span className="ml-0.5 inline-flex items-center justify-center min-w-[16px] h-4 px-1 rounded-full bg-primary-foreground/20 text-[10px] font-bold">{unreadCount}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Filtres */}
|
{/* Filtres */}
|
||||||
<FilterBar
|
<FilterBar
|
||||||
filters={filterOptions}
|
filters={filterOptions}
|
||||||
@@ -406,11 +459,23 @@ export default function VeilleDashboard() {
|
|||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||||
<FileSearch size={48} className="text-muted-foreground/30 mb-4" />
|
<FileSearch size={48} className="text-muted-foreground/30 mb-4" />
|
||||||
<p className="text-muted-foreground font-medium">Aucun résultat trouvé</p>
|
<p className="text-muted-foreground font-medium">
|
||||||
<p className="text-muted-foreground/60 text-sm mt-1">Modifiez vos filtres ou importez des données</p>
|
{readFilter === "unread" ? "Aucun article non lu" : "Aucun résultat trouvé"}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground/60 text-sm mt-1">
|
||||||
|
{readFilter === "unread"
|
||||||
|
? "Tous les articles ont été lus, ou modifiez le filtre Lu/Non lu"
|
||||||
|
: "Modifiez vos filtres ou importez des données"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : viewMode === "list" ? (
|
) : viewMode === "list" ? (
|
||||||
<VeilleListView items={items} onDetail={openDetail} readIds={readIds} />
|
<VeilleListView
|
||||||
|
items={items}
|
||||||
|
onDetail={openDetail}
|
||||||
|
readIds={readIds}
|
||||||
|
selectedRowId={selectedRowId}
|
||||||
|
onSelectRow={setSelectedRowId}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<VeilleGridView items={items} onDetail={openDetail} readIds={readIds} />
|
<VeilleGridView items={items} onDetail={openDetail} readIds={readIds} />
|
||||||
)}
|
)}
|
||||||
@@ -440,13 +505,25 @@ export default function VeilleDashboard() {
|
|||||||
|
|
||||||
// ─── Vue Liste ────────────────────────────────────────────────────────────────
|
// ─── Vue Liste ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function VeilleListView({ items, onDetail, readIds }: { items: VeilleItem[]; onDetail: (item: VeilleItem) => void; readIds: Set<number> }) {
|
function VeilleListView({
|
||||||
|
items,
|
||||||
|
onDetail,
|
||||||
|
readIds,
|
||||||
|
selectedRowId,
|
||||||
|
onSelectRow,
|
||||||
|
}: {
|
||||||
|
items: VeilleItem[];
|
||||||
|
onDetail: (item: VeilleItem) => void;
|
||||||
|
readIds: Set<number>;
|
||||||
|
selectedRowId: number | null;
|
||||||
|
onSelectRow: (id: number | null) => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border overflow-hidden shadow-sm">
|
<div className="rounded-xl border border-border overflow-hidden shadow-sm">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-muted/50 border-b border-border">
|
<tr className="bg-muted/60 border-b border-border">
|
||||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-8">#</th>
|
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-8">#</th>
|
||||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground">Titre</th>
|
<th className="text-left px-4 py-3 font-semibold text-muted-foreground">Titre</th>
|
||||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-32">Type</th>
|
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-32">Type</th>
|
||||||
@@ -456,18 +533,48 @@ function VeilleListView({ items, onDetail, readIds }: { items: VeilleItem[]; onD
|
|||||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-20">Actions</th>
|
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-20">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody>
|
||||||
{items.map((item, idx) => (
|
{items.map((item, idx) => {
|
||||||
<tr key={item.id} className={cn("hover:bg-muted/30 transition-colors border-l-4", TYPE_ACCENT[item.typeVeille] || "border-l-transparent", !readIds.has(item.id) && "bg-blue-50/30")}>
|
const isSelected = selectedRowId === item.id;
|
||||||
|
const isRead = readIds.has(item.id);
|
||||||
|
// Alternance de couleurs : pair = blanc, impair = gris très léger
|
||||||
|
const isEven = idx % 2 === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => onSelectRow(isSelected ? null : item.id)}
|
||||||
|
className={cn(
|
||||||
|
"border-b border-border/50 border-l-4 cursor-pointer transition-colors",
|
||||||
|
TYPE_ACCENT[item.typeVeille] || "border-l-transparent",
|
||||||
|
// Ligne sélectionnée — priorité maximale
|
||||||
|
isSelected
|
||||||
|
? "bg-primary/10 hover:bg-primary/15"
|
||||||
|
// Non sélectionnée : alternance pair/impair
|
||||||
|
: isEven
|
||||||
|
? "bg-white hover:bg-primary/5"
|
||||||
|
: "bg-slate-50/80 hover:bg-primary/5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<td className="px-4 py-3 text-muted-foreground/50 text-xs">{idx + 1}</td>
|
<td className="px-4 py-3 text-muted-foreground/50 text-xs">{idx + 1}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="max-w-md flex items-start gap-2">
|
<div className="max-w-md flex items-start gap-2">
|
||||||
{!readIds.has(item.id) && (
|
{!isRead && (
|
||||||
<span className="mt-1.5 w-2 h-2 rounded-full bg-primary flex-shrink-0" title="Non lu" />
|
<span className="mt-1.5 w-2 h-2 rounded-full bg-primary flex-shrink-0" title="Non lu" />
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<p className={cn("font-medium line-clamp-2 leading-snug", readIds.has(item.id) ? "text-muted-foreground" : "text-foreground")}>{item.titre}</p>
|
<p className={cn(
|
||||||
{(item.iaResume || item.resume) && <p className="text-xs text-muted-foreground mt-1 line-clamp-2">{item.iaResume || item.resume}</p>}
|
"font-medium line-clamp-2 leading-snug",
|
||||||
|
isRead ? "text-muted-foreground" : "text-foreground",
|
||||||
|
isSelected && "text-primary font-semibold"
|
||||||
|
)}>
|
||||||
|
{item.titre}
|
||||||
|
</p>
|
||||||
|
{(item.iaResume || item.resume) && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||||
|
{item.iaResume || item.resume}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -478,10 +585,11 @@ function VeilleListView({ items, onDetail, readIds }: { items: VeilleItem[]; onD
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-muted-foreground text-xs">{item.niveau || "—"}</td>
|
<td className="px-4 py-3 text-muted-foreground text-xs">{item.niveau || "—"}</td>
|
||||||
<td className="px-4 py-3 text-muted-foreground text-xs">{item.territoire || "—"}</td>
|
<td className="px-4 py-3 text-muted-foreground text-xs">{item.territoire || "—"}</td>
|
||||||
<td className="px-4 py-3 text-muted-foreground text-xs whitespace-nowrap">{formatDate(item.datePublication) || "—"}</td>
|
<td className="px-4 py-3 text-muted-foreground text-xs whitespace-nowrap">
|
||||||
<td className="px-4 py-3">
|
{formatDate(item.datePublication) || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{/* Bouton Détail — bleu */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => onDetail(item)}
|
onClick={() => onDetail(item)}
|
||||||
className="inline-flex items-center justify-center w-7 h-7 rounded-md bg-blue-50 text-blue-600 hover:bg-blue-100 hover:text-blue-700 transition-colors border border-blue-200"
|
className="inline-flex items-center justify-center w-7 h-7 rounded-md bg-blue-50 text-blue-600 hover:bg-blue-100 hover:text-blue-700 transition-colors border border-blue-200"
|
||||||
@@ -489,7 +597,6 @@ function VeilleListView({ items, onDetail, readIds }: { items: VeilleItem[]; onD
|
|||||||
>
|
>
|
||||||
<Eye size={13} />
|
<Eye size={13} />
|
||||||
</button>
|
</button>
|
||||||
{/* Bouton Lien externe — vert */}
|
|
||||||
{item.lien && (
|
{item.lien && (
|
||||||
<a
|
<a
|
||||||
href={item.lien}
|
href={item.lien}
|
||||||
@@ -504,7 +611,8 @@ function VeilleListView({ items, onDetail, readIds }: { items: VeilleItem[]; onD
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -514,18 +622,32 @@ function VeilleListView({ items, onDetail, readIds }: { items: VeilleItem[]; onD
|
|||||||
|
|
||||||
// ─── Vue Vignettes ────────────────────────────────────────────────────────────
|
// ─── Vue Vignettes ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function VeilleGridView({ items, onDetail, readIds }: { items: VeilleItem[]; onDetail: (item: VeilleItem) => void; readIds: Set<number> }) {
|
function VeilleGridView({
|
||||||
|
items,
|
||||||
|
onDetail,
|
||||||
|
readIds,
|
||||||
|
}: {
|
||||||
|
items: VeilleItem[];
|
||||||
|
onDetail: (item: VeilleItem) => void;
|
||||||
|
readIds: Set<number>;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<Card key={item.id} className={cn("group hover:shadow-md transition-all duration-200 border-border overflow-hidden border-l-4", TYPE_ACCENT[item.typeVeille] || "", !readIds.has(item.id) && "ring-1 ring-primary/20")}>
|
<Card
|
||||||
|
key={item.id}
|
||||||
|
className={cn(
|
||||||
|
"group hover:shadow-md transition-all duration-200 border-border overflow-hidden border-l-4",
|
||||||
|
TYPE_ACCENT[item.typeVeille] || "",
|
||||||
|
!readIds.has(item.id) && "ring-1 ring-primary/20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<CardHeader className="pb-2 pt-4 px-4">
|
<CardHeader className="pb-2 pt-4 px-4">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<Badge variant="outline" className={cn("text-xs flex-shrink-0", TYPE_COLORS[item.typeVeille])}>
|
<Badge variant="outline" className={cn("text-xs flex-shrink-0", TYPE_COLORS[item.typeVeille])}>
|
||||||
{TYPE_LABELS[item.typeVeille as TypeVeille] || item.typeVeille}
|
{TYPE_LABELS[item.typeVeille as TypeVeille] || item.typeVeille}
|
||||||
</Badge>
|
</Badge>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
{/* Bouton Détail */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => onDetail(item)}
|
onClick={() => onDetail(item)}
|
||||||
className="text-muted-foreground hover:text-primary transition-colors"
|
className="text-muted-foreground hover:text-primary transition-colors"
|
||||||
@@ -534,19 +656,35 @@ function VeilleGridView({ items, onDetail, readIds }: { items: VeilleItem[]; onD
|
|||||||
<Eye size={14} />
|
<Eye size={14} />
|
||||||
</button>
|
</button>
|
||||||
{item.lien && (
|
{item.lien && (
|
||||||
<a href={item.lien} target="_blank" rel="noopener noreferrer" className="text-muted-foreground hover:text-accent transition-colors">
|
<a
|
||||||
|
href={item.lien}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-muted-foreground hover:text-accent transition-colors"
|
||||||
|
>
|
||||||
<ExternalLink size={14} />
|
<ExternalLink size={14} />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-1.5 mt-2">
|
<div className="flex items-start gap-1.5 mt-2">
|
||||||
{!readIds.has(item.id) && <span className="mt-1 w-2 h-2 rounded-full bg-primary flex-shrink-0" title="Non lu" />}
|
{!readIds.has(item.id) && (
|
||||||
<h3 className={cn("font-semibold text-sm leading-snug line-clamp-3", readIds.has(item.id) ? "text-muted-foreground" : "text-foreground")}>{item.titre}</h3>
|
<span className="mt-1 w-2 h-2 rounded-full bg-primary flex-shrink-0" title="Non lu" />
|
||||||
|
)}
|
||||||
|
<h3 className={cn(
|
||||||
|
"font-semibold text-sm leading-snug line-clamp-3",
|
||||||
|
readIds.has(item.id) ? "text-muted-foreground" : "text-foreground"
|
||||||
|
)}>
|
||||||
|
{item.titre}
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-4 space-y-2">
|
<CardContent className="px-4 pb-4 space-y-2">
|
||||||
{(item.iaResume || item.resume) && <p className="text-xs text-muted-foreground line-clamp-3 leading-relaxed">{item.iaResume || item.resume}</p>}
|
{(item.iaResume || item.resume) && (
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-3 leading-relaxed">
|
||||||
|
{item.iaResume || item.resume}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||||
{item.territoire && (
|
{item.territoire && (
|
||||||
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-teal-50 text-teal-700 border border-teal-200">
|
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-teal-50 text-teal-700 border border-teal-200">
|
||||||
|
|||||||
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
|
||||||
8
drizzle/0010_graceful_nova.sql
Normal file
8
drizzle/0010_graceful_nova.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE `processed_dedup_keys` (
|
||||||
|
`id` int AUTO_INCREMENT NOT NULL,
|
||||||
|
`dedupKey` varchar(64) NOT NULL,
|
||||||
|
`feedType` enum('veille','aap') NOT NULL,
|
||||||
|
`processedAt` timestamp NOT NULL DEFAULT (now()),
|
||||||
|
CONSTRAINT `processed_dedup_keys_id` PRIMARY KEY(`id`),
|
||||||
|
CONSTRAINT `processed_dedup_keys_dedupKey_unique` UNIQUE(`dedupKey`)
|
||||||
|
);
|
||||||
2
drizzle/0011_sharp_gambit.sql
Normal file
2
drizzle/0011_sharp_gambit.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `local_users` ADD `azureAdId` varchar(128);--> statement-breakpoint
|
||||||
|
ALTER TABLE `local_users` ADD CONSTRAINT `local_users_azureAdId_unique` UNIQUE(`azureAdId`);
|
||||||
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`)
|
||||||
|
);
|
||||||
1038
drizzle/meta/0010_snapshot.json
Normal file
1038
drizzle/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1051
drizzle/meta/0011_snapshot.json
Normal file
1051
drizzle/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
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
@@ -71,6 +71,34 @@
|
|||||||
"when": 1781683138994,
|
"when": 1781683138994,
|
||||||
"tag": "0009_fat_rocket_racer",
|
"tag": "0009_fat_rocket_racer",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1782978530081,
|
||||||
|
"tag": "0010_graceful_nova",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "5",
|
||||||
|
"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,
|
mysqlTable,
|
||||||
text,
|
text,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
uniqueIndex,
|
||||||
varchar,
|
varchar,
|
||||||
json,
|
json,
|
||||||
} from "drizzle-orm/mysql-core";
|
} from "drizzle-orm/mysql-core";
|
||||||
@@ -36,6 +37,7 @@ export const localUsers = mysqlTable("local_users", {
|
|||||||
passwordHash: varchar("passwordHash", { length: 255 }).notNull(),
|
passwordHash: varchar("passwordHash", { length: 255 }).notNull(),
|
||||||
role: mysqlEnum("role", ["admin", "user", "readonly"]).default("user").notNull(),
|
role: mysqlEnum("role", ["admin", "user", "readonly"]).default("user").notNull(),
|
||||||
isActive: boolean("isActive").default(true).notNull(),
|
isActive: boolean("isActive").default(true).notNull(),
|
||||||
|
azureAdId: varchar("azureAdId", { length: 128 }).unique(),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||||
lastSignedIn: timestamp("lastSignedIn"),
|
lastSignedIn: timestamp("lastSignedIn"),
|
||||||
@@ -135,6 +137,25 @@ export const importLogs = mysqlTable("import_logs", {
|
|||||||
export type ImportLog = typeof importLogs.$inferSelect;
|
export type ImportLog = typeof importLogs.$inferSelect;
|
||||||
export type InsertImportLog = typeof importLogs.$inferInsert;
|
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 ───────────────────────────────────────────────────────────
|
// ─── Boîte à idées ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const ideas = mysqlTable("ideas", {
|
export const ideas = mysqlTable("ideas", {
|
||||||
@@ -203,13 +224,34 @@ export type InsertRssSettings = typeof rssSettings.$inferInsert;
|
|||||||
|
|
||||||
// ─── Suivi de lecture des articles ──────────────────────────────────────────
|
// ─── Suivi de lecture des articles ──────────────────────────────────────────
|
||||||
|
|
||||||
export const articleReads = mysqlTable("article_reads", {
|
export const articleReads = mysqlTable(
|
||||||
|
"article_reads",
|
||||||
|
{
|
||||||
id: int("id").autoincrement().primaryKey(),
|
id: int("id").autoincrement().primaryKey(),
|
||||||
userId: int("userId").notNull(),
|
userId: int("userId").notNull(),
|
||||||
articleType: mysqlEnum("articleType", ["veille", "aap"]).notNull(),
|
articleType: mysqlEnum("articleType", ["veille", "aap"]).notNull(),
|
||||||
articleId: int("articleId").notNull(),
|
articleId: int("articleId").notNull(),
|
||||||
readAt: timestamp("readAt").defaultNow().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 ArticleRead = typeof articleReads.$inferSelect;
|
||||||
export type InsertArticleRead = typeof articleReads.$inferInsert;
|
export type InsertArticleRead = typeof articleReads.$inferInsert;
|
||||||
|
|
||||||
|
// ─── Tombstones : articles déjà traités (empêche la réinsertion après purge) ──
|
||||||
|
|
||||||
|
export const processedDedupKeys = mysqlTable("processed_dedup_keys", {
|
||||||
|
id: int("id").autoincrement().primaryKey(),
|
||||||
|
dedupKey: varchar("dedupKey", { length: 64 }).notNull().unique(),
|
||||||
|
// Type de contenu (veille ou aap)
|
||||||
|
feedType: mysqlEnum("feedType", ["veille", "aap"]).notNull(),
|
||||||
|
// Date à laquelle l'article a été traité pour la première fois
|
||||||
|
processedAt: timestamp("processedAt").defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ProcessedDedupKey = typeof processedDedupKeys.$inferSelect;
|
||||||
|
export type InsertProcessedDedupKey = typeof processedDedupKeys.$inferInsert;
|
||||||
|
|||||||
@@ -10,11 +10,13 @@
|
|||||||
"check": "tsc --noEmit",
|
"check": "tsc --noEmit",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"verify": "pnpm check && pnpm test && pnpm build",
|
||||||
"db:push": "drizzle-kit generate && drizzle-kit migrate"
|
"db:push": "drizzle-kit generate && drizzle-kit migrate"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.693.0",
|
"@aws-sdk/client-s3": "^3.693.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.693.0",
|
"@aws-sdk/s3-request-presigner": "^3.693.0",
|
||||||
|
"@azure/msal-node": "^5.3.1",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
|
|||||||
106
pnpm-lock.yaml
generated
106
pnpm-lock.yaml
generated
@@ -22,6 +22,9 @@ importers:
|
|||||||
'@aws-sdk/s3-request-presigner':
|
'@aws-sdk/s3-request-presigner':
|
||||||
specifier: ^3.693.0
|
specifier: ^3.693.0
|
||||||
version: 3.907.0
|
version: 3.907.0
|
||||||
|
'@azure/msal-node':
|
||||||
|
specifier: ^5.3.1
|
||||||
|
version: 5.3.1
|
||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^5.2.2
|
specifier: ^5.2.2
|
||||||
version: 5.2.2(react-hook-form@7.64.0(react@19.2.1))
|
version: 5.2.2(react-hook-form@7.64.0(react@19.2.1))
|
||||||
@@ -477,6 +480,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-ORHRQ2tmvnBXc8t/X9Z8IcSbBA4xTLKuN873FopzklHMeqBst7YG0d+AX97inkvDX+NChYtSr+qGfcqGFaI8Zw==}
|
resolution: {integrity: sha512-ORHRQ2tmvnBXc8t/X9Z8IcSbBA4xTLKuN873FopzklHMeqBst7YG0d+AX97inkvDX+NChYtSr+qGfcqGFaI8Zw==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
|
'@azure/msal-common@16.11.0':
|
||||||
|
resolution: {integrity: sha512-UikJOtMwkFpZNzTH6Dqk8UTUPbow15zH3e0UjGYZy69lYENW/S05gMLhbxI2eonz66uALhIljvhsSMEb6+O30g==}
|
||||||
|
engines: {node: '>=0.8.0'}
|
||||||
|
|
||||||
|
'@azure/msal-node@5.3.1':
|
||||||
|
resolution: {integrity: sha512-sqqv3L1UOI4KDXonNtbxPYUgbSWVXqxvmmb6BUw9n4P/UXgG+cVur3dLWQN4Cz7qQ+UJROCCxMXlksm7gIq0Sw==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
'@babel/code-frame@7.27.1':
|
'@babel/code-frame@7.27.1':
|
||||||
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
|
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -2481,6 +2492,9 @@ packages:
|
|||||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
buffer-equal-constant-time@1.0.1:
|
||||||
|
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||||
|
|
||||||
buffer-from@1.1.2:
|
buffer-from@1.1.2:
|
||||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||||
|
|
||||||
@@ -2968,6 +2982,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
ecdsa-sig-formatter@1.0.11:
|
||||||
|
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||||
|
|
||||||
ee-first@1.1.1:
|
ee-first@1.1.1:
|
||||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||||
|
|
||||||
@@ -3336,6 +3353,16 @@ packages:
|
|||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
jsonwebtoken@9.0.3:
|
||||||
|
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
|
||||||
|
engines: {node: '>=12', npm: '>=6'}
|
||||||
|
|
||||||
|
jwa@2.0.1:
|
||||||
|
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
|
||||||
|
|
||||||
|
jws@4.0.1:
|
||||||
|
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
||||||
|
|
||||||
katex@0.16.25:
|
katex@0.16.25:
|
||||||
resolution: {integrity: sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q==}
|
resolution: {integrity: sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -3427,6 +3454,27 @@ packages:
|
|||||||
lodash-es@4.17.21:
|
lodash-es@4.17.21:
|
||||||
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
|
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
|
||||||
|
|
||||||
|
lodash.includes@4.3.0:
|
||||||
|
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||||
|
|
||||||
|
lodash.isboolean@3.0.3:
|
||||||
|
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
|
||||||
|
|
||||||
|
lodash.isinteger@4.0.4:
|
||||||
|
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
|
||||||
|
|
||||||
|
lodash.isnumber@3.0.3:
|
||||||
|
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
|
||||||
|
|
||||||
|
lodash.isplainobject@4.0.6:
|
||||||
|
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||||
|
|
||||||
|
lodash.isstring@4.0.1:
|
||||||
|
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
|
||||||
|
|
||||||
|
lodash.once@4.1.1:
|
||||||
|
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
||||||
|
|
||||||
lodash@4.17.21:
|
lodash@4.17.21:
|
||||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||||
|
|
||||||
@@ -4001,6 +4049,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
semver@7.8.5:
|
||||||
|
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
send@0.19.0:
|
send@0.19.0:
|
||||||
resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==}
|
resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -4949,6 +5002,13 @@ snapshots:
|
|||||||
|
|
||||||
'@aws/lambda-invoke-store@0.0.1': {}
|
'@aws/lambda-invoke-store@0.0.1': {}
|
||||||
|
|
||||||
|
'@azure/msal-common@16.11.0': {}
|
||||||
|
|
||||||
|
'@azure/msal-node@5.3.1':
|
||||||
|
dependencies:
|
||||||
|
'@azure/msal-common': 16.11.0
|
||||||
|
jsonwebtoken: 9.0.3
|
||||||
|
|
||||||
'@babel/code-frame@7.27.1':
|
'@babel/code-frame@7.27.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/helper-validator-identifier': 7.27.1
|
'@babel/helper-validator-identifier': 7.27.1
|
||||||
@@ -6953,6 +7013,8 @@ snapshots:
|
|||||||
node-releases: 2.0.23
|
node-releases: 2.0.23
|
||||||
update-browserslist-db: 1.1.3(browserslist@4.26.3)
|
update-browserslist-db: 1.1.3(browserslist@4.26.3)
|
||||||
|
|
||||||
|
buffer-equal-constant-time@1.0.1: {}
|
||||||
|
|
||||||
buffer-from@1.1.2: {}
|
buffer-from@1.1.2: {}
|
||||||
|
|
||||||
busboy@1.6.0:
|
busboy@1.6.0:
|
||||||
@@ -7347,6 +7409,10 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
|
|
||||||
|
ecdsa-sig-formatter@1.0.11:
|
||||||
|
dependencies:
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
ee-first@1.1.1: {}
|
ee-first@1.1.1: {}
|
||||||
|
|
||||||
electron-to-chromium@1.5.230: {}
|
electron-to-chromium@1.5.230: {}
|
||||||
@@ -7837,6 +7903,30 @@ snapshots:
|
|||||||
|
|
||||||
json5@2.2.3: {}
|
json5@2.2.3: {}
|
||||||
|
|
||||||
|
jsonwebtoken@9.0.3:
|
||||||
|
dependencies:
|
||||||
|
jws: 4.0.1
|
||||||
|
lodash.includes: 4.3.0
|
||||||
|
lodash.isboolean: 3.0.3
|
||||||
|
lodash.isinteger: 4.0.4
|
||||||
|
lodash.isnumber: 3.0.3
|
||||||
|
lodash.isplainobject: 4.0.6
|
||||||
|
lodash.isstring: 4.0.1
|
||||||
|
lodash.once: 4.1.1
|
||||||
|
ms: 2.1.3
|
||||||
|
semver: 7.8.5
|
||||||
|
|
||||||
|
jwa@2.0.1:
|
||||||
|
dependencies:
|
||||||
|
buffer-equal-constant-time: 1.0.1
|
||||||
|
ecdsa-sig-formatter: 1.0.11
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
|
jws@4.0.1:
|
||||||
|
dependencies:
|
||||||
|
jwa: 2.0.1
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
katex@0.16.25:
|
katex@0.16.25:
|
||||||
dependencies:
|
dependencies:
|
||||||
commander: 8.3.0
|
commander: 8.3.0
|
||||||
@@ -7910,6 +8000,20 @@ snapshots:
|
|||||||
|
|
||||||
lodash-es@4.17.21: {}
|
lodash-es@4.17.21: {}
|
||||||
|
|
||||||
|
lodash.includes@4.3.0: {}
|
||||||
|
|
||||||
|
lodash.isboolean@3.0.3: {}
|
||||||
|
|
||||||
|
lodash.isinteger@4.0.4: {}
|
||||||
|
|
||||||
|
lodash.isnumber@3.0.3: {}
|
||||||
|
|
||||||
|
lodash.isplainobject@4.0.6: {}
|
||||||
|
|
||||||
|
lodash.isstring@4.0.1: {}
|
||||||
|
|
||||||
|
lodash.once@4.1.1: {}
|
||||||
|
|
||||||
lodash@4.17.21: {}
|
lodash@4.17.21: {}
|
||||||
|
|
||||||
long@5.3.2: {}
|
long@5.3.2: {}
|
||||||
@@ -8774,6 +8878,8 @@ snapshots:
|
|||||||
|
|
||||||
semver@6.3.1: {}
|
semver@6.3.1: {}
|
||||||
|
|
||||||
|
semver@7.8.5: {}
|
||||||
|
|
||||||
send@0.19.0:
|
send@0.19.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 2.6.9
|
debug: 2.6.9
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import { serveStatic, setupVite } from "./vite";
|
|||||||
import { runFullImport } from "../importer";
|
import { runFullImport } from "../importer";
|
||||||
import uploadRoutes from "../uploadRoutes";
|
import uploadRoutes from "../uploadRoutes";
|
||||||
import scheduledRoutes from "../scheduledRoutes";
|
import scheduledRoutes from "../scheduledRoutes";
|
||||||
import { ensureAdminExists } from "../localAuth";
|
import { ensureAdminExists, generateLocalToken } from "../localAuth";
|
||||||
import { getSetting, getRssSettings } from "../db";
|
import { isAzureAdConfigured, getAzureAuthUrl, handleAzureCallback } from "../azureAuth";
|
||||||
|
import { getLocalUserByAzureAdId, getLocalUserByEmail, upsertLocalUserAzure, getSetting, purgeOldArticles } from "../db";
|
||||||
import { runRssFetch } from "../rssEngine";
|
import { runRssFetch } from "../rssEngine";
|
||||||
|
|
||||||
function isPortAvailable(port: number): Promise<boolean> {
|
function isPortAvailable(port: number): Promise<boolean> {
|
||||||
@@ -30,14 +31,43 @@ async function findAvailablePort(startPort: number = 3000): Promise<number> {
|
|||||||
throw new Error(`No available port found starting from ${startPort}`);
|
throw new Error(`No available port found starting from ${startPort}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Tâche d'import quotidien ─────────────────────────────────────────────────
|
// ─── Tâche d'import quotidien + lecture RSS ──────────────────────────────────
|
||||||
let cronJob: ReturnType<typeof cron.schedule> | null = null;
|
let cronJob: ReturnType<typeof cron.schedule> | null = null;
|
||||||
|
|
||||||
async function scheduleDailyImport() {
|
export async function scheduleDailyImport() {
|
||||||
// Heure configurable, défaut 06:00
|
|
||||||
const importTime = (await getSetting("import_time")) || "06:00";
|
const importTime = (await getSetting("import_time")) || "06:00";
|
||||||
|
const fetchMode = (await getSetting("fetch_mode")) || "scheduled";
|
||||||
|
const fetchIntervalMinutes = parseInt((await getSetting("fetch_interval_minutes")) || "1440", 10);
|
||||||
|
|
||||||
|
const fetchDayOfWeek = parseInt((await getSetting("fetch_day_of_week")) || "1", 10); // 0=dim, 1=lun, ..., 6=sam
|
||||||
|
const fetchDayOfMonth = parseInt((await getSetting("fetch_day_of_month")) || "1", 10); // 1-28
|
||||||
|
const DAYS_FR = ["dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi"];
|
||||||
|
|
||||||
|
let cronExpr: string;
|
||||||
|
if (fetchMode === "interval") {
|
||||||
|
const intervalMin = Math.max(60, fetchIntervalMinutes);
|
||||||
|
if (intervalMin % 60 === 0) {
|
||||||
|
const hours = intervalMin / 60;
|
||||||
|
cronExpr = hours === 24 ? `0 0 * * *` : `0 */${hours} * * *`;
|
||||||
|
} else {
|
||||||
|
cronExpr = `*/${intervalMin} * * * *`;
|
||||||
|
}
|
||||||
|
console.log(`[Cron] Mode intervalle — toutes les ${intervalMin} minutes (${cronExpr})`);
|
||||||
|
} else if (fetchMode === "weekly") {
|
||||||
const [hour, minute] = importTime.split(":").map(Number);
|
const [hour, minute] = importTime.split(":").map(Number);
|
||||||
const cronExpr = `0 ${minute ?? 0} ${hour ?? 6} * * *`;
|
cronExpr = `0 ${minute ?? 0} ${hour ?? 6} * * ${fetchDayOfWeek}`;
|
||||||
|
console.log(`[Cron] Mode hebdomadaire — chaque ${DAYS_FR[fetchDayOfWeek] ?? "lundi"} à ${importTime} (${cronExpr})`);
|
||||||
|
} else if (fetchMode === "monthly") {
|
||||||
|
const [hour, minute] = importTime.split(":").map(Number);
|
||||||
|
cronExpr = `0 ${minute ?? 0} ${hour ?? 6} ${fetchDayOfMonth} * *`;
|
||||||
|
console.log(`[Cron] Mode mensuel — le ${fetchDayOfMonth} de chaque mois à ${importTime} (${cronExpr})`);
|
||||||
|
} else {
|
||||||
|
// scheduled (heure fixe quotidienne) ou daily
|
||||||
|
const [hour, minute] = importTime.split(":").map(Number);
|
||||||
|
cronExpr = `0 ${minute ?? 0} ${hour ?? 6} * * *`;
|
||||||
|
console.log(`[Cron] Mode quotidien — tous les jours à ${importTime} (${cronExpr})`);
|
||||||
|
}
|
||||||
|
|
||||||
if (cronJob) {
|
if (cronJob) {
|
||||||
cronJob.stop();
|
cronJob.stop();
|
||||||
cronJob = null;
|
cronJob = null;
|
||||||
@@ -45,85 +75,46 @@ async function scheduleDailyImport() {
|
|||||||
cronJob = cron.schedule(cronExpr, async () => {
|
cronJob = cron.schedule(cronExpr, async () => {
|
||||||
console.log(`[Cron] Import automatique démarré à ${new Date().toISOString()}`);
|
console.log(`[Cron] Import automatique démarré à ${new Date().toISOString()}`);
|
||||||
try {
|
try {
|
||||||
|
// 1. Import des fichiers Excel (Veille + AAP)
|
||||||
const result = await runFullImport();
|
const result = await runFullImport();
|
||||||
console.log(`[Cron] Import terminé — Veille: +${result.veille.newRows} | AAP: +${result.aap.newRows}`);
|
console.log(`[Cron] Import Excel terminé — Veille: +${result.veille.newRows} | AAP: +${result.aap.newRows}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[Cron] Erreur lors de l'import:", e);
|
console.error("[Cron] Erreur lors de l'import Excel:", e);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
console.log(`[Cron] Import quotidien planifié à ${importTime} (${cronExpr})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Planificateur RSS natif ──────────────────────────────────────────────────
|
|
||||||
let rssCronJob: ReturnType<typeof cron.schedule> | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Démarre (ou redémarre) le planificateur RSS en lisant la configuration
|
|
||||||
* depuis la table rss_settings. Peut être appelé au démarrage et à chaque
|
|
||||||
* modification des paramètres RSS via l'interface d'administration.
|
|
||||||
*/
|
|
||||||
export async function scheduleRssFetch() {
|
|
||||||
// Arrêter le cron existant s'il y en a un
|
|
||||||
if (rssCronJob) {
|
|
||||||
rssCronJob.stop();
|
|
||||||
rssCronJob = null;
|
|
||||||
console.log("[RSS Cron] Planificateur RSS arrêté.");
|
|
||||||
}
|
|
||||||
|
|
||||||
let settings = null;
|
|
||||||
try {
|
try {
|
||||||
settings = await getRssSettings();
|
// 2. Lecture des flux RSS
|
||||||
} catch (e) {
|
const rssSummary = await runRssFetch();
|
||||||
// En cas d'erreur (ex: colonne manquante lors d'une migration),
|
|
||||||
// utiliser les valeurs par défaut et planifier un retry dans 2 minutes
|
|
||||||
console.warn("[RSS Cron] Impossible de lire les paramètres RSS, utilisation des valeurs par défaut:", (e as Error).message);
|
|
||||||
setTimeout(() => scheduleRssFetch(), 2 * 60 * 1000);
|
|
||||||
// Démarrer quand même avec les valeurs par défaut
|
|
||||||
settings = { autoFetchEnabled: true, fetchMode: "interval" as const, fetchIntervalMinutes: 60, scheduledTime: "06:00" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!settings || !settings.autoFetchEnabled) {
|
|
||||||
console.log("[RSS Cron] Lecture automatique des flux RSS désactivée.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cronExpr: string;
|
|
||||||
|
|
||||||
if (settings.fetchMode === "interval") {
|
|
||||||
// Mode intervalle : toutes les N minutes
|
|
||||||
const intervalMin = Math.max(5, settings.fetchIntervalMinutes ?? 60);
|
|
||||||
if (intervalMin < 60) {
|
|
||||||
cronExpr = `*/${intervalMin} * * * *`;
|
|
||||||
} else if (intervalMin % 60 === 0) {
|
|
||||||
const hours = intervalMin / 60;
|
|
||||||
cronExpr = `0 */${hours} * * *`;
|
|
||||||
} else {
|
|
||||||
cronExpr = `*/${intervalMin} * * * *`;
|
|
||||||
}
|
|
||||||
console.log(`[RSS Cron] Mode intervalle — toutes les ${intervalMin} minutes (${cronExpr})`);
|
|
||||||
} else {
|
|
||||||
// Mode planifié : heure fixe quotidienne
|
|
||||||
const scheduledTime = settings.scheduledTime ?? "06:00";
|
|
||||||
const [hour, minute] = scheduledTime.split(":").map(Number);
|
|
||||||
cronExpr = `0 ${minute ?? 0} ${hour ?? 6} * * *`;
|
|
||||||
console.log(`[RSS Cron] Mode planifié — tous les jours à ${scheduledTime} (${cronExpr})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
rssCronJob = cron.schedule(cronExpr, async () => {
|
|
||||||
console.log(`[RSS Cron] Lecture des flux RSS démarrée à ${new Date().toISOString()}`);
|
|
||||||
try {
|
|
||||||
const summary = await runRssFetch();
|
|
||||||
console.log(
|
console.log(
|
||||||
`[RSS Cron] Lecture terminée — ${summary.totalFeeds} flux, ` +
|
`[Cron] Lecture RSS terminée — ${rssSummary.totalFeeds} flux, ` +
|
||||||
`+${summary.totalNewItems} nouveaux articles, ` +
|
`+${rssSummary.totalNewItems} nouveaux articles, ` +
|
||||||
`${summary.errorFeeds} erreur(s)`
|
`${rssSummary.errorFeeds} erreur(s)`
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[RSS Cron] Erreur lors de la lecture des flux:", e);
|
console.error("[Cron] Erreur lors de la lecture RSS:", e);
|
||||||
|
}
|
||||||
|
// 3. Purge des articles selon la règle de rétention
|
||||||
|
try {
|
||||||
|
const retentionStr = await getSetting("retention_months");
|
||||||
|
const retentionMonths = retentionStr ? parseInt(retentionStr, 10) : 0;
|
||||||
|
if (retentionMonths > 0) {
|
||||||
|
const purged = await purgeOldArticles(retentionMonths);
|
||||||
|
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) {
|
||||||
|
console.error("[Cron] Erreur lors de la purge de rétention:", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
console.log("[RSS Cron] Planificateur RSS démarré.");
|
/**
|
||||||
|
* Stub conservé pour compatibilité avec les imports existants dans routers.ts.
|
||||||
|
* La lecture RSS est désormais intégrée dans scheduleDailyImport().
|
||||||
|
*/
|
||||||
|
export async function scheduleRssFetch() {
|
||||||
|
// No-op : la lecture RSS est pilotée par le cron d'import quotidien (import_time)
|
||||||
|
console.log("[RSS Cron] Planificateur RSS indépendant désactivé — la lecture est pilotée par le cron d'import quotidien.");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
@@ -136,6 +127,87 @@ async function startServer() {
|
|||||||
registerOAuthRoutes(app);
|
registerOAuthRoutes(app);
|
||||||
app.use(uploadRoutes);
|
app.use(uploadRoutes);
|
||||||
app.use(scheduledRoutes);
|
app.use(scheduledRoutes);
|
||||||
|
|
||||||
|
// ─── Azure AD OAuth2 callback ─────────────────────────────────────────────
|
||||||
|
app.get("/api/auth/azure/callback", async (req, res) => {
|
||||||
|
const code = req.query.code as string | undefined;
|
||||||
|
const error = req.query.error as string | undefined;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
res.redirect(`/login?error=${encodeURIComponent("Connexion Microsoft refus\u00e9e")}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!code) {
|
||||||
|
res.redirect("/login?error=" + encodeURIComponent("Code OAuth manquant"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isAzureAdConfigured()) {
|
||||||
|
res.redirect("/login?error=" + encodeURIComponent("Azure AD non configur\u00e9"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const azureUser = await handleAzureCallback(code);
|
||||||
|
|
||||||
|
// Chercher par azureAdId puis par email
|
||||||
|
let user = await getLocalUserByAzureAdId(azureUser.azureAdId);
|
||||||
|
if (!user) user = await getLocalUserByEmail(azureUser.email);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Cr\u00e9er automatiquement avec r\u00f4le "user"
|
||||||
|
await upsertLocalUserAzure({
|
||||||
|
email: azureUser.email,
|
||||||
|
name: azureUser.name,
|
||||||
|
azureAdId: azureUser.azureAdId,
|
||||||
|
role: "user",
|
||||||
|
});
|
||||||
|
user = await getLocalUserByEmail(azureUser.email);
|
||||||
|
} else if (!user.azureAdId) {
|
||||||
|
// Lier le compte existant \u00e0 Azure AD
|
||||||
|
await upsertLocalUserAzure({
|
||||||
|
email: user.email ?? azureUser.email,
|
||||||
|
azureAdId: azureUser.azureAdId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user || !user.isActive) {
|
||||||
|
res.redirect("/login?error=" + encodeURIComponent("Compte inactif ou introuvable"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre \u00e0 jour lastSignedIn
|
||||||
|
const db = await (await import("../db")).getDb();
|
||||||
|
if (db) {
|
||||||
|
const { localUsers } = await import("../../drizzle/schema");
|
||||||
|
const { eq } = await import("drizzle-orm");
|
||||||
|
await db.update(localUsers).set({ lastSignedIn: new Date() }).where(eq(localUsers.id, user.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// G\u00e9n\u00e9rer le token JWT local et le stocker dans le cookie
|
||||||
|
const token = await generateLocalToken(user.id, user.role);
|
||||||
|
res.cookie("veille_local_auth", token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retourner les infos user en JSON pour que le frontend hydrate LocalAuthContext
|
||||||
|
const userPayload = JSON.stringify({
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
username: user.username ?? null,
|
||||||
|
email: user.email ?? null,
|
||||||
|
role: user.role,
|
||||||
|
});
|
||||||
|
// Rediriger vers une page de callback qui hydrate le contexte
|
||||||
|
res.redirect(`/azure-callback?user=${encodeURIComponent(userPayload)}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[Azure AD] Erreur callback:", err.message);
|
||||||
|
res.redirect("/login?error=" + encodeURIComponent("Erreur d'authentification Microsoft"));
|
||||||
|
}
|
||||||
|
});
|
||||||
app.use(
|
app.use(
|
||||||
"/api/trpc",
|
"/api/trpc",
|
||||||
createExpressMiddleware({ router: appRouter, createContext })
|
createExpressMiddleware({ router: appRouter, createContext })
|
||||||
@@ -159,11 +231,23 @@ async function startServer() {
|
|||||||
try {
|
try {
|
||||||
await ensureAdminExists();
|
await ensureAdminExists();
|
||||||
await scheduleDailyImport();
|
await scheduleDailyImport();
|
||||||
await scheduleRssFetch();
|
// Purge de rétention au démarrage
|
||||||
|
const retentionStr = await getSetting("retention_months");
|
||||||
|
const retentionMonths = retentionStr ? parseInt(retentionStr, 10) : 0;
|
||||||
|
if (retentionMonths > 0) {
|
||||||
|
const purged = await purgeOldArticles(retentionMonths);
|
||||||
|
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) {
|
} catch (e) {
|
||||||
console.error("[Init] Erreur d'initialisation:", e);
|
console.error("[Init] Erreur d'initialisation:", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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.typeVeille).toBe("informationnelle");
|
||||||
expect(result.relevant).toBe(true); // on suppose pertinent par défaut
|
expect(result.relevant).toBe(true); // on suppose pertinent par défaut
|
||||||
expect(result.reason).toContain("règles");
|
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 () => {
|
it("retourne le fallback (rules) si le LLM retourne un JSON malformé", async () => {
|
||||||
|
|||||||
@@ -19,37 +19,55 @@ export type AiTypeVeille =
|
|||||||
| "technologique"
|
| "technologique"
|
||||||
| "informationnelle";
|
| "informationnelle";
|
||||||
|
|
||||||
|
export type AiCategorieAap =
|
||||||
|
| "Handicap"
|
||||||
|
| "Précarité"
|
||||||
|
| "Enfance"
|
||||||
|
| "PA"
|
||||||
|
| "Sanitaire"
|
||||||
|
| "Autre";
|
||||||
|
|
||||||
export interface AiClassificationResult {
|
export interface AiClassificationResult {
|
||||||
/** L'article est-il pertinent pour le secteur médico-social ? */
|
/** L'article est-il pertinent pour le secteur médico-social ? */
|
||||||
relevant: boolean;
|
relevant: boolean;
|
||||||
/** Type de veille déduit par l'IA (null si non pertinent) */
|
/** Type de veille déduit par l'IA (null si non pertinent) */
|
||||||
typeVeille: AiTypeVeille | null;
|
typeVeille: AiTypeVeille | null;
|
||||||
|
/** Catégorie AAP déduite par l'IA (null si non AAP ou non pertinent) */
|
||||||
|
categorieAap: AiCategorieAap | null;
|
||||||
/** Explication courte de la décision */
|
/** Explication courte de la décision */
|
||||||
reason: string;
|
reason: string;
|
||||||
/** Indique si la classification a été faite par l'IA ou par les règles (fallback) */
|
/** Indique si la classification a été faite par l'IA ou par les règles (fallback) */
|
||||||
classifiedBy: "ia" | "rules";
|
classifiedBy: "ia" | "rules";
|
||||||
|
/** Cause technique du fallback, réservée au journal d'administration. */
|
||||||
|
technicalError: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Prompt unique : pertinence + type de veille ─────────────────────────────
|
function getTechnicalErrorMessage(error: unknown): string {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return message.slice(0, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
const PROMPT_CLASSIFICATION = `Tu es un expert du secteur social, médico-social et sanitaire en France, spécialisé dans les associations gestionnaires d'établissements et services.
|
// ─── Prompt veille stratégique : pertinence + type de veille ─────────────────
|
||||||
|
|
||||||
Analyse attentivement le texte fourni.
|
const PROMPT_CLASSIFICATION = `Tu es un expert des établissements et services sociaux et médico-sociaux (ESMS) en France.
|
||||||
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 :
|
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 ?"
|
||||||
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
|
|
||||||
|
|
||||||
Un article est NON PERTINENT si :
|
Un article est pertinent s'il concerne directement ou indirectement :
|
||||||
- Il traite de politique générale, économie, international, environnement, sport, culture, agriculture, pêche, tourisme, immobilier, technologie grand public, rénovation énergétique, etc.
|
• 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
|
||||||
- Il mentionne des personnes vulnérables de façon anecdotique sans lien direct avec les 5 secteurs ci-dessus
|
• 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
|
||||||
- Le lien avec les secteurs d'activité de l'association est vague, indirect ou nécessite plusieurs degrés de déduction
|
• 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
|
||||||
|
|
||||||
Sois STRICT : en cas de doute, réponds pertinent: false.
|
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 :
|
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
|
- "reglementaire" : textes de loi, décrets, circulaires, obligations légales, réformes institutionnelles, instructions ministérielles
|
||||||
@@ -62,6 +80,38 @@ Réponds UNIQUEMENT avec un objet JSON valide, sans texte autour, sans balises m
|
|||||||
|
|
||||||
Si pertinent est false, typeVeille doit être null.`;
|
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.
|
||||||
|
|
||||||
|
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 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
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
En cas de doute, réponds pertinent: false.
|
||||||
|
|
||||||
|
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"}
|
||||||
|
|
||||||
|
Si pertinent est false, categorie doit être null.`;
|
||||||
|
|
||||||
// ─── Prompt résumé IA ─────────────────────────────────────────────────────────
|
// ─── Prompt résumé IA ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const PROMPT_RESUME = `Tu es un expert des établissements et services sociaux et médico-sociaux (ESMS).
|
const PROMPT_RESUME = `Tu es un expert des établissements et services sociaux et médico-sociaux (ESMS).
|
||||||
@@ -95,14 +145,8 @@ function parseJsonSafe<T>(text: string): T | null {
|
|||||||
// ─── Classification principale ────────────────────────────────────────────────
|
// ─── Classification principale ────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Classifie un article en une seule étape via l'IA.
|
* Classifie un article de veille stratégique : pertinence + type de veille.
|
||||||
* Détermine simultanément la pertinence et le type de veille.
|
|
||||||
* Retourne toujours un résultat (fallback sur rules en cas d'erreur).
|
* Retourne toujours un résultat (fallback sur rules en cas d'erreur).
|
||||||
*
|
|
||||||
* @param titre Titre de l'article
|
|
||||||
* @param resume Résumé / description de l'article
|
|
||||||
* @param fallbackFn Fonction de fallback appelée si l'IA échoue
|
|
||||||
* @param contenuPage Premiers paragraphes de l'article complet (optionnel, améliore la précision)
|
|
||||||
*/
|
*/
|
||||||
export async function classifyArticle(
|
export async function classifyArticle(
|
||||||
titre: string,
|
titre: string,
|
||||||
@@ -110,7 +154,6 @@ export async function classifyArticle(
|
|||||||
fallbackFn: () => { typeVeille: AiTypeVeille },
|
fallbackFn: () => { typeVeille: AiTypeVeille },
|
||||||
contenuPage?: string | null
|
contenuPage?: string | null
|
||||||
): Promise<AiClassificationResult> {
|
): Promise<AiClassificationResult> {
|
||||||
// Construire le contenu utilisateur en enrichissant avec le contenu de la page si disponible
|
|
||||||
const contenuSection = contenuPage
|
const contenuSection = contenuPage
|
||||||
? `\n\nExtrait de l'article (premiers paragraphes) : ${contenuPage}`
|
? `\n\nExtrait de l'article (premiers paragraphes) : ${contenuPage}`
|
||||||
: "";
|
: "";
|
||||||
@@ -133,10 +176,7 @@ export async function classifyArticle(
|
|||||||
pertinent: { type: "boolean" },
|
pertinent: { type: "boolean" },
|
||||||
typeVeille: {
|
typeVeille: {
|
||||||
anyOf: [
|
anyOf: [
|
||||||
{
|
{ type: "string", enum: ["reglementaire", "concurrentielle", "technologique", "informationnelle"] },
|
||||||
type: "string",
|
|
||||||
enum: ["reglementaire", "concurrentielle", "technologique", "informationnelle"],
|
|
||||||
},
|
|
||||||
{ type: "null" },
|
{ type: "null" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -151,31 +191,102 @@ export async function classifyArticle(
|
|||||||
|
|
||||||
const rawContent = response?.choices?.[0]?.message?.content;
|
const rawContent = response?.choices?.[0]?.message?.content;
|
||||||
if (!rawContent) throw new Error("Réponse LLM vide");
|
if (!rawContent) throw new Error("Réponse LLM vide");
|
||||||
|
|
||||||
const content = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent);
|
const content = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent);
|
||||||
const parsed = parseJsonSafe<{
|
const parsed = parseJsonSafe<{ pertinent: boolean; typeVeille: AiTypeVeille | null; raison: string }>(content);
|
||||||
pertinent: boolean;
|
|
||||||
typeVeille: AiTypeVeille | null;
|
|
||||||
raison: string;
|
|
||||||
}>(content);
|
|
||||||
|
|
||||||
if (!parsed) throw new Error("JSON LLM invalide");
|
if (!parsed) throw new Error("JSON LLM invalide");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
relevant: parsed.pertinent,
|
relevant: parsed.pertinent,
|
||||||
typeVeille: parsed.pertinent ? (parsed.typeVeille ?? "informationnelle") : null,
|
typeVeille: parsed.pertinent ? (parsed.typeVeille ?? "informationnelle") : null,
|
||||||
|
categorieAap: null,
|
||||||
reason: parsed.raison,
|
reason: parsed.raison,
|
||||||
classifiedBy: "ia",
|
classifiedBy: "ia",
|
||||||
|
technicalError: null,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[AI Classifier] Erreur classification:", (e as Error).message);
|
const technicalError = getTechnicalErrorMessage(e);
|
||||||
// Fallback sur les règles
|
console.error("[AI Classifier] Erreur classification veille:", technicalError);
|
||||||
const fb = fallbackFn();
|
const fb = fallbackFn();
|
||||||
return {
|
return {
|
||||||
relevant: true,
|
relevant: true,
|
||||||
typeVeille: fb.typeVeille,
|
typeVeille: fb.typeVeille,
|
||||||
|
categorieAap: null,
|
||||||
reason: "Classification par règles (erreur LLM)",
|
reason: "Classification par règles (erreur LLM)",
|
||||||
classifiedBy: "rules",
|
classifiedBy: "rules",
|
||||||
|
technicalError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classifie un appel à projet : pertinence + catégorie secteur (Handicap/PA/Enfance/Précarité/Sanitaire).
|
||||||
|
* Retourne toujours un résultat (fallback sur rules en cas d'erreur).
|
||||||
|
*/
|
||||||
|
export async function classifyAap(
|
||||||
|
titre: string,
|
||||||
|
resume: string,
|
||||||
|
fallbackCategorie: AiCategorieAap,
|
||||||
|
contenuPage?: string | null
|
||||||
|
): Promise<AiClassificationResult> {
|
||||||
|
const contenuSection = contenuPage
|
||||||
|
? `\n\nExtrait de l'article (premiers paragraphes) : ${contenuPage}`
|
||||||
|
: "";
|
||||||
|
const userContent = `Titre : ${titre}\n\nRésumé RSS : ${resume}${contenuSection}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await invokeLLM({
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: PROMPT_CLASSIFICATION_AAP },
|
||||||
|
{ role: "user", content: userContent },
|
||||||
|
],
|
||||||
|
response_format: {
|
||||||
|
type: "json_schema",
|
||||||
|
json_schema: {
|
||||||
|
name: "classification_aap_result",
|
||||||
|
strict: true,
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
pertinent: { type: "boolean" },
|
||||||
|
categorie: {
|
||||||
|
anyOf: [
|
||||||
|
{ type: "string", enum: ["Handicap", "Précarité", "Enfance", "PA", "Sanitaire", "Autre"] },
|
||||||
|
{ type: "null" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
raison: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["pertinent", "categorie", "raison"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawContent = response?.choices?.[0]?.message?.content;
|
||||||
|
if (!rawContent) throw new Error("Réponse LLM vide");
|
||||||
|
const content = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent);
|
||||||
|
const parsed = parseJsonSafe<{ pertinent: boolean; categorie: AiCategorieAap | null; raison: string }>(content);
|
||||||
|
if (!parsed) throw new Error("JSON LLM invalide");
|
||||||
|
|
||||||
|
return {
|
||||||
|
relevant: parsed.pertinent,
|
||||||
|
typeVeille: null,
|
||||||
|
categorieAap: parsed.pertinent ? (parsed.categorie ?? "Autre") : null,
|
||||||
|
reason: parsed.raison,
|
||||||
|
classifiedBy: "ia",
|
||||||
|
technicalError: null,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
47
server/azureAuth.test.ts
Normal file
47
server/azureAuth.test.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
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", () => {
|
||||||
|
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("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(azureTestConfig.tenantId);
|
||||||
|
expect(url).toContain(azureTestConfig.clientId);
|
||||||
|
});
|
||||||
|
});
|
||||||
76
server/azureAuth.ts
Normal file
76
server/azureAuth.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { ConfidentialClientApplication } from "@azure/msal-node";
|
||||||
|
|
||||||
|
// ─── Azure AD Authentication ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let msalClient: ConfidentialClientApplication | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie que les 3 variables d'environnement Azure AD sont présentes
|
||||||
|
*/
|
||||||
|
export function isAzureAdConfigured(): boolean {
|
||||||
|
return !!(
|
||||||
|
process.env.AZURE_AD_TENANT_ID &&
|
||||||
|
process.env.AZURE_AD_CLIENT_ID &&
|
||||||
|
process.env.AZURE_AD_CLIENT_SECRET
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instancie le client MSAL (lazy, singleton)
|
||||||
|
*/
|
||||||
|
function getMsalClient(): ConfidentialClientApplication {
|
||||||
|
if (!isAzureAdConfigured()) {
|
||||||
|
throw new Error("Azure AD is not configured");
|
||||||
|
}
|
||||||
|
if (!msalClient) {
|
||||||
|
msalClient = new ConfidentialClientApplication({
|
||||||
|
auth: {
|
||||||
|
clientId: process.env.AZURE_AD_CLIENT_ID!,
|
||||||
|
authority: `https://login.microsoftonline.com/${process.env.AZURE_AD_TENANT_ID}`,
|
||||||
|
clientSecret: process.env.AZURE_AD_CLIENT_SECRET!,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return msalClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne l'URL de redirection Azure AD pour l'utilisateur
|
||||||
|
*/
|
||||||
|
export async function getAzureAuthUrl(): Promise<string> {
|
||||||
|
const client = getMsalClient();
|
||||||
|
const redirectUri =
|
||||||
|
process.env.AZURE_AD_REDIRECT_URI ||
|
||||||
|
"http://localhost:3000/api/auth/azure/callback";
|
||||||
|
return client.getAuthCodeUrl({
|
||||||
|
scopes: ["user.read"],
|
||||||
|
redirectUri,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Échange le code OAuth contre un token et retourne les infos utilisateur.
|
||||||
|
* azureAdId = homeAccountId = "{objectId}.{tenantId}" (~73 caractères)
|
||||||
|
*/
|
||||||
|
export async function handleAzureCallback(code: string) {
|
||||||
|
const client = getMsalClient();
|
||||||
|
const redirectUri =
|
||||||
|
process.env.AZURE_AD_REDIRECT_URI ||
|
||||||
|
"http://localhost:3000/api/auth/azure/callback";
|
||||||
|
|
||||||
|
const response = await client.acquireTokenByCode({
|
||||||
|
code,
|
||||||
|
scopes: ["user.read"],
|
||||||
|
redirectUri,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response || !response.account) {
|
||||||
|
throw new Error("Failed to acquire token from Azure AD");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
azureAdId: response.account.homeAccountId, // "{objectId}.{tenantId}"
|
||||||
|
email: response.account.username, // UPN (ex: user@domain.com)
|
||||||
|
name: response.account.name || response.account.username,
|
||||||
|
};
|
||||||
|
}
|
||||||
229
server/db.ts
229
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 { drizzle } from "drizzle-orm/mysql2";
|
||||||
import mysql from "mysql2/promise";
|
import mysql from "mysql2/promise";
|
||||||
import {
|
import {
|
||||||
@@ -14,9 +14,13 @@ import {
|
|||||||
InsertIdea,
|
InsertIdea,
|
||||||
rssFeeds,
|
rssFeeds,
|
||||||
rssSettings,
|
rssSettings,
|
||||||
|
processedDedupKeys,
|
||||||
|
articleReads,
|
||||||
|
classificationErrors,
|
||||||
type InsertRssFeed,
|
type InsertRssFeed,
|
||||||
type InsertRssSettings,
|
type InsertRssSettings,
|
||||||
type ImportLog,
|
type ImportLog,
|
||||||
|
type ClassificationError,
|
||||||
type RssFeed,
|
type RssFeed,
|
||||||
type RssSettings,
|
type RssSettings,
|
||||||
} from "../drizzle/schema";
|
} from "../drizzle/schema";
|
||||||
@@ -43,6 +47,114 @@ export async function getDb() {
|
|||||||
return _db;
|
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) ─────────────────────────────────────────────────────
|
// ─── Users (Manus OAuth) ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function upsertUser(user: InsertUser): Promise<void> {
|
export async function upsertUser(user: InsertUser): Promise<void> {
|
||||||
@@ -116,6 +228,51 @@ export async function deleteLocalUser(id: number) {
|
|||||||
await db.delete(localUsers).where(eq(localUsers.id, id));
|
await db.delete(localUsers).where(eq(localUsers.id, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getLocalUserByAzureAdId(azureAdId: string) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return null;
|
||||||
|
const results = await db.select().from(localUsers).where(eq(localUsers.azureAdId, azureAdId)).limit(1);
|
||||||
|
return results[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLocalUserByEmail(email: string) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return null;
|
||||||
|
const results = await db.select().from(localUsers).where(eq(localUsers.email, email)).limit(1);
|
||||||
|
return results[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertLocalUserAzure(data: {
|
||||||
|
email: string;
|
||||||
|
name?: string;
|
||||||
|
azureAdId: string;
|
||||||
|
role?: "admin" | "user" | "readonly";
|
||||||
|
}) {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new Error("DB unavailable");
|
||||||
|
// Chercher si l'utilisateur existe déjà par email
|
||||||
|
const existing = await getLocalUserByEmail(data.email);
|
||||||
|
if (existing) {
|
||||||
|
// Lier le compte existant à Azure AD
|
||||||
|
await db.update(localUsers)
|
||||||
|
.set({ azureAdId: data.azureAdId, ...(data.name && { name: data.name }) })
|
||||||
|
.where(eq(localUsers.id, existing.id));
|
||||||
|
return existing.id;
|
||||||
|
} else {
|
||||||
|
// Créer un nouveau compte (sans mot de passe — connexion Azure uniquement)
|
||||||
|
const result = await db.insert(localUsers).values({
|
||||||
|
name: data.name ?? data.email,
|
||||||
|
username: data.email,
|
||||||
|
email: data.email,
|
||||||
|
passwordHash: "", // Pas de mot de passe local
|
||||||
|
role: data.role ?? "user",
|
||||||
|
isActive: true,
|
||||||
|
azureAdId: data.azureAdId,
|
||||||
|
});
|
||||||
|
return (result as any)[0]?.insertId ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Veille Items ─────────────────────────────────────────────────────────────
|
// ─── Veille Items ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface VeilleFilters {
|
export interface VeilleFilters {
|
||||||
@@ -334,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 ────────────────────────────────────────────────────────────
|
// ─── Boîte à idées ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function createIdea(data: InsertIdea) {
|
export async function createIdea(data: InsertIdea) {
|
||||||
@@ -444,9 +644,35 @@ export async function saveRssSettings(data: Partial<Omit<InsertRssSettings, "id"
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Purge ───────────────────────────────────────────────────────────────────
|
// ─── Purge ───────────────────────────────────────────────────────────────────
|
||||||
|
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> {
|
export async function purgeVeilleItems(): Promise<number> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) throw new Error("Database not available");
|
if (!db) throw new Error("Database not available");
|
||||||
|
await db.delete(articleReads).where(eq(articleReads.articleType, "veille"));
|
||||||
const result = await db.delete(veilleItems);
|
const result = await db.delete(veilleItems);
|
||||||
return (result as any).affectedRows ?? 0;
|
return (result as any).affectedRows ?? 0;
|
||||||
}
|
}
|
||||||
@@ -454,6 +680,7 @@ export async function purgeVeilleItems(): Promise<number> {
|
|||||||
export async function purgeAapItems(): Promise<number> {
|
export async function purgeAapItems(): Promise<number> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) throw new Error("Database not available");
|
if (!db) throw new Error("Database not available");
|
||||||
|
await db.delete(articleReads).where(eq(articleReads.articleType, "aap"));
|
||||||
const result = await db.delete(aapItems);
|
const result = await db.delete(aapItems);
|
||||||
return (result as any).affectedRows ?? 0;
|
return (result as any).affectedRows ?? 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
setSettings,
|
setSettings,
|
||||||
getImportLogs,
|
getImportLogs,
|
||||||
getImportStats,
|
getImportStats,
|
||||||
|
getClassificationErrors,
|
||||||
getLocalUsers,
|
getLocalUsers,
|
||||||
createLocalUser,
|
createLocalUser,
|
||||||
updateLocalUser,
|
updateLocalUser,
|
||||||
@@ -31,14 +32,19 @@ import {
|
|||||||
deleteRssFeed,
|
deleteRssFeed,
|
||||||
getRssSettings,
|
getRssSettings,
|
||||||
saveRssSettings,
|
saveRssSettings,
|
||||||
|
getDb,
|
||||||
|
getReadArticleIds,
|
||||||
|
getUnreadArticleCount,
|
||||||
|
markArticlesAsRead,
|
||||||
|
removeArticleReadRecords,
|
||||||
} from "./db";
|
} from "./db";
|
||||||
import { importVeille, importAAP, runFullImport, getImportConfig } from "./importer";
|
import { importVeille, importAAP, runFullImport, getImportConfig } from "./importer";
|
||||||
import { scheduleRssFetch } from "./_core/index";
|
import { scheduleDailyImport } from "./_core/index";
|
||||||
import { loginLocalUser, hashPassword, ensureAdminExists } from "./localAuth";
|
import { loginLocalUser, hashPassword, ensureAdminExists } from "./localAuth";
|
||||||
import { classifyArticle } from "./aiClassifier";
|
import { isAzureAdConfigured, getAzureAuthUrl } from "./azureAuth";
|
||||||
import { getDb } from "./db";
|
import { classifyAap, classifyArticle } from "./aiClassifier";
|
||||||
import { veilleItems, aapItems, articleReads } from "../drizzle/schema";
|
import { veilleItems, aapItems, processedDedupKeys } from "../drizzle/schema";
|
||||||
import { isNull, or, eq as eqDrizzle, and, inArray, count } from "drizzle-orm";
|
import { isNull, or, eq as eqDrizzle } from "drizzle-orm";
|
||||||
|
|
||||||
// ─── Middleware admin ─────────────────────────────────────────────────────────
|
// ─── Middleware admin ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -80,6 +86,17 @@ export const appRouter = router({
|
|||||||
ctx.res.clearCookie("veille_local_auth", { ...cookieOptions, maxAge: -1 });
|
ctx.res.clearCookie("veille_local_auth", { ...cookieOptions, maxAge: -1 });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
// Azure AD
|
||||||
|
isAzureAdAvailable: publicProcedure.query(() => {
|
||||||
|
return { available: isAzureAdConfigured() };
|
||||||
|
}),
|
||||||
|
getAzureLoginUrl: publicProcedure.query(async () => {
|
||||||
|
if (!isAzureAdConfigured()) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Azure AD non configur\u00e9" });
|
||||||
|
}
|
||||||
|
const url = await getAzureAuthUrl();
|
||||||
|
return { url };
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// ─── Veille ─────────────────────────────────────────────────────────────────
|
// ─── Veille ─────────────────────────────────────────────────────────────────
|
||||||
@@ -116,14 +133,15 @@ export const appRouter = router({
|
|||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
|
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
|
||||||
|
|
||||||
// Récupérer les articles non encore classés par l'IA
|
// Récupérer les articles non encore classés par l'IA OU classés par le fallback rules
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(veilleItems)
|
.from(veilleItems)
|
||||||
.where(or(isNull(veilleItems.iaClassifiedBy), isNull(veilleItems.iaRelevant)))
|
.where(or(isNull(veilleItems.iaClassifiedBy), isNull(veilleItems.iaRelevant), eqDrizzle(veilleItems.iaClassifiedBy, "rules")))
|
||||||
.limit(input.limit);
|
.limit(input.limit);
|
||||||
|
|
||||||
let processed = 0;
|
let processed = 0;
|
||||||
|
let deleted = 0;
|
||||||
let errors = 0;
|
let errors = 0;
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -133,6 +151,18 @@ export const appRouter = router({
|
|||||||
row.resume || "",
|
row.resume || "",
|
||||||
() => ({ typeVeille: (row.typeVeille || "informationnelle") as "reglementaire" | "concurrentielle" | "technologique" | "informationnelle" })
|
() => ({ typeVeille: (row.typeVeille || "informationnelle") as "reglementaire" | "concurrentielle" | "technologique" | "informationnelle" })
|
||||||
);
|
);
|
||||||
|
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) {
|
||||||
|
await db.insert(processedDedupKeys)
|
||||||
|
.values({ dedupKey: row.dedupKey, feedType: "veille" })
|
||||||
|
.onDuplicateKeyUpdate({ set: { dedupKey: row.dedupKey } });
|
||||||
|
}
|
||||||
|
deleted++;
|
||||||
|
} else {
|
||||||
await db
|
await db
|
||||||
.update(veilleItems)
|
.update(veilleItems)
|
||||||
.set({
|
.set({
|
||||||
@@ -143,63 +173,40 @@ export const appRouter = router({
|
|||||||
})
|
})
|
||||||
.where(eqDrizzle(veilleItems.id, row.id));
|
.where(eqDrizzle(veilleItems.id, row.id));
|
||||||
processed++;
|
processed++;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
errors++;
|
errors++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { processed, errors, total: rows.length };
|
return { processed, deleted, errors, total: rows.length };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// ─── Marquage lu/non lu ──────────────────────────────────────────────────────────────────────
|
// ─── Marquage lu/non lu ─────────────────────────────────────────────────
|
||||||
markAsRead: protectedProcedure
|
markAsRead: protectedProcedure
|
||||||
.input(z.object({ articleId: z.number().int().positive() }))
|
.input(z.object({ articleId: z.number().int().positive() }))
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const db = await getDb();
|
const marked = await markArticlesAsRead(ctx.user.id, "veille", [input.articleId]);
|
||||||
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
|
return { success: true, marked };
|
||||||
// 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 };
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
markAllAsRead: protectedProcedure.mutation(async ({ ctx }) => {
|
markAllAsRead: protectedProcedure.mutation(async ({ ctx }) => {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
|
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 allItems = await db.select({ id: veilleItems.id }).from(veilleItems);
|
||||||
const allIds = allItems.map((r: { id: number }) => r.id);
|
const marked = await markArticlesAsRead(ctx.user.id, "veille", allItems.map((item: { id: number }) => item.id));
|
||||||
// Trouver ceux déjà lus
|
return { success: true, marked };
|
||||||
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 };
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
unreadCount: protectedProcedure.query(async ({ ctx }) => {
|
unreadCount: protectedProcedure.query(async ({ ctx }) => {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) return { count: 0 };
|
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
|
||||||
const totalRows = await db.select({ cnt: count() }).from(veilleItems);
|
const totalRows = await db.select({ id: veilleItems.id }).from(veilleItems);
|
||||||
const total = totalRows[0]?.cnt ?? 0;
|
return { count: await getUnreadArticleCount(ctx.user.id, "veille", totalRows.length) };
|
||||||
const readRows = await db
|
}),
|
||||||
.select({ cnt: count() })
|
getReadIds: protectedProcedure.query(async ({ ctx }) => {
|
||||||
.from(articleReads)
|
return { ids: await getReadArticleIds(ctx.user.id, "veille") };
|
||||||
.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) };
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
// ─── AAPP ────────────────────────────────────────────────────────────────────
|
// ─── AAPP ────────────────────────────────────────────────────────────────────
|
||||||
@@ -240,85 +247,77 @@ export const appRouter = router({
|
|||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(aapItems)
|
.from(aapItems)
|
||||||
.where(or(isNull(aapItems.iaClassifiedBy), isNull(aapItems.iaRelevant)))
|
.where(or(isNull(aapItems.iaClassifiedBy), isNull(aapItems.iaRelevant), eqDrizzle(aapItems.iaClassifiedBy, "rules")))
|
||||||
.limit(input.limit);
|
.limit(input.limit);
|
||||||
|
|
||||||
let processed = 0;
|
let processed = 0;
|
||||||
|
let deleted = 0;
|
||||||
let errors = 0;
|
let errors = 0;
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
try {
|
try {
|
||||||
const aiResult = await classifyArticle(
|
const aiResult = await classifyAap(
|
||||||
row.titre || "",
|
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)
|
||||||
|
.values({ dedupKey: row.dedupKey, feedType: "aap" })
|
||||||
|
.onDuplicateKeyUpdate({ set: { dedupKey: row.dedupKey } });
|
||||||
|
}
|
||||||
|
deleted++;
|
||||||
|
} else {
|
||||||
await db
|
await db
|
||||||
.update(aapItems)
|
.update(aapItems)
|
||||||
.set({
|
.set({
|
||||||
|
categorie: aiResult.categorieAap ?? row.categorie,
|
||||||
iaRelevant: aiResult.relevant,
|
iaRelevant: aiResult.relevant,
|
||||||
|
iaCategorie: aiResult.categorieAap,
|
||||||
iaClassifiedBy: aiResult.classifiedBy,
|
iaClassifiedBy: aiResult.classifiedBy,
|
||||||
iaReason: aiResult.reason,
|
iaReason: aiResult.reason,
|
||||||
})
|
})
|
||||||
.where(eqDrizzle(aapItems.id, row.id));
|
.where(eqDrizzle(aapItems.id, row.id));
|
||||||
processed++;
|
processed++;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
errors++;
|
errors++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { processed, errors, total: rows.length };
|
return { processed, deleted, errors, total: rows.length };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// ─── Marquage lu/non lu AAP ──────────────────────────────────────────────────────────────────────
|
// ─── Marquage lu/non lu AAP ───────────────────────────────────────────────
|
||||||
markAsRead: protectedProcedure
|
markAsRead: protectedProcedure
|
||||||
.input(z.object({ articleId: z.number().int().positive() }))
|
.input(z.object({ articleId: z.number().int().positive() }))
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const db = await getDb();
|
const marked = await markArticlesAsRead(ctx.user.id, "aap", [input.articleId]);
|
||||||
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
|
return { success: true, marked };
|
||||||
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 };
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
markAllAsRead: protectedProcedure.mutation(async ({ ctx }) => {
|
markAllAsRead: protectedProcedure.mutation(async ({ ctx }) => {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
|
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
|
||||||
const allItems = await db.select({ id: aapItems.id }).from(aapItems);
|
const allItems = await db.select({ id: aapItems.id }).from(aapItems);
|
||||||
const allIds = allItems.map((r: { id: number }) => r.id);
|
const marked = await markArticlesAsRead(ctx.user.id, "aap", allItems.map((item: { id: number }) => item.id));
|
||||||
const alreadyRead = await db
|
return { success: true, marked };
|
||||||
.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 };
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
unreadCount: protectedProcedure.query(async ({ ctx }) => {
|
unreadCount: protectedProcedure.query(async ({ ctx }) => {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) return { count: 0 };
|
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
|
||||||
const totalRows = await db.select({ cnt: count() }).from(aapItems);
|
const totalRows = await db.select({ id: aapItems.id }).from(aapItems);
|
||||||
const total = totalRows[0]?.cnt ?? 0;
|
return { count: await getUnreadArticleCount(ctx.user.id, "aap", totalRows.length) };
|
||||||
const readRows = await db
|
}),
|
||||||
.select({ cnt: count() })
|
getReadIds: protectedProcedure.query(async ({ ctx }) => {
|
||||||
.from(articleReads)
|
return { ids: await getReadArticleIds(ctx.user.id, "aap") };
|
||||||
.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) };
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
// ─── Importt ─────────────────────────────────────────────────────────────────
|
// ─── Import ─────────────────────────────────────────────────────────────────
|
||||||
import: router({
|
import: router({
|
||||||
run: adminProcedure
|
run: adminProcedure
|
||||||
.input(z.object({ type: z.enum(["veille", "aap", "all"]).default("all") }))
|
.input(z.object({ type: z.enum(["veille", "aap", "all"]).default("all") }))
|
||||||
@@ -372,6 +371,11 @@ export const appRouter = router({
|
|||||||
sharepoint_token: z.string().optional(),
|
sharepoint_token: z.string().optional(),
|
||||||
auth_mode: z.enum(["local", "free"]).optional(),
|
auth_mode: z.enum(["local", "free"]).optional(),
|
||||||
import_time: z.string().optional(),
|
import_time: z.string().optional(),
|
||||||
|
fetch_mode: z.enum(["scheduled", "weekly", "monthly", "interval"]).optional(),
|
||||||
|
fetch_interval_minutes: z.string().optional(),
|
||||||
|
fetch_day_of_week: z.string().optional(),
|
||||||
|
fetch_day_of_month: z.string().optional(),
|
||||||
|
retention_months: z.string().optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
@@ -380,6 +384,10 @@ export const appRouter = router({
|
|||||||
if (v !== undefined && v !== "••••••••") toSave[k] = v;
|
if (v !== undefined && v !== "••••••••") toSave[k] = v;
|
||||||
}
|
}
|
||||||
await setSettings(toSave);
|
await setSettings(toSave);
|
||||||
|
// Recharger le cron si la planification a changé
|
||||||
|
if (toSave.import_time || toSave.fetch_mode || toSave.fetch_interval_minutes || toSave.fetch_day_of_week || toSave.fetch_day_of_month) {
|
||||||
|
await scheduleDailyImport();
|
||||||
|
}
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@@ -513,6 +521,11 @@ export const appRouter = router({
|
|||||||
return getRssFeeds();
|
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
|
// Créer un flux
|
||||||
create: adminProcedure
|
create: adminProcedure
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
@@ -592,8 +605,8 @@ export const appRouter = router({
|
|||||||
}))
|
}))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
await saveRssSettings(input);
|
await saveRssSettings(input);
|
||||||
// Recharger le planificateur RSS avec les nouveaux paramètres
|
// Recharger le planificateur (la lecture RSS est intégrée dans scheduleDailyImport)
|
||||||
await scheduleRssFetch();
|
await scheduleDailyImport();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -14,15 +14,16 @@
|
|||||||
*/
|
*/
|
||||||
import { XMLParser } from "fast-xml-parser";
|
import { XMLParser } from "fast-xml-parser";
|
||||||
import * as crypto from "crypto";
|
import * as crypto from "crypto";
|
||||||
import { getDb } from "./db";
|
import { getDb, recordClassificationError, removeArticleReadRecords } from "./db";
|
||||||
import {
|
import {
|
||||||
rssFeeds,
|
rssFeeds,
|
||||||
veilleItems,
|
veilleItems,
|
||||||
aapItems,
|
aapItems,
|
||||||
|
processedDedupKeys,
|
||||||
type RssFeed,
|
type RssFeed,
|
||||||
} from "../drizzle/schema";
|
} from "../drizzle/schema";
|
||||||
import { eq, sql } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
import { classifyArticle, generateSummary } from "./aiClassifier";
|
import { classifyArticle, classifyAap, generateSummary } from "./aiClassifier";
|
||||||
import { fetchArticleFirstParagraph } from "./articleFetcher";
|
import { fetchArticleFirstParagraph } from "./articleFetcher";
|
||||||
|
|
||||||
// ─── Types internes ───────────────────────────────────────────────────────────
|
// ─── Types internes ───────────────────────────────────────────────────────────
|
||||||
@@ -48,50 +49,106 @@ interface FetchResult {
|
|||||||
newItems: number;
|
newItems: number;
|
||||||
skippedItems: number;
|
skippedItems: number;
|
||||||
mergedItems: number;
|
mergedItems: number;
|
||||||
error?: string;
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Dictionnaire des départements d'Auvergne-Rhône-Alpes ────────────────────
|
// ─── Dictionnaire des départements (AuRA, PACA, Nouvelle-Aquitaine, Occitanie, Bourgogne-FC) ──────
|
||||||
// IMPORTANT : les départements composés (Haute-Loire, Haute-Savoie, Puy-de-Dôme)
|
// IMPORTANT : les départements composés doivent être AVANT leurs variantes simples
|
||||||
// doivent être AVANT leurs variantes simples (Loire, Savoie) pour éviter les faux positifs.
|
// pour éviter les faux positifs (ex. Haute-Loire avant Loire).
|
||||||
|
|
||||||
const AURA_DEPARTMENTS: Array<{ pattern: RegExp; name: string; num: string }> = [
|
const AURA_DEPARTMENTS: Array<{ pattern: RegExp; name: string; num: string; region: string }> = [
|
||||||
// Composés en premier
|
|
||||||
{ pattern: /haute-savoie|haute savoie|(?<!\d)74(?!\d)|\(74\)/i, name: "Haute-Savoie", num: "74" },
|
// ─── AUVERGNE-RHÔNE-ALPES ─────────────────────────────────────────────────────────────────
|
||||||
{ pattern: /haute-loire|haute loire|(?<!\d)43(?!\d)|\(43\)/i, name: "Haute-Loire", num: "43" },
|
{ pattern: /haute-savoie|haute savoie/i, name: "Haute-Savoie", num: "74", region: "Auvergne-Rhône-Alpes" },
|
||||||
{ pattern: /puy-de-d[oô]me|puy de d[oô]me|(?<!\d)63(?!\d)|\(63\)/i, name: "Puy-de-Dôme", num: "63" },
|
{ pattern: /haute-loire|haute loire/i, name: "Haute-Loire", num: "43", region: "Auvergne-Rhône-Alpes" },
|
||||||
// Simples ensuite
|
{ pattern: /puy-de-d[oô]me|puy de d[oô]me/i, name: "Puy-de-Dôme", num: "63", region: "Auvergne-Rhône-Alpes" },
|
||||||
{ pattern: /\bain\b|(?<!\d)01(?!\d)|\(01\)/i, name: "Ain", num: "01" },
|
{ pattern: /\bain\b/i, name: "Ain", num: "01", region: "Auvergne-Rhône-Alpes" },
|
||||||
{ pattern: /\ballier\b|(?<!\d)03(?!\d)|\(03\)/i, name: "Allier", num: "03" },
|
{ pattern: /\ballier\b/i, name: "Allier", num: "03", region: "Auvergne-Rhône-Alpes" },
|
||||||
{ pattern: /\bard[eè]che\b|(?<!\d)07(?!\d)|\(07\)/i, name: "Ardèche", num: "07" },
|
{ pattern: /\bard[eè]che\b/i, name: "Ardèche", num: "07", region: "Auvergne-Rhône-Alpes" },
|
||||||
{ pattern: /\bcantal\b|(?<!\d)15(?!\d)|\(15\)/i, name: "Cantal", num: "15" },
|
{ pattern: /\bcantal\b/i, name: "Cantal", num: "15", region: "Auvergne-Rhône-Alpes" },
|
||||||
{ pattern: /\bdr[oô]me\b|(?<!\d)26(?!\d)|\(26\)/i, name: "Drôme", num: "26" },
|
{ pattern: /\bdr[oô]me\b/i, name: "Drôme", num: "26", region: "Auvergne-Rhône-Alpes" },
|
||||||
{ pattern: /\bis[eè]re\b|(?<!\d)38(?!\d)|\(38\)/i, name: "Isère", num: "38" },
|
{ pattern: /\bis[eè]re\b/i, name: "Isère", num: "38", region: "Auvergne-Rhône-Alpes" },
|
||||||
// Loire : exclure "Haute-Loire" déjà traité
|
{ pattern: /(?<!haute-)\bloire\b/i, name: "Loire", num: "42", region: "Auvergne-Rhône-Alpes" },
|
||||||
{ pattern: /(?<!haute-)\bloire\b|(?<!\d)42(?!\d)|\(42\)/i, name: "Loire", num: "42" },
|
{ pattern: /\brhone\b|\brhône\b|m[eé]tropole\s+de\s+lyon/i, name: "Rhône", num: "69", region: "Auvergne-Rhône-Alpes" },
|
||||||
{ pattern: /\brhone\b|\brhône\b|(?<!\d)69(?!\d)|\(69\)|m[eé]tropole\s+de\s+lyon/i, name: "Rhône", num: "69" },
|
{ pattern: /\bsavoie\b(?!.*haute)/i, name: "Savoie", num: "73", region: "Auvergne-Rhône-Alpes" },
|
||||||
// Savoie : exclure "Haute-Savoie" déjà traité
|
|
||||||
{ pattern: /\bsavoie\b(?!.*haute)|(?<!\d)73(?!\d)|\(73\)/i, name: "Savoie", num: "73" },
|
// ─── PROVENCE-ALPES-CÔTE D'AZUR (PACA) ────────────────────────────────────────────────────
|
||||||
|
{ pattern: /alpes-de-haute-provence|alpes de haute provence/i, name: "Alpes-de-Haute-Provence", num: "04", region: "Provence-Alpes-Côte d'Azur" },
|
||||||
|
{ pattern: /hautes-alpes|hautes alpes/i, name: "Hautes-Alpes", num: "05", region: "Provence-Alpes-Côte d'Azur" },
|
||||||
|
{ pattern: /alpes-maritimes|alpes maritimes/i, name: "Alpes-Maritimes", num: "06", region: "Provence-Alpes-Côte d'Azur" },
|
||||||
|
{ pattern: /bouches-du-rh[oô]ne|bouches du rh[oô]ne|marseille/i, name: "Bouches-du-Rhône", num: "13", region: "Provence-Alpes-Côte d'Azur" },
|
||||||
|
{ pattern: /\bvar\b/i, name: "Var", num: "83", region: "Provence-Alpes-Côte d'Azur" },
|
||||||
|
{ pattern: /\bvaucluse\b/i, name: "Vaucluse", num: "84", region: "Provence-Alpes-Côte d'Azur" },
|
||||||
|
|
||||||
|
// ─── NOUVELLE-AQUITAINE ───────────────────────────────────────────────────────────────────────────
|
||||||
|
{ pattern: /charente-maritime|charente maritime/i, name: "Charente-Maritime", num: "17", region: "Nouvelle-Aquitaine" },
|
||||||
|
{ pattern: /\bcharente\b/i, name: "Charente", num: "16", region: "Nouvelle-Aquitaine" },
|
||||||
|
{ pattern: /\bcorr[eè]ze\b/i, name: "Corrèze", num: "19", region: "Nouvelle-Aquitaine" },
|
||||||
|
{ pattern: /\bcreuse\b/i, name: "Creuse", num: "23", region: "Nouvelle-Aquitaine" },
|
||||||
|
{ pattern: /\bdordogne\b/i, name: "Dordogne", num: "24", region: "Nouvelle-Aquitaine" },
|
||||||
|
{ pattern: /\bgironde\b|bordeaux/i, name: "Gironde", num: "33", region: "Nouvelle-Aquitaine" },
|
||||||
|
{ pattern: /\blandes\b/i, name: "Landes", num: "40", region: "Nouvelle-Aquitaine" },
|
||||||
|
{ pattern: /lot-et-garonne|lot et garonne/i, name: "Lot-et-Garonne", num: "47", region: "Nouvelle-Aquitaine" },
|
||||||
|
{ pattern: /pyr[eé]n[eé]es-atlantiques|pyr[eé]n[eé]es atlantiques/i, name: "Pyrénées-Atlantiques", num: "64", region: "Nouvelle-Aquitaine" },
|
||||||
|
{ pattern: /deux-s[eè]vres|deux s[eè]vres/i, name: "Deux-Sèvres", num: "79", region: "Nouvelle-Aquitaine" },
|
||||||
|
{ pattern: /haute-vienne|haute vienne/i, name: "Haute-Vienne", num: "87", region: "Nouvelle-Aquitaine" },
|
||||||
|
{ pattern: /\bvienne\b/i, name: "Vienne", num: "86", region: "Nouvelle-Aquitaine" },
|
||||||
|
|
||||||
|
// ─── OCCITANIE ───────────────────────────────────────────────────────────────────────────────────
|
||||||
|
{ pattern: /\bari[eè]ge\b/i, name: "Ariège", num: "09", region: "Occitanie" },
|
||||||
|
{ pattern: /\baude\b/i, name: "Aude", num: "11", region: "Occitanie" },
|
||||||
|
{ pattern: /\baveyron\b/i, name: "Aveyron", num: "12", region: "Occitanie" },
|
||||||
|
{ pattern: /\bgard\b/i, name: "Gard", num: "30", region: "Occitanie" },
|
||||||
|
{ pattern: /haute-garonne|haute garonne|toulouse/i, name: "Haute-Garonne", num: "31", region: "Occitanie" },
|
||||||
|
{ pattern: /\bgers\b/i, name: "Gers", num: "32", region: "Occitanie" },
|
||||||
|
{ pattern: /\bh[eé]rault\b|montpellier/i, name: "Hérault", num: "34", region: "Occitanie" },
|
||||||
|
{ pattern: /\blot\b/i, name: "Lot", num: "46", region: "Occitanie" },
|
||||||
|
{ pattern: /\bloz[eè]re\b/i, name: "Lozère", num: "48", region: "Occitanie" },
|
||||||
|
{ pattern: /hautes-pyr[eé]n[eé]es|hautes pyr[eé]n[eé]es/i, name: "Hautes-Pyrénées", num: "65", region: "Occitanie" },
|
||||||
|
{ pattern: /pyr[eé]n[eé]es-orientales|pyr[eé]n[eé]es orientales/i, name: "Pyrénées-Orientales", num: "66", region: "Occitanie" },
|
||||||
|
{ pattern: /tarn-et-garonne|tarn et garonne/i, name: "Tarn-et-Garonne", num: "82", region: "Occitanie" },
|
||||||
|
{ pattern: /\btarn\b/i, name: "Tarn", num: "81", region: "Occitanie" },
|
||||||
|
|
||||||
|
// ─── BOURGOGNE-FRANCHE-COMTÉ ────────────────────────────────────────────────────────────────────
|
||||||
|
{ pattern: /c[oô]te-d.or|c[oô]te d.or|dijon/i, name: "Côte-d'Or", num: "21", region: "Bourgogne-Franche-Comté" },
|
||||||
|
{ pattern: /\bdoubs\b|besan[cç]on/i, name: "Doubs", num: "25", region: "Bourgogne-Franche-Comté" },
|
||||||
|
{ pattern: /\bjura\b/i, name: "Jura", num: "39", region: "Bourgogne-Franche-Comté" },
|
||||||
|
{ pattern: /\bni[eè]vre\b/i, name: "Nièvre", num: "58", region: "Bourgogne-Franche-Comté" },
|
||||||
|
{ pattern: /haute-sa[oô]ne|haute sa[oô]ne/i, name: "Haute-Saône", num: "70", region: "Bourgogne-Franche-Comté" },
|
||||||
|
{ pattern: /sa[oô]ne-et-loire|sa[oô]ne et loire/i, name: "Saône-et-Loire", num: "71", region: "Bourgogne-Franche-Comté" },
|
||||||
|
{ pattern: /\byonne\b/i, name: "Yonne", num: "89", region: "Bourgogne-Franche-Comté" },
|
||||||
|
{ pattern: /territoire-de-belfort|territoire de belfort|belfort/i, name: "Territoire de Belfort", num: "90", region: "Bourgogne-Franche-Comté" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Détecte le département dans un texte.
|
* Détecte le département dans un texte.
|
||||||
* Retourne { name, num } ou null si non trouvé.
|
* Retourne { name, num, region } ou null si non trouvé.
|
||||||
*/
|
*/
|
||||||
export function detectDepartment(text: string): { name: string; num: string } | null {
|
export function detectDepartment(text: string): { name: string; num: string; region: string } | null {
|
||||||
for (const dept of AURA_DEPARTMENTS) {
|
for (const dept of AURA_DEPARTMENTS) {
|
||||||
if (dept.pattern.test(text)) {
|
if (dept.pattern.test(text)) {
|
||||||
return { name: dept.name, num: dept.num };
|
return { name: dept.name, num: dept.num, region: dept.region };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Détecte si le texte mentionne la région Auvergne-Rhône-Alpes.
|
* Détecte si le texte mentionne une des régions couvertes.
|
||||||
|
* Retourne le nom de la région ou null.
|
||||||
*/
|
*/
|
||||||
|
function detectRegion(text: string): string | null {
|
||||||
|
if (/auvergne.?rh.?ne.?alpes|\baura\b|a\.r\.a\.|ars\s+auvergne|rh.?ne.?alpes/i.test(text)) return "Auvergne-Rhône-Alpes";
|
||||||
|
if (/provence.?alpes.?c.?te.?d.?azur|\bpaca\b|ars\s+paca|ars\s+provence/i.test(text)) return "Provence-Alpes-Côte d'Azur";
|
||||||
|
if (/nouvelle.?aquitaine|ars\s+nouvelle.?aquitaine/i.test(text)) return "Nouvelle-Aquitaine";
|
||||||
|
if (/\boccitanie\b|ars\s+occitanie/i.test(text)) return "Occitanie";
|
||||||
|
if (/bourgogne.?franche.?comt[eé]|ars\s+bourgogne/i.test(text)) return "Bourgogne-Franche-Comté";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Utiliser detectRegion à la place */
|
||||||
function isRegional(text: string): boolean {
|
function isRegional(text: string): boolean {
|
||||||
return /auvergne.?rh.?ne.?alpes|aura\b|a\.r\.a\.|ars\s+auvergne/i.test(text);
|
return detectRegion(text) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Normalisation du titre pour la fusion ────────────────────────────────────
|
// ─── Normalisation du titre pour la fusion ────────────────────────────────────
|
||||||
@@ -105,16 +162,14 @@ function isRegional(text: string): boolean {
|
|||||||
function buildMergeKey(title: string): string {
|
function buildMergeKey(title: string): string {
|
||||||
let key = title.toLowerCase();
|
let key = title.toLowerCase();
|
||||||
|
|
||||||
// Supprimer les noms de départements AuRA (composites d'abord)
|
// Supprimer les noms de départements (composites d'abord)
|
||||||
const deptNames = [
|
const deptNames = AURA_DEPARTMENTS.flatMap(d => [
|
||||||
"haute-savoie", "haute savoie", "haute-loire", "haute loire",
|
d.name.toLowerCase(),
|
||||||
"puy-de-dôme", "puy-de-dome", "puy de dôme", "puy de dome",
|
d.name.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, ""),
|
||||||
"ain", "allier", "ardèche", "ardeche", "cantal",
|
]).concat([
|
||||||
"drôme", "drome", "isère", "isere", "loire",
|
|
||||||
"rhône", "rhone", "savoie",
|
|
||||||
"métropole de lyon", "metropole de lyon", "lyon",
|
"métropole de lyon", "metropole de lyon", "lyon",
|
||||||
"allier",
|
"marseille", "toulouse", "bordeaux", "montpellier", "nice", "dijon", "besançon", "besancon",
|
||||||
];
|
]);
|
||||||
|
|
||||||
// Protéger "auvergne-rhône-alpes" et "rhône-alpes"
|
// Protéger "auvergne-rhône-alpes" et "rhône-alpes"
|
||||||
key = key.replace(/auvergne.?rh.?ne.?alpes/gi, "__AURA__");
|
key = key.replace(/auvergne.?rh.?ne.?alpes/gi, "__AURA__");
|
||||||
@@ -189,25 +244,46 @@ export function detectVeilleCategorie(text: string): VeilleCategorie {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Déduit le niveau et le territoire d'un article de veille.
|
* Déduit le niveau et le territoire d'un article de veille.
|
||||||
|
* Retourne le niveau le plus fin détecté : départemental > régional > national.
|
||||||
*/
|
*/
|
||||||
export function detectVeilleNiveauTerritoire(text: string): { niveau: VeilleNiveau; territoire: string } {
|
export function detectVeilleNiveauTerritoire(text: string): { niveau: VeilleNiveau; territoire: string } {
|
||||||
const dept = detectDepartment(text);
|
const dept = detectDepartment(text);
|
||||||
if (dept) {
|
if (dept) {
|
||||||
return { niveau: "departemental", territoire: dept.name };
|
return { niveau: "departemental", territoire: dept.name };
|
||||||
}
|
}
|
||||||
if (isRegional(text)) {
|
const region = detectRegion(text);
|
||||||
return { niveau: "regional", territoire: "Auvergne-Rhône-Alpes" };
|
if (region) {
|
||||||
|
return { niveau: "regional", territoire: region };
|
||||||
}
|
}
|
||||||
return { niveau: "national", territoire: "France" };
|
return { niveau: "national", territoire: "France" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Extraction automatique pour les AAP ─────────────────────────────────────
|
// ─── Extraction automatique pour les AAP ─────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détecte le niveau de localisation le plus fin dans le texte :
|
||||||
|
* - Département connu → region = région du département, departement = "Nom (num)"
|
||||||
|
* - Région connue → region = nom de la région, departement = null
|
||||||
|
* - Sinon → region = "National", departement = null
|
||||||
|
*/
|
||||||
export function detectAapGeo(text: string): { region: string; departement: string | null } {
|
export function detectAapGeo(text: string): { region: string; departement: string | null } {
|
||||||
const dept = detectDepartment(text);
|
const dept = detectDepartment(text);
|
||||||
|
if (dept) {
|
||||||
return {
|
return {
|
||||||
region: "Auvergne-Rhône-Alpes",
|
region: dept.region,
|
||||||
departement: dept ? `${dept.name} (${dept.num})` : null,
|
departement: `${dept.name} (${dept.num})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const region = detectRegion(text);
|
||||||
|
if (region) {
|
||||||
|
return {
|
||||||
|
region,
|
||||||
|
departement: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
region: "National",
|
||||||
|
departement: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +299,105 @@ function parseDate(dateStr?: string): Date | null {
|
|||||||
return isNaN(d.getTime()) ? null : d;
|
return isNaN(d.getTime()) ? null : d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tente d'extraire une date de clôture depuis le texte d'un AAP.
|
||||||
|
* Patterns reconnus (fr) :
|
||||||
|
* "date limite : 30 septembre 2026"
|
||||||
|
* "clôture le 30/09/2026"
|
||||||
|
* "avant le 30 sept. 2026"
|
||||||
|
* "jusqu'au 30-09-2026"
|
||||||
|
* "dépôt des dossiers : 30 septembre 2026"
|
||||||
|
* "délai de réponse : 30 septembre 2026"
|
||||||
|
* Retourne un objet Date ou null si non détecté.
|
||||||
|
*/
|
||||||
|
const MONTHS_FR: Record<string, number> = {
|
||||||
|
janvier: 0, février: 1, fevrier: 1, mars: 2, avril: 3, mai: 4, juin: 5,
|
||||||
|
juillet: 6, août: 7, aout: 7, septembre: 8, octobre: 9, novembre: 10, décembre: 11, decembre: 11,
|
||||||
|
janv: 0, févr: 1, fevr: 1, avr: 3, juil: 6, sept: 8, oct: 9, nov: 10, déc: 11, dec: 11,
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseFrDate(dateStr: string): Date | null {
|
||||||
|
const s = dateStr.trim();
|
||||||
|
// Format JJ/MM/AAAA ou JJ-MM-AAAA ou JJ.MM.AAAA
|
||||||
|
const numMatch = s.match(/(\d{1,2})[\/.\-](\d{1,2})[\/.\-](\d{4})/);
|
||||||
|
if (numMatch) {
|
||||||
|
const d = new Date(parseInt(numMatch[3]), parseInt(numMatch[2]) - 1, parseInt(numMatch[1]));
|
||||||
|
return isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
// Format JJ mois AAAA ou "1er mois AAAA"
|
||||||
|
const textMatch = s.match(/(\d{1,2})(?:er|e)?\s+([a-z\u00e9\u00e8\u00ea\u00e0\u00f9\u00fb]+)\.?\s+(\d{4})/i);
|
||||||
|
if (textMatch) {
|
||||||
|
const month = MONTHS_FR[textMatch[2].toLowerCase().replace(/\.$/, "")];
|
||||||
|
if (month !== undefined) {
|
||||||
|
const d = new Date(parseInt(textMatch[3]), month, parseInt(textMatch[1]));
|
||||||
|
return isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Format AAAA-MM-JJ (ISO)
|
||||||
|
const isoMatch = s.match(/(\d{4})-(\d{2})-(\d{2})/);
|
||||||
|
if (isoMatch) {
|
||||||
|
const d = new Date(parseInt(isoMatch[1]), parseInt(isoMatch[2]) - 1, parseInt(isoMatch[3]));
|
||||||
|
return isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extrait la première date valide trouvée dans une chaîne
|
||||||
|
function findDateInStr(s: string): Date | null {
|
||||||
|
// Essayer directement
|
||||||
|
const d = parseFrDate(s);
|
||||||
|
if (d) return d;
|
||||||
|
// Chercher un sous-motif date dans la chaîne (utile quand du texte suit la date)
|
||||||
|
const sub = s.match(/(\d{1,2}(?:er|e)?[\s.\/\-][\w\s.]+\d{4}|\d{4}-\d{2}-\d{2}|\d{1,2}[\/\.\-]\d{1,2}[\/\.\-]\d{4})/);
|
||||||
|
if (sub) return parseFrDate(sub[1]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractDateCloture(text: string): Date | null {
|
||||||
|
// Normaliser : supprimer les retours à la ligne et les espaces multiples
|
||||||
|
const t = text.replace(/[\r\n]+/g, " ").replace(/\s{2,}/g, " ");
|
||||||
|
|
||||||
|
const patterns: RegExp[] = [
|
||||||
|
// "date limite (de dépôt|de candidature|d'envoi|de soumission|de remise)? ..."
|
||||||
|
/date\s+limite(?:[^:\d]{0,50})?[:\s]+([\d][\w\s.\/\-]+\d{4})/i,
|
||||||
|
// "fin (de dépôt|des candidatures|de soumission|d'inscription|de réception)?"
|
||||||
|
/fin\s+(?:de\s+)?(?:d[eé]p[oô]t|des\s+candidatures|de\s+soumission|d['\u2019]inscription|de\s+r[eé]ception|des\s+dossiers)(?:[^:\d]{0,30})?[:\s]+([\d][\w\s.\/\-]+\d{4})/i,
|
||||||
|
// "clôture (des candidatures|des dossiers|de l'appel|le|au|:)?"
|
||||||
|
/cl[oô]ture(?:[^:\d]{0,50})?[:\s]+([\d][\w\s.\/\-]+\d{4})/i,
|
||||||
|
// "clôture le ..."
|
||||||
|
/cl[oô]ture\s+le\s+([\d][\w\s.\/\-]+\d{4})/i,
|
||||||
|
// "avant le ..."
|
||||||
|
/avant\s+le\s+([\d][\w\s.\/\-]+\d{4})/i,
|
||||||
|
// "jusqu'au ..."
|
||||||
|
/jusqu['\u2019]au\s+([\d][\w\s.\/\-]+\d{4})/i,
|
||||||
|
// "dépôt (des dossiers|des candidatures|des projets)? ..."
|
||||||
|
/d[eé]p[oô]t(?:[^:\d]{0,50})?[:\s]+([\d][\w\s.\/\-]+\d{4})/i,
|
||||||
|
// "délai (de réponse|de dépôt|de soumission)? ..."
|
||||||
|
/d[eé]lai(?:[^:\d]{0,50})?[:\s]+([\d][\w\s.\/\-]+\d{4})/i,
|
||||||
|
// "réception (des dossiers|des candidatures)? ..."
|
||||||
|
/r[eé]ception(?:[^:\d]{0,50})?[:\s]+([\d][\w\s.\/\-]+\d{4})/i,
|
||||||
|
// "soumission (des candidatures|des projets)? ..."
|
||||||
|
/soumission(?:[^:\d]{0,50})?[:\s]+([\d][\w\s.\/\-]+\d{4})/i,
|
||||||
|
// "remise (des offres|des dossiers|des candidatures)? ..."
|
||||||
|
/remise(?:[^:\d]{0,50})?[:\s]+([\d][\w\s.\/\-]+\d{4})/i,
|
||||||
|
// "candidatures (attendues|acceptées|ouvertes)? jusqu'au ..."
|
||||||
|
/candidatures?(?:[^\d]{0,40})?jusqu['\u2019]au\s+([\d][\w\s.\/\-]+\d{4})/i,
|
||||||
|
// "répondre avant le ..."
|
||||||
|
/r[eé]pondre\s+avant\s+le\s+([\d][\w\s.\/\-]+\d{4})/i,
|
||||||
|
// "ouvert jusqu'au ..."
|
||||||
|
/ouvert(?:e)?\s+jusqu['\u2019]au\s+([\d][\w\s.\/\-]+\d{4})/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = t.match(pattern);
|
||||||
|
if (match?.[1]) {
|
||||||
|
const d = findDateInStr(match[1].trim());
|
||||||
|
if (d && d.getFullYear() >= 2020 && d.getFullYear() <= 2035) return d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function stripHtml(html: string): string {
|
function stripHtml(html: string): string {
|
||||||
return html
|
return html
|
||||||
.replace(/<[^>]*>/g, "")
|
.replace(/<[^>]*>/g, "")
|
||||||
@@ -347,10 +522,26 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Filtre de date minimale : ignorer les articles trop anciens ──────────────
|
||||||
|
const MIN_DATE = new Date('2026-04-01');
|
||||||
|
if (pubDate && pubDate < MIN_DATE) {
|
||||||
|
result.skippedItems++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Clé de déduplication : basée sur le titre NORMALISÉ (sans département) ───
|
// ─── Clé de déduplication : basée sur le titre NORMALISÉ (sans département) ───
|
||||||
const normalizedTitle = buildMergeKey(title);
|
const normalizedTitle = buildMergeKey(title);
|
||||||
const dedupKey = dedupHash(normalizedTitle + "|" + (feed.feedType ?? ""));
|
const dedupKey = dedupHash(normalizedTitle + "|" + (feed.feedType ?? ""));
|
||||||
|
|
||||||
|
// ─── Tombstone : ignorer les articles déjà traités (même supprimés) ─────────
|
||||||
|
const tombstone = await db.select().from(processedDedupKeys)
|
||||||
|
.where(eq(processedDedupKeys.dedupKey, dedupKey))
|
||||||
|
.limit(1);
|
||||||
|
if (tombstone.length > 0) {
|
||||||
|
result.skippedItems++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (feed.feedType === "veille") {
|
if (feed.feedType === "veille") {
|
||||||
// ── Classification IA (avec fallback sur mots-clés) ──────────────────────────────────────
|
// ── Classification IA (avec fallback sur mots-clés) ──────────────────────────────────────
|
||||||
const { niveau, territoire } = detectVeilleNiveauTerritoire(fullText);
|
const { niveau, territoire } = detectVeilleNiveauTerritoire(fullText);
|
||||||
@@ -372,14 +563,29 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
|
|||||||
},
|
},
|
||||||
contenuPage
|
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
|
const typeVeille = (aiResult.typeVeille ?? feed.defaultTypeVeille ?? "informationnelle") as
|
||||||
"reglementaire" | "concurrentielle" | "technologique" | "informationnelle";
|
"reglementaire" | "concurrentielle" | "technologique" | "informationnelle";
|
||||||
|
|
||||||
|
// ── Filtre de pertinence : ne pas insérer les articles non pertinents ──────
|
||||||
|
if (!aiResult.relevant) {
|
||||||
|
console.log(`[RSS] Article rejeté (non pertinent) : "${title.substring(0, 80)}" — ${aiResult.reason}`);
|
||||||
|
result.skippedItems++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Générer le résumé IA uniquement pour les articles pertinents
|
// Générer le résumé IA uniquement pour les articles pertinents
|
||||||
const iaResume = aiResult.relevant
|
const iaResume = await generateSummary(title, description);
|
||||||
? await generateSummary(title, description)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Essayer d'insérer
|
// Essayer d'insérer
|
||||||
@@ -399,6 +605,8 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
|
|||||||
iaReason: aiResult.reason,
|
iaReason: aiResult.reason,
|
||||||
iaResume: iaResume || null,
|
iaResume: iaResume || null,
|
||||||
});
|
});
|
||||||
|
// Enregistrer le tombstone pour éviter la réinsertion après purge
|
||||||
|
await db.insert(processedDedupKeys).values({ dedupKey, feedType: "veille" }).onDuplicateKeyUpdate({ set: { dedupKey } });
|
||||||
result.newItems++;
|
result.newItems++;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.message?.includes("Duplicate entry") || e?.cause?.message?.includes("Duplicate entry")) {
|
if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.message?.includes("Duplicate entry") || e?.cause?.message?.includes("Duplicate entry")) {
|
||||||
@@ -432,22 +640,37 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
|
|||||||
? await fetchArticleFirstParagraph(link)
|
? await fetchArticleFirstParagraph(link)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const aiResult = await classifyArticle(
|
const aiResult = await classifyAap(
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
() => ({
|
(feed.defaultCategorieAap ?? "Autre") as "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre",
|
||||||
typeVeille: "informationnelle" as const,
|
|
||||||
}),
|
|
||||||
contenuPageAap
|
contenuPageAap
|
||||||
);
|
);
|
||||||
|
if (aiResult.technicalError) {
|
||||||
const categorie = (feed.defaultCategorieAap ?? "Autre") as
|
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";
|
"Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre";
|
||||||
|
|
||||||
// Générer le résumé IA uniquement pour les articles pertinents
|
// ── Filtre de pertinence : ne pas insérer les AAP non pertinents ─────────
|
||||||
const iaResume = aiResult.relevant
|
if (!aiResult.relevant) {
|
||||||
? await generateSummary(title, description)
|
console.log(`[RSS] AAP rejeté (non pertinent) : "${title.substring(0, 80)}" — ${aiResult.reason}`);
|
||||||
: null;
|
result.skippedItems++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Générer le résumé IA pour les AAP pertinents
|
||||||
|
const iaResume = await generateSummary(title, description);
|
||||||
|
|
||||||
|
// Extraire la date de clôture depuis le texte
|
||||||
|
const dateCloture = extractDateCloture(fullText);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await db.insert(aapItems).values({
|
await db.insert(aapItems).values({
|
||||||
@@ -459,11 +682,14 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
|
|||||||
departements: JSON.stringify(departement ? [departement] : []),
|
departements: JSON.stringify(departement ? [departement] : []),
|
||||||
lien: link || null,
|
lien: link || null,
|
||||||
datePublication: pubDate,
|
datePublication: pubDate,
|
||||||
|
dateCloture: dateCloture || null,
|
||||||
iaRelevant: aiResult.relevant,
|
iaRelevant: aiResult.relevant,
|
||||||
iaClassifiedBy: aiResult.classifiedBy,
|
iaClassifiedBy: aiResult.classifiedBy,
|
||||||
iaReason: aiResult.reason,
|
iaReason: aiResult.reason,
|
||||||
iaResume: iaResume || null,
|
iaResume: iaResume || null,
|
||||||
});
|
});
|
||||||
|
// Enregistrer le tombstone pour éviter la réinsertion après purge
|
||||||
|
await db.insert(processedDedupKeys).values({ dedupKey, feedType: "aap" }).onDuplicateKeyUpdate({ set: { dedupKey } });
|
||||||
result.newItems++;
|
result.newItems++;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.message?.includes("Duplicate entry") || e?.cause?.message?.includes("Duplicate entry")) {
|
if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.message?.includes("Duplicate entry") || e?.cause?.message?.includes("Duplicate entry")) {
|
||||||
@@ -496,10 +722,10 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
|
|||||||
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
result.status = "error";
|
result.status = "error";
|
||||||
result.error = e?.message ?? String(e);
|
result.errorMessage = e?.message ?? String(e);
|
||||||
try {
|
try {
|
||||||
await db.update(rssFeeds)
|
await db.update(rssFeeds)
|
||||||
.set({ lastFetchedAt: new Date(), lastFetchStatus: "error", lastFetchError: result.error })
|
.set({ lastFetchedAt: new Date(), lastFetchStatus: "error", lastFetchError: result.errorMessage })
|
||||||
.where(eq(rssFeeds.id, feed.id));
|
.where(eq(rssFeeds.id, feed.id));
|
||||||
} catch (_) { /* ignore */ }
|
} catch (_) { /* ignore */ }
|
||||||
}
|
}
|
||||||
@@ -569,6 +795,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
|
|||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// Si le nouveau dedupKey existe déjà → cet article est un doublon, le supprimer
|
// 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")) {
|
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));
|
await db.delete(veilleItems).where(eq(veilleItems.id, row.id));
|
||||||
veilleMerged++;
|
veilleMerged++;
|
||||||
} else {
|
} else {
|
||||||
@@ -611,6 +838,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
|
|||||||
|
|
||||||
// Supprimer les doublons
|
// Supprimer les doublons
|
||||||
for (const dup of duplicates) {
|
for (const dup of duplicates) {
|
||||||
|
await removeArticleReadRecords("veille", [dup.id]);
|
||||||
await db.delete(veilleItems).where(eq(veilleItems.id, dup.id));
|
await db.delete(veilleItems).where(eq(veilleItems.id, dup.id));
|
||||||
veilleMerged++;
|
veilleMerged++;
|
||||||
}
|
}
|
||||||
@@ -619,6 +847,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
|
|||||||
// Si le newDedupKey existe déjà → supprimer tout le groupe
|
// 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")) {
|
if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.cause?.message?.includes("Duplicate entry")) {
|
||||||
for (const row of sorted) {
|
for (const row of sorted) {
|
||||||
|
await removeArticleReadRecords("veille", [row.id]);
|
||||||
await db.delete(veilleItems).where(eq(veilleItems.id, row.id));
|
await db.delete(veilleItems).where(eq(veilleItems.id, row.id));
|
||||||
veilleMerged++;
|
veilleMerged++;
|
||||||
}
|
}
|
||||||
@@ -662,6 +891,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
|
|||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// Si le nouveau dedupKey existe déjà → cet article est un doublon, le supprimer
|
// 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")) {
|
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));
|
await db.delete(aapItems).where(eq(aapItems.id, row.id));
|
||||||
aapMerged++;
|
aapMerged++;
|
||||||
} else {
|
} else {
|
||||||
@@ -697,6 +927,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
|
|||||||
.where(eq(aapItems.id, primary.id));
|
.where(eq(aapItems.id, primary.id));
|
||||||
|
|
||||||
for (const dup of duplicates) {
|
for (const dup of duplicates) {
|
||||||
|
await removeArticleReadRecords("aap", [dup.id]);
|
||||||
await db.delete(aapItems).where(eq(aapItems.id, dup.id));
|
await db.delete(aapItems).where(eq(aapItems.id, dup.id));
|
||||||
aapMerged++;
|
aapMerged++;
|
||||||
}
|
}
|
||||||
@@ -705,6 +936,7 @@ export async function migrateExistingItems(): Promise<MigrationSummary> {
|
|||||||
// Si le newDedupKey existe déjà → supprimer tout le groupe
|
// 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")) {
|
if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.cause?.message?.includes("Duplicate entry")) {
|
||||||
for (const row of sorted) {
|
for (const row of sorted) {
|
||||||
|
await removeArticleReadRecords("aap", [row.id]);
|
||||||
await db.delete(aapItems).where(eq(aapItems.id, row.id));
|
await db.delete(aapItems).where(eq(aapItems.id, row.id));
|
||||||
aapMerged++;
|
aapMerged++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,18 @@
|
|||||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
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 { appRouter } from "./routers";
|
||||||
import type { TrpcContext } from "./_core/context";
|
import type { TrpcContext } from "./_core/context";
|
||||||
|
|
||||||
@@ -108,6 +122,12 @@ describe("protection admin", () => {
|
|||||||
const caller = appRouter.createCaller(ctx);
|
const caller = appRouter.createCaller(ctx);
|
||||||
await expect(caller.users.list()).rejects.toThrow();
|
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 ───────────────────────────────────────────────────────
|
// ─── Tests accès public ───────────────────────────────────────────────────────
|
||||||
|
|||||||
42
todo.md
42
todo.md
@@ -162,3 +162,45 @@
|
|||||||
- [x] Mettre à jour la signature de classifyArticle pour accepter un paramètre optionnel contenuPage
|
- [x] Mettre à jour la signature de classifyArticle pour accepter un paramètre optionnel contenuPage
|
||||||
- [x] 0 erreur TypeScript, tests passés
|
- [x] 0 erreur TypeScript, tests passés
|
||||||
- [x] Checkpoint
|
- [x] Checkpoint
|
||||||
|
|
||||||
|
## Filtre de pertinence strict à l'insertion + mise à jour prompt IA
|
||||||
|
- [x] aiClassifier.ts : mettre à jour le prompt avec la formulation exacte fournie (expert ESMS, 5 secteurs, liste noire, "doute = false")
|
||||||
|
- [x] rssEngine.ts : ajouter filtre `if (!aiResult.relevant) { skip; continue; }` avant insertion veille_items
|
||||||
|
- [x] rssEngine.ts : même filtre avant insertion aap_items
|
||||||
|
- [x] 21 tests passés, 0 erreur TypeScript
|
||||||
|
- [x] Déployer en recette
|
||||||
|
- [x] Déployer en production (en attente validation recette — intentionnel)
|
||||||
|
- [x] Mode vignette par défaut
|
||||||
|
- [x] Alternance de couleurs en mode liste (pair=blanc, impair=gris clair)
|
||||||
|
- [x] Couleur de sélection au clic sur une ligne (bg-primary/10)
|
||||||
|
- [x] Filtre Lu/Non lu avec bouton segmenté (Non lu par défaut)
|
||||||
|
- [x] Message vide adapté selon le filtre actif
|
||||||
|
|
||||||
|
## Paramétrage fréquence des mises à jour
|
||||||
|
- [x] BDD : fetch_mode et fetch_interval_minutes stockés comme clés/valeurs dans app_settings (pas de migration nécessaire)
|
||||||
|
- [x] Backend index.ts : lire fetch_mode et fetch_interval_minutes pour construire l'expression cron
|
||||||
|
- [x] Backend routers.ts : exposer et sauvegarder ces deux nouveaux paramètres
|
||||||
|
- [x] Frontend Settings.tsx : sélecteur mode (heure fixe / intervalle) + sélecteur intervalle (1h, 2h, 4h, 6h, 12h, 24h)
|
||||||
|
- [x] Déployer en recette
|
||||||
|
|
||||||
|
## Évolutions planification et rétention
|
||||||
|
- [x] Afficher la prochaine date d'exécution dynamique dans les Paramètres (selon mode/heure/jour choisi)
|
||||||
|
- [x] Ajouter la règle de rétention configurable dans les Paramètres (supprimer articles > N mois)
|
||||||
|
- [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