diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/client/src/App.tsx b/client/src/App.tsx index 5c7a610..1f85b3c 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,39 +1,132 @@ import { Toaster } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; import NotFound from "@/pages/NotFound"; -import { Route, Switch } from "wouter"; +import { Route, Switch, Redirect } from "wouter"; import ErrorBoundary from "./components/ErrorBoundary"; import { ThemeProvider } from "./contexts/ThemeContext"; -import Home from "./pages/Home"; +import { LocalAuthProvider, useLocalAuth } from "./contexts/LocalAuthContext"; +import { AppLayout } from "./components/AppLayout"; +import Login from "./pages/Login"; +import VeilleDashboard from "./pages/VeilleDashboard"; +import AAPDashboard from "./pages/AAPDashboard"; +import Settings from "./pages/Settings"; +import UsersAdmin from "./pages/UsersAdmin"; +import ImportLogs from "./pages/ImportLogs"; +import { Loader2 } from "lucide-react"; + +// ─── Guard d'authentification ───────────────────────────────────────────────── + +function AuthGuard({ children }: { children: React.ReactNode }) { + const { user, loading } = useLocalAuth(); + + if (loading) { + return ( +
+ +
+ ); + } + + if (!user) { + return ; + } + + return <>{children}; +} + +// ─── Layout avec sidebar ────────────────────────────────────────────────────── + +function DashboardWrapper({ children }: { children: React.ReactNode }) { + const { user, logout } = useLocalAuth(); + return ( + + {children} + + ); +} + +// ─── Pages protégées ────────────────────────────────────────────────────────── + +function VeillePage() { + return ( + + + + + + ); +} + +function AAPPage() { + return ( + + + + + + ); +} + +function SettingsPage() { + return ( + + + + + + ); +} + +function UsersPage() { + return ( + + + + + + ); +} + +function LogsPage() { + return ( + + + + + + ); +} + +// ─── Routeur principal ──────────────────────────────────────────────────────── function Router() { - // make sure to consider if you need authentication for certain routes return ( - - - {/* Final fallback route */} + + + + + + + + + + ); } -// NOTE: About Theme -// - First choose a default theme according to your design style (dark or light bg), than change color palette in index.css -// to keep consistent foreground/background color across components -// - If you want to make theme switchable, pass `switchable` ThemeProvider and use `useTheme` hook - function App() { return ( - - - - - + + + + + + + ); diff --git a/client/src/components/AppLayout.tsx b/client/src/components/AppLayout.tsx new file mode 100644 index 0000000..a9ecd56 --- /dev/null +++ b/client/src/components/AppLayout.tsx @@ -0,0 +1,284 @@ +import { useState, useEffect } from "react"; +import { Link, useLocation } from "wouter"; +import { + LayoutDashboard, + FileSearch, + Target, + Settings, + Users, + LogOut, + ChevronLeft, + ChevronRight, + FileText, + Activity, + ChevronDown, + ChevronUp, + RefreshCw, + Menu, + X, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { trpc } from "@/lib/trpc"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; + +interface NavItem { + label: string; + href: string; + icon: React.ReactNode; + badge?: string; + adminOnly?: boolean; +} + +interface NavGroup { + label: string; + icon: React.ReactNode; + items: NavItem[]; + defaultOpen?: boolean; +} + +const NAV_GROUPS: NavGroup[] = [ + { + label: "Tableaux de bord", + icon: , + defaultOpen: true, + items: [ + { label: "Veille Stratégique", href: "/veille", icon: }, + { label: "Appels à Projets", href: "/aap", icon: }, + ], + }, + { + label: "Administration", + icon: , + defaultOpen: false, + items: [ + { label: "Logs d'import", href: "/admin/logs", icon: , adminOnly: true }, + { label: "Utilisateurs", href: "/admin/users", icon: , adminOnly: true }, + { label: "Paramètres", href: "/admin/settings", icon: , adminOnly: true }, + ], + }, +]; + +interface AppLayoutProps { + children: React.ReactNode; + user: { name?: string | null; email?: string | null; role?: string } | null; + onLogout: () => void; +} + +export function AppLayout({ children, user, onLogout }: AppLayoutProps) { + const [collapsed, setCollapsed] = useState(false); + const [mobileOpen, setMobileOpen] = useState(false); + const [openGroups, setOpenGroups] = useState>({ + "Tableaux de bord": true, + "Administration": false, + }); + const [location] = useLocation(); + + const isAdmin = user?.role === "admin"; + + const importMutation = trpc.import.run.useMutation({ + onSuccess: (data) => { + const v = "veille" in data ? data.veille : null; + const a = "aap" in data ? data.aap : null; + const msg = [ + v ? `Veille: +${v.newRows} nouvelles entrées` : null, + a ? `AAP: +${a.newRows} nouvelles entrées` : null, + ].filter(Boolean).join(" | "); + toast.success("Import terminé", { description: msg || "Aucune nouvelle entrée" }); + }, + onError: (e) => toast.error("Erreur d'import", { description: e.message }), + }); + + const toggleGroup = (label: string) => { + setOpenGroups((prev) => ({ ...prev, [label]: !prev[label] })); + }; + + const SidebarContent = () => ( +
+ {/* Logo */} +
+
+ +
+ {!collapsed && ( +
+

Veille Réglementaire

+

Direction des Opérations

+
+ )} +
+ + {/* Navigation */} + + + {/* Import rapide */} + {isAdmin && ( +
+ +
+ )} + + {/* Utilisateur */} +
+ {!collapsed ? ( +
+
+ + {(user?.name || user?.email || "?")[0].toUpperCase()} + +
+
+

{user?.name || "Utilisateur"}

+

{user?.role === "admin" ? "Administrateur" : user?.role === "readonly" ? "Lecture seule" : "Utilisateur"}

+
+ +
+ ) : ( + + )} +
+
+ ); + + return ( +
+ {/* Sidebar desktop */} + + + {/* Sidebar mobile overlay */} + {mobileOpen && ( +
+
setMobileOpen(false)} /> + +
+ )} + + {/* Contenu principal */} +
+ {/* Header mobile */} +
+ + Veille Réglementaire +
+ + {/* Zone de contenu scrollable */} +
+ {children} +
+
+
+ ); +} diff --git a/client/src/components/FilterBar.tsx b/client/src/components/FilterBar.tsx new file mode 100644 index 0000000..031aa06 --- /dev/null +++ b/client/src/components/FilterBar.tsx @@ -0,0 +1,149 @@ +import { useState } from "react"; +import { Search, X, Filter, ChevronDown } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; + +export interface FilterOption { + key: string; + label: string; + options?: string[]; + type?: "select" | "date"; +} + +interface FilterBarProps { + filters: FilterOption[]; + values: Record; + onChange: (key: string, value: string) => void; + onReset: () => void; + searchKey?: string; + searchPlaceholder?: string; + totalCount?: number; + filteredCount?: number; +} + +export function FilterBar({ + filters, + values, + onChange, + onReset, + searchKey = "search", + searchPlaceholder = "Rechercher…", + totalCount, + filteredCount, +}: FilterBarProps) { + const [expanded, setExpanded] = useState(false); + + const activeCount = Object.values(values).filter((v) => v && v !== "all").length; + + return ( +
+ {/* Barre principale */} +
+ {/* Recherche */} +
+ + onChange(searchKey, e.target.value)} + className="pl-9 h-9 bg-background" + /> + {values[searchKey] && ( + + )} +
+ + {/* Bouton filtres */} + + + {/* Reset */} + {activeCount > 0 && ( + + )} + + {/* Compteur */} + {totalCount !== undefined && ( + + {filteredCount !== undefined && filteredCount !== totalCount + ? `${filteredCount} / ${totalCount} résultats` + : `${totalCount} résultat${totalCount !== 1 ? "s" : ""}`} + + )} +
+ + {/* Filtres étendus */} + {expanded && ( +
+ {filters.map((filter) => { + if (filter.type === "date") { + return ( +
+ + onChange(filter.key, e.target.value)} + className="h-8 text-sm bg-background" + /> +
+ ); + } + + return ( +
+ + +
+ ); + })} +
+ )} +
+ ); +} diff --git a/client/src/contexts/LocalAuthContext.tsx b/client/src/contexts/LocalAuthContext.tsx new file mode 100644 index 0000000..6131396 --- /dev/null +++ b/client/src/contexts/LocalAuthContext.tsx @@ -0,0 +1,78 @@ +import { createContext, useContext, useState, useEffect, ReactNode } from "react"; +import { trpc } from "@/lib/trpc"; + +interface LocalUser { + id: number; + name: string; + email: string; + role: "admin" | "user" | "readonly"; +} + +interface LocalAuthContextType { + user: LocalUser | null; + loading: boolean; + login: (email: string, password: string) => Promise; + logout: () => Promise; + isAuthenticated: boolean; +} + +const LocalAuthContext = createContext(null); + +const LOCAL_USER_KEY = "veille_local_user"; + +export function LocalAuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(() => { + try { + const stored = localStorage.getItem(LOCAL_USER_KEY); + return stored ? JSON.parse(stored) : null; + } catch { + return null; + } + }); + const [loading, setLoading] = useState(false); + + const loginMutation = trpc.auth.localLogin.useMutation(); + const logoutMutation = trpc.auth.localLogout.useMutation(); + + const login = async (email: string, password: string) => { + setLoading(true); + try { + const result = await loginMutation.mutateAsync({ email, password }); + const localUser = result.user as LocalUser; + setUser(localUser); + localStorage.setItem(LOCAL_USER_KEY, JSON.stringify(localUser)); + } finally { + setLoading(false); + } + }; + + const logout = async () => { + try { + await logoutMutation.mutateAsync(); + } catch { + // ignore + } + setUser(null); + localStorage.removeItem(LOCAL_USER_KEY); + }; + + return ( + + {children} + + ); +} + +export function useLocalAuth() { + const ctx = useContext(LocalAuthContext); + if (!ctx) throw new Error("useLocalAuth must be used within LocalAuthProvider"); + return ctx; +} diff --git a/client/src/index.css b/client/src/index.css index 72b423d..d62ab1d 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -43,74 +43,77 @@ } :root { - --primary: var(--color-blue-700); - --primary-foreground: var(--color-blue-50); - --sidebar-primary: var(--color-blue-600); - --sidebar-primary-foreground: var(--color-blue-50); - --chart-1: var(--color-blue-300); - --chart-2: var(--color-blue-500); - --chart-3: var(--color-blue-600); - --chart-4: var(--color-blue-700); - --chart-5: var(--color-blue-800); --radius: 0.65rem; - --background: oklch(1 0 0); - --foreground: oklch(0.235 0.015 65); + --background: oklch(0.98 0.004 240); + --foreground: oklch(0.15 0.02 240); --card: oklch(1 0 0); - --card-foreground: oklch(0.235 0.015 65); + --card-foreground: oklch(0.15 0.02 240); --popover: oklch(1 0 0); - --popover-foreground: oklch(0.235 0.015 65); - --secondary: oklch(0.98 0.001 286.375); - --secondary-foreground: oklch(0.4 0.015 65); - --muted: oklch(0.967 0.001 286.375); - --muted-foreground: oklch(0.552 0.016 285.938); - --accent: oklch(0.967 0.001 286.375); - --accent-foreground: oklch(0.141 0.005 285.823); - --destructive: oklch(0.577 0.245 27.325); + --popover-foreground: oklch(0.15 0.02 240); + /* Bleu marine Itinova */ + --primary: oklch(0.32 0.12 240); + --primary-foreground: oklch(0.98 0.004 240); + --secondary: oklch(0.94 0.01 240); + --secondary-foreground: oklch(0.32 0.12 240); + --muted: oklch(0.95 0.005 240); + --muted-foreground: oklch(0.50 0.02 240); + /* Teal accent */ + --accent: oklch(0.55 0.14 195); + --accent-foreground: oklch(1 0 0); + --destructive: oklch(0.55 0.22 25); --destructive-foreground: oklch(0.985 0 0); - --border: oklch(0.92 0.004 286.32); - --input: oklch(0.92 0.004 286.32); - --ring: oklch(0.623 0.214 259.815); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.235 0.015 65); - --sidebar-accent: oklch(0.967 0.001 286.375); - --sidebar-accent-foreground: oklch(0.141 0.005 285.823); - --sidebar-border: oklch(0.92 0.004 286.32); - --sidebar-ring: oklch(0.623 0.214 259.815); + --border: oklch(0.88 0.01 240); + --input: oklch(0.92 0.008 240); + --ring: oklch(0.32 0.12 240); + --chart-1: oklch(0.55 0.14 195); + --chart-2: oklch(0.45 0.12 240); + --chart-3: oklch(0.65 0.12 160); + --chart-4: oklch(0.60 0.15 280); + --chart-5: oklch(0.55 0.18 30); + /* Sidebar sombre */ + --sidebar: oklch(0.20 0.06 240); + --sidebar-foreground: oklch(0.92 0.01 240); + --sidebar-primary: oklch(0.60 0.14 195); + --sidebar-primary-foreground: oklch(1 0 0); + --sidebar-accent: oklch(0.28 0.08 240); + --sidebar-accent-foreground: oklch(0.92 0.01 240); + --sidebar-border: oklch(0.28 0.06 240); + --sidebar-ring: oklch(0.60 0.14 195); } .dark { - --primary: var(--color-blue-700); - --primary-foreground: var(--color-blue-50); - --sidebar-primary: var(--color-blue-500); - --sidebar-primary-foreground: var(--color-blue-50); - --background: oklch(0.141 0.005 285.823); - --foreground: oklch(0.85 0.005 65); - --card: oklch(0.21 0.006 285.885); - --card-foreground: oklch(0.85 0.005 65); - --popover: oklch(0.21 0.006 285.885); - --popover-foreground: oklch(0.85 0.005 65); - --secondary: oklch(0.24 0.006 286.033); - --secondary-foreground: oklch(0.7 0.005 65); - --muted: oklch(0.274 0.006 286.033); - --muted-foreground: oklch(0.705 0.015 286.067); - --accent: oklch(0.274 0.006 286.033); - --accent-foreground: oklch(0.92 0.005 65); - --destructive: oklch(0.704 0.191 22.216); + --background: oklch(0.12 0.02 240); + --foreground: oklch(0.92 0.01 240); + --card: oklch(0.17 0.03 240); + --card-foreground: oklch(0.92 0.01 240); + --popover: oklch(0.17 0.03 240); + --popover-foreground: oklch(0.92 0.01 240); + --primary: oklch(0.60 0.14 195); + --primary-foreground: oklch(0.12 0.02 240); + --secondary: oklch(0.22 0.04 240); + --secondary-foreground: oklch(0.92 0.01 240); + --muted: oklch(0.22 0.04 240); + --muted-foreground: oklch(0.60 0.02 240); + --accent: oklch(0.60 0.14 195); + --accent-foreground: oklch(0.12 0.02 240); + --destructive: oklch(0.60 0.22 25); --destructive-foreground: oklch(0.985 0 0); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.488 0.243 264.376); - --chart-1: var(--color-blue-300); - --chart-2: var(--color-blue-500); - --chart-3: var(--color-blue-600); - --chart-4: var(--color-blue-700); - --chart-5: var(--color-blue-800); - --sidebar: oklch(0.21 0.006 285.885); - --sidebar-foreground: oklch(0.85 0.005 65); - --sidebar-accent: oklch(0.274 0.006 286.033); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.488 0.243 264.376); + --border: oklch(0.28 0.04 240); + --input: oklch(0.22 0.04 240); + --ring: oklch(0.60 0.14 195); + --chart-1: oklch(0.65 0.14 195); + --chart-2: oklch(0.55 0.12 240); + --chart-3: oklch(0.70 0.12 160); + --chart-4: oklch(0.65 0.15 280); + --chart-5: oklch(0.60 0.18 30); + --sidebar: oklch(0.14 0.03 240); + --sidebar-foreground: oklch(0.88 0.01 240); + --sidebar-primary: oklch(0.60 0.14 195); + --sidebar-primary-foreground: oklch(0.12 0.02 240); + --sidebar-accent: oklch(0.20 0.04 240); + --sidebar-accent-foreground: oklch(0.88 0.01 240); + --sidebar-border: oklch(0.22 0.04 240); + --sidebar-ring: oklch(0.60 0.14 195); } @layer base { diff --git a/client/src/pages/AAPDashboard.tsx b/client/src/pages/AAPDashboard.tsx new file mode 100644 index 0000000..61c4a11 --- /dev/null +++ b/client/src/pages/AAPDashboard.tsx @@ -0,0 +1,312 @@ +import { useState, useMemo } from "react"; +import { trpc } from "@/lib/trpc"; +import { FilterBar } from "@/components/FilterBar"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + LayoutGrid, + List, + ExternalLink, + Calendar, + MapPin, + Target, + Loader2, + ChevronLeft, + ChevronRight, + AlertCircle, + Clock, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { format, isPast, differenceInDays } from "date-fns"; +import { fr } from "date-fns/locale"; + +type AAPCategorie = "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre"; + +interface AAPItem { + id: number; + titre: string; + categorie: string; + region: string | null; + departement: string | null; + dateCloture: Date | null; + datePublication: Date | null; + lien: string | null; + importedAt: Date; +} + +const CAT_COLORS: Record = { + Handicap: "bg-violet-100 text-violet-800 border-violet-200", + PA: "bg-sky-100 text-sky-800 border-sky-200", + Enfance: "bg-pink-100 text-pink-800 border-pink-200", + "Précarité": "bg-orange-100 text-orange-800 border-orange-200", + Sanitaire: "bg-teal-100 text-teal-800 border-teal-200", + Autre: "bg-gray-100 text-gray-700 border-gray-200", +}; + +const CAT_ACCENT: Record = { + Handicap: "border-l-violet-500", + PA: "border-l-sky-500", + Enfance: "border-l-pink-500", + "Précarité": "border-l-orange-500", + Sanitaire: "border-l-teal-500", + Autre: "border-l-gray-400", +}; + +const PAGE_SIZE = 24; + +function formatDate(d: Date | null | undefined): string | null { + if (!d) return null; + try { return format(new Date(d), "d MMM yyyy", { locale: fr }); } + catch { return null; } +} + +function ClotureStatus({ date }: { date: Date | null | undefined }) { + if (!date) return ; + const d = new Date(date); + const past = isPast(d); + const daysLeft = differenceInDays(d, new Date()); + + if (past) { + return ( + + + Clôturé + + ); + } + if (daysLeft <= 7) { + return ( + + + {daysLeft}j restants + + ); + } + return {formatDate(d)}; +} + +export default function AAPDashboard() { + const [viewMode, setViewMode] = useState<"list" | "grid">("list"); + const [activeTab, setActiveTab] = useState("all"); + const [page, setPage] = useState(1); + const [filterValues, setFilterValues] = useState>({}); + + const filtersQuery = trpc.aap.filters.useQuery(); + + const queryInput = useMemo(() => ({ + categorie: activeTab !== "all" ? activeTab : undefined, + region: filterValues.region || undefined, + departement: filterValues.departement || undefined, + search: filterValues.search || undefined, + dateFrom: filterValues.dateFrom ? new Date(filterValues.dateFrom) : undefined, + dateTo: filterValues.dateTo ? new Date(filterValues.dateTo) : undefined, + clotureFrom: filterValues.clotureFrom ? new Date(filterValues.clotureFrom) : undefined, + clotureTo: filterValues.clotureTo ? new Date(filterValues.clotureTo) : undefined, + page, + pageSize: PAGE_SIZE, + }), [activeTab, filterValues, page]); + + const itemsQuery = trpc.aap.list.useQuery(queryInput); + + const handleFilterChange = (key: string, value: string) => { + setFilterValues((prev) => ({ ...prev, [key]: value })); + setPage(1); + }; + + const handleReset = () => { + setFilterValues({}); + setPage(1); + }; + + const items = (itemsQuery.data?.items ?? []) as AAPItem[]; + const total = itemsQuery.data?.total ?? 0; + const totalPages = Math.ceil(total / PAGE_SIZE); + + const filterOptions = [ + { key: "region", label: "Région", options: filtersQuery.data?.regions ?? [] }, + { key: "departement", label: "Département", options: filtersQuery.data?.departements ?? [] }, + { key: "dateFrom", label: "Publié depuis", type: "date" as const }, + { key: "dateTo", label: "Publié jusqu'à", type: "date" as const }, + { key: "clotureFrom", label: "Clôture depuis", type: "date" as const }, + { key: "clotureTo", label: "Clôture jusqu'à", type: "date" as const }, + ]; + + const categories: AAPCategorie[] = ["Handicap", "PA", "Enfance", "Précarité", "Sanitaire", "Autre"]; + + return ( +
+ {/* En-tête */} +
+
+
+ +

Appels à Projets

+
+

+ Handicap, Personnes Âgées, Enfance, Précarité, Sanitaire et Autre +

+
+
+ + +
+
+ + {/* Onglets */} + { setActiveTab(v as AAPCategorie | "all"); setPage(1); }}> + + Tous + {categories.map((c) => ( + {c} + ))} + + + + {/* Filtres */} + + + {/* Contenu */} + {itemsQuery.isLoading ? ( +
+ +
+ ) : items.length === 0 ? ( +
+ +

Aucun appel à projets trouvé

+

Modifiez vos filtres ou importez des données

+
+ ) : viewMode === "list" ? ( + + ) : ( + + )} + + {/* Pagination */} + {totalPages > 1 && ( +
+ + Page {page} / {totalPages} + +
+ )} +
+ ); +} + +// ─── Vue Liste ──────────────────────────────────────────────────────────────── + +function AAPListView({ items }: { items: AAPItem[] }) { + return ( +
+
+ + + + + + + + + + + + + + + {items.map((item, idx) => ( + + + + + + + + + + + ))} + +
#TitreCatégorieRégionDépartementPublicationClôtureLien
{idx + 1} +

{item.titre}

+
+ + {item.categorie} + + {item.region || "—"}{item.departement || "—"}{formatDate(item.datePublication) || "—"} + {item.lien && ( + + + + )} +
+
+
+ ); +} + +// ─── Vue Vignettes ──────────────────────────────────────────────────────────── + +function AAPGridView({ items }: { items: AAPItem[] }) { + return ( +
+ {items.map((item) => ( + + +
+ + {item.categorie} + + {item.lien && ( + + + + )} +
+

{item.titre}

+
+ +
+ {item.region && ( + + {item.region} + + )} + {item.departement && ( + + {item.departement} + + )} +
+
+ {item.datePublication && ( + + + {formatDate(item.datePublication)} + + )} + +
+
+
+ ))} +
+ ); +} diff --git a/client/src/pages/ImportLogs.tsx b/client/src/pages/ImportLogs.tsx new file mode 100644 index 0000000..bc170ac --- /dev/null +++ b/client/src/pages/ImportLogs.tsx @@ -0,0 +1,192 @@ +import { useState } from "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 { + Activity, + CheckCircle, + XCircle, + Loader2, + RefreshCw, + ChevronLeft, + ChevronRight, + Clock, + FileText, +} from "lucide-react"; +import { format } from "date-fns"; +import { fr } from "date-fns/locale"; +import { cn } from "@/lib/utils"; + +interface ImportLog { + id: number; + fileType: "veille" | "aap"; + status: "success" | "error" | "partial"; + newRows: number | null; + skippedRows: number | null; + totalRows: number | null; + errorMessage: string | null; + startedAt: Date; + completedAt: Date | null; + source: string | null; +} + +const STATUS_CONFIG = { + success: { label: "Succès", color: "bg-emerald-100 text-emerald-800 border-emerald-200", icon: }, + error: { label: "Erreur", color: "bg-red-100 text-red-800 border-red-200", icon: }, + partial: { label: "Partiel", color: "bg-amber-100 text-amber-800 border-amber-200", icon: }, +}; + +const FILE_CONFIG = { + veille: { label: "Veille Stratégique", color: "bg-blue-100 text-blue-800 border-blue-200" }, + aap: { label: "Appels à Projets", color: "bg-violet-100 text-violet-800 border-violet-200" }, +}; + +const PAGE_SIZE = 20; + +export default function ImportLogs() { + const [page, setPage] = useState(1); + + const logsQuery = trpc.import.logs.useQuery({ page, pageSize: PAGE_SIZE }); + const importMutation = trpc.import.run.useMutation({ + onSuccess: () => logsQuery.refetch(), + }); + + const logs = (logsQuery.data?.logs ?? []) as unknown as ImportLog[]; + const total = logsQuery.data?.total ?? 0; + const totalPages = Math.ceil(total / PAGE_SIZE); + + return ( +
+ {/* En-tête */} +
+
+
+ +

Logs d'import

+
+

Historique des imports automatiques et manuels

+
+ +
+ + {/* Stats rapides */} + {logsQuery.data?.stats && ( +
+ {[ + { label: "Total imports", value: logsQuery.data.stats.total, icon: , color: "text-primary" }, + { label: "Succès", value: logsQuery.data.stats.success, icon: , color: "text-emerald-600" }, + { label: "Erreurs", value: logsQuery.data.stats.errors, icon: , color: "text-red-500" }, + { label: "Nouvelles entrées", value: logsQuery.data.stats.totalNewRows, icon: , color: "text-accent" }, + ].map((stat) => ( + + +
+ {stat.icon} + {stat.label} +
+

{stat.value}

+
+
+ ))} +
+ )} + + {/* Tableau des logs */} + + + {logsQuery.isLoading ? ( +
+ +
+ ) : logs.length === 0 ? ( +
+ +

Aucun log d'import

+

Les imports apparaîtront ici

+
+ ) : ( +
+ + + + + + + + + + + + + + + {logs.map((log) => { + const status = STATUS_CONFIG[log.status] || STATUS_CONFIG.error; + const fileConf = FILE_CONFIG[log.fileType] || FILE_CONFIG.veille; + return ( + + + + + + + + + + + ); + })} + +
DateFichierStatutNouvellesIgnoréesTotalDuréeMessage
+
+ + {format(new Date(log.startedAt), "d MMM yyyy HH:mm", { locale: fr })} +
+
+ + {fileConf.label} + + + + {status.icon} + {status.label} + + + +{log.newRows} + {log.skippedRows}{log.totalRows} + {log.startedAt && log.completedAt + ? `${((new Date(log.completedAt).getTime() - new Date(log.startedAt).getTime()) / 1000).toFixed(1)}s` + : "—"} + + {log.errorMessage ? ( + {log.errorMessage} + ) : "—"} +
+
+ )} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + Page {page} / {totalPages} + +
+ )} +
+ ); +} diff --git a/client/src/pages/Login.tsx b/client/src/pages/Login.tsx new file mode 100644 index 0000000..892879c --- /dev/null +++ b/client/src/pages/Login.tsx @@ -0,0 +1,133 @@ +import { useState } from "react"; +import { useLocation } from "wouter"; +import { useLocalAuth } from "@/contexts/LocalAuthContext"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { FileText, Eye, EyeOff, Loader2, Shield } from "lucide-react"; +import { toast } from "sonner"; + +export default function Login() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [loading, setLoading] = useState(false); + const { login } = useLocalAuth(); + const [, navigate] = useLocation(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!email || !password) return; + setLoading(true); + try { + await login(email, password); + navigate("/veille"); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Erreur de connexion"; + toast.error("Connexion échouée", { description: msg }); + } finally { + setLoading(false); + } + }; + + return ( +
+ {/* Décoration de fond */} +
+
+
+
+ +
+ {/* Logo et titre */} +
+
+ +
+

Veille Réglementaire

+

Direction des Opérations — Itinova

+
+ + + +
+ + Connexion sécurisée +
+ + Entrez vos identifiants pour accéder à l'application + +
+ +
+
+ + setEmail(e.target.value)} + autoComplete="email" + required + className="h-11" + /> +
+ +
+ +
+ setPassword(e.target.value)} + autoComplete="current-password" + required + className="h-11 pr-10" + /> + +
+
+ + +
+ +
+

+ Compte par défaut : admin@itinova.fr +
+ Mot de passe : Admin@Itinova2024! +

+
+
+
+ +

+ © {new Date().getFullYear()} Itinova — Application interne +

+
+
+ ); +} diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx new file mode 100644 index 0000000..733a4c2 --- /dev/null +++ b/client/src/pages/Settings.tsx @@ -0,0 +1,366 @@ +import { useState, useEffect } from "react"; +import { trpc } from "@/lib/trpc"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { Switch } from "@/components/ui/switch"; +import { + Settings as SettingsIcon, + HardDrive, + Cloud, + Server, + Globe, + Save, + RefreshCw, + CheckCircle, + Clock, + Loader2, + Info, +} from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; + +type SourceType = "local" | "onedrive" | "ftp" | "sharepoint"; + +const SOURCE_ICONS: Record = { + local: , + onedrive: , + ftp: , + sharepoint: , +}; + +const SOURCE_LABELS: Record = { + local: "Fichier local", + onedrive: "OneDrive", + ftp: "Serveur FTP", + sharepoint: "SharePoint", +}; + +export default function SettingsPage() { + const settingsQuery = trpc.settings.get.useQuery(); + const saveMutation = trpc.settings.save.useMutation({ + onSuccess: () => toast.success("Paramètres sauvegardés"), + onError: (e) => toast.error("Erreur", { description: e.message }), + }); + const importMutation = trpc.import.run.useMutation({ + onSuccess: (data) => { + const v = "veille" in data ? data.veille : null; + const a = "aap" in data ? data.aap : null; + toast.success("Import terminé", { + description: [ + v ? `Veille: +${v.newRows} nouvelles entrées (${v.skippedRows} ignorées)` : null, + a ? `AAP: +${a.newRows} nouvelles entrées (${a.skippedRows} ignorées)` : null, + ].filter(Boolean).join(" | "), + }); + }, + onError: (e) => toast.error("Erreur d'import", { description: e.message }), + }); + + const [form, setForm] = useState>({ + source_type: "local", + veille_file_path: "", + aap_file_path: "", + ftp_host: "", + ftp_port: "21", + ftp_user: "", + ftp_password: "", + ftp_secure: "false", + onedrive_token: "", + sharepoint_site_url: "", + sharepoint_token: "", + auth_mode: "local", + import_time: "06:00", + }); + + useEffect(() => { + if (settingsQuery.data) { + setForm((prev) => ({ ...prev, ...settingsQuery.data })); + } + }, [settingsQuery.data]); + + const set = (key: string, value: string) => setForm((prev) => ({ ...prev, [key]: value })); + + const handleSave = () => { + saveMutation.mutate(form as Parameters[0]); + }; + + const sourceType = (form.source_type || "local") as SourceType; + + return ( +
+ {/* En-tête */} +
+
+
+ +

Paramètres

+
+

Configuration de l'application et des imports

+
+ +
+ + + + Source des fichiers + Import & Planification + Authentification + + + {/* ─── Source des fichiers ─────────────────────────────────────── */} + + + + Type de source + Choisissez où sont stockés les fichiers Excel à importer + + +
+ {(["local", "onedrive", "ftp", "sharepoint"] as SourceType[]).map((s) => ( + + ))} +
+
+
+ + {/* Chemins des fichiers */} + + + Chemins des fichiers + + {sourceType === "local" + ? "Chemin absolu vers les fichiers sur le serveur" + : sourceType === "ftp" + ? "Chemin relatif sur le serveur FTP" + : "URL complète vers les fichiers"} + + + +
+ + set("veille_file_path", e.target.value)} + /> +
+
+ + set("aap_file_path", e.target.value)} + /> +
+
+
+ + {/* Configuration FTP */} + {sourceType === "ftp" && ( + + + Configuration FTP + + +
+ + set("ftp_host", e.target.value)} /> +
+
+ + set("ftp_port", e.target.value)} /> +
+
+ + set("ftp_user", e.target.value)} /> +
+
+ + set("ftp_password", e.target.value)} /> +
+
+ set("ftp_secure", v ? "true" : "false")} + /> + +
+
+
+ )} + + {/* Configuration OneDrive */} + {sourceType === "onedrive" && ( + + + Configuration OneDrive + Token d'accès Microsoft Graph API + + +
+ + set("onedrive_token", e.target.value)} /> +
+
+ +

+ Obtenez un token via Azure Active Directory avec les permissions Files.Read. Les URLs des fichiers doivent être des liens de téléchargement directs. +

+
+
+
+ )} + + {/* Configuration SharePoint */} + {sourceType === "sharepoint" && ( + + + Configuration SharePoint + + +
+ + set("sharepoint_site_url", e.target.value)} /> +
+
+ + set("sharepoint_token", e.target.value)} /> +
+
+
+ )} +
+ + {/* ─── Import & Planification ──────────────────────────────────── */} + + + + Planification de l'import + L'import automatique s'exécute quotidiennement à l'heure configurée + + +
+ + set("import_time", e.target.value)} + className="h-11" + /> +

+ Prochain import : demain à {form.import_time || "06:00"} +

+
+
+
+ + + + Import manuel + Déclenchez un import immédiat des deux fichiers + + +
+ + + +
+ {importMutation.isSuccess && ( +
+ + Import terminé avec succès +
+ )} +
+
+
+ + {/* ─── Authentification ────────────────────────────────────────── */} + + + + Mode d'accès + Définissez si l'application nécessite une authentification + + +
+ + +
+
+
+
+
+
+ ); +} diff --git a/client/src/pages/UsersAdmin.tsx b/client/src/pages/UsersAdmin.tsx new file mode 100644 index 0000000..9aece9f --- /dev/null +++ b/client/src/pages/UsersAdmin.tsx @@ -0,0 +1,313 @@ +import { useState } from "react"; +import { trpc } from "@/lib/trpc"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { + Users, + Plus, + Pencil, + Trash2, + Loader2, + ShieldCheck, + User, + Eye, + CheckCircle, + XCircle, +} from "lucide-react"; +import { toast } from "sonner"; +import { format } from "date-fns"; +import { fr } from "date-fns/locale"; +import { cn } from "@/lib/utils"; + +type Role = "admin" | "user" | "readonly"; + +interface LocalUser { + id: number; + name: string; + email: string; + role: Role; + isActive: boolean; + createdAt: Date; + lastSignedIn: Date | null; +} + +const ROLE_LABELS: Record = { + admin: "Administrateur", + user: "Utilisateur", + readonly: "Lecture seule", +}; + +const ROLE_COLORS: Record = { + admin: "bg-red-100 text-red-800 border-red-200", + user: "bg-blue-100 text-blue-800 border-blue-200", + readonly: "bg-gray-100 text-gray-700 border-gray-200", +}; + +const ROLE_ICONS: Record = { + admin: , + user: , + readonly: , +}; + +interface UserFormData { + name: string; + email: string; + password: string; + role: Role; + isActive: boolean; +} + +const DEFAULT_FORM: UserFormData = { name: "", email: "", password: "", role: "user", isActive: true }; + +export default function UsersAdmin() { + const [showDialog, setShowDialog] = useState(false); + const [editingUser, setEditingUser] = useState(null); + const [form, setForm] = useState(DEFAULT_FORM); + const [deleteId, setDeleteId] = useState(null); + + const usersQuery = trpc.users.list.useQuery(); + const utils = trpc.useUtils(); + + const createMutation = trpc.users.create.useMutation({ + onSuccess: () => { + toast.success("Utilisateur créé"); + utils.users.list.invalidate(); + setShowDialog(false); + setForm(DEFAULT_FORM); + }, + onError: (e) => toast.error("Erreur", { description: e.message }), + }); + + const updateMutation = trpc.users.update.useMutation({ + onSuccess: () => { + toast.success("Utilisateur mis à jour"); + utils.users.list.invalidate(); + setShowDialog(false); + setEditingUser(null); + }, + onError: (e) => toast.error("Erreur", { description: e.message }), + }); + + const deleteMutation = trpc.users.delete.useMutation({ + onSuccess: () => { + toast.success("Utilisateur supprimé"); + utils.users.list.invalidate(); + setDeleteId(null); + }, + onError: (e) => toast.error("Erreur", { description: e.message }), + }); + + const openCreate = () => { + setEditingUser(null); + setForm(DEFAULT_FORM); + setShowDialog(true); + }; + + const openEdit = (user: LocalUser) => { + setEditingUser(user); + setForm({ name: user.name, email: user.email, password: "", role: user.role, isActive: user.isActive }); + setShowDialog(true); + }; + + const handleSubmit = () => { + if (editingUser) { + const data: Parameters[0] = { + id: editingUser.id, + name: form.name, + email: form.email, + role: form.role, + isActive: form.isActive, + }; + if (form.password) data.password = form.password; + updateMutation.mutate(data); + } else { + createMutation.mutate({ name: form.name, email: form.email, password: form.password, role: form.role }); + } + }; + + const users = (usersQuery.data ?? []) as LocalUser[]; + + return ( +
+ {/* En-tête */} +
+
+
+ +

Gestion des utilisateurs

+
+

{users.length} utilisateur{users.length !== 1 ? "s" : ""} enregistré{users.length !== 1 ? "s" : ""}

+
+ +
+ + {/* Tableau */} + + + {usersQuery.isLoading ? ( +
+ +
+ ) : users.length === 0 ? ( +
+ +

Aucun utilisateur

+
+ ) : ( +
+ + + + + + + + + + + + + {users.map((user) => ( + + + + + + + + + ))} + +
NomEmailRôleStatutDernière connexionActions
+
+
+ {user.name[0]?.toUpperCase()} +
+ {user.name} +
+
{user.email} + + {ROLE_ICONS[user.role]} + {ROLE_LABELS[user.role]} + + + {user.isActive ? ( + + Actif + + ) : ( + + Inactif + + )} + + {user.lastSignedIn + ? format(new Date(user.lastSignedIn), "d MMM yyyy", { locale: fr }) + : "Jamais"} + +
+ + +
+
+
+ )} +
+
+ + {/* Dialog création/modification */} + + + + {editingUser ? "Modifier l'utilisateur" : "Nouvel utilisateur"} + + {editingUser ? "Modifiez les informations de l'utilisateur" : "Créez un nouveau compte utilisateur"} + + +
+
+
+ + setForm((f) => ({ ...f, name: e.target.value }))} /> +
+
+ + setForm((f) => ({ ...f, email: e.target.value }))} /> +
+
+ + setForm((f) => ({ ...f, password: e.target.value }))} /> +
+
+ + +
+ {editingUser && ( +
+ +
+ setForm((f) => ({ ...f, isActive: v }))} /> + {form.isActive ? "Actif" : "Inactif"} +
+
+ )} +
+
+ + + + +
+
+ + {/* Dialog suppression */} + setDeleteId(null)}> + + + Supprimer l'utilisateur + Cette action est irréversible. L'utilisateur ne pourra plus se connecter. + + + + + + + +
+ ); +} diff --git a/client/src/pages/VeilleDashboard.tsx b/client/src/pages/VeilleDashboard.tsx new file mode 100644 index 0000000..2e19493 --- /dev/null +++ b/client/src/pages/VeilleDashboard.tsx @@ -0,0 +1,281 @@ +import { useState, useMemo } from "react"; +import { trpc } from "@/lib/trpc"; +import { FilterBar } from "@/components/FilterBar"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + LayoutGrid, + List, + ExternalLink, + Calendar, + MapPin, + Tag, + Layers, + FileSearch, + Loader2, + ChevronLeft, + ChevronRight, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { format } from "date-fns"; +import { fr } from "date-fns/locale"; + +type TypeVeille = "reglementaire" | "concurrentielle" | "technologique" | "generale"; + +interface VeilleItem { + id: number; + titre: string; + typeVeille: string; + categorie: string | null; + niveau: string | null; + territoire: string | null; + resume: string | null; + lien: string | null; + datePublication: Date | null; + importedAt: Date; +} + +const TYPE_LABELS: Record = { + reglementaire: "Réglementaire", + concurrentielle: "Concurrentielle", + technologique: "Technologique", + generale: "Générale", +}; + +const TYPE_COLORS: Record = { + reglementaire: "bg-blue-100 text-blue-800 border-blue-200", + concurrentielle: "bg-purple-100 text-purple-800 border-purple-200", + technologique: "bg-emerald-100 text-emerald-800 border-emerald-200", + generale: "bg-amber-100 text-amber-800 border-amber-200", +}; + +const TYPE_ACCENT: Record = { + reglementaire: "border-l-blue-500", + concurrentielle: "border-l-purple-500", + technologique: "border-l-emerald-500", + generale: "border-l-amber-500", +}; + +const PAGE_SIZE = 24; + +function formatDate(d: Date | null | undefined): string | null { + if (!d) return null; + try { return format(new Date(d), "d MMM yyyy", { locale: fr }); } + catch { return null; } +} + +export default function VeilleDashboard() { + const [viewMode, setViewMode] = useState<"list" | "grid">("list"); + const [activeTab, setActiveTab] = useState("all"); + const [page, setPage] = useState(1); + const [filterValues, setFilterValues] = useState>({}); + + const filtersQuery = trpc.veille.filters.useQuery(); + + const queryInput = useMemo(() => ({ + typeVeille: activeTab !== "all" ? activeTab : undefined, + categorie: filterValues.categorie || undefined, + niveau: filterValues.niveau || undefined, + territoire: filterValues.territoire || undefined, + search: filterValues.search || undefined, + dateFrom: filterValues.dateFrom ? new Date(filterValues.dateFrom) : undefined, + dateTo: filterValues.dateTo ? new Date(filterValues.dateTo) : undefined, + page, + pageSize: PAGE_SIZE, + }), [activeTab, filterValues, page]); + + const itemsQuery = trpc.veille.list.useQuery(queryInput); + + const handleFilterChange = (key: string, value: string) => { + setFilterValues((prev) => ({ ...prev, [key]: value })); + setPage(1); + }; + + const handleReset = () => { + setFilterValues({}); + setPage(1); + }; + + const items = (itemsQuery.data?.items ?? []) as VeilleItem[]; + const total = itemsQuery.data?.total ?? 0; + const totalPages = Math.ceil(total / PAGE_SIZE); + + const filterOptions = [ + { key: "categorie", label: "Catégorie", options: filtersQuery.data?.categories ?? [] }, + { key: "niveau", label: "Niveau", options: filtersQuery.data?.niveaux ?? [] }, + { key: "territoire", label: "Territoire", options: filtersQuery.data?.territoires ?? [] }, + { key: "dateFrom", label: "Date depuis", type: "date" as const }, + { key: "dateTo", label: "Date jusqu'à", type: "date" as const }, + ]; + + return ( +
+ {/* En-tête */} +
+
+
+ +

Veille Stratégique

+
+

+ Suivi réglementaire, concurrentiel, technologique et général +

+
+
+ + +
+
+ + {/* Onglets */} + { setActiveTab(v as TypeVeille | "all"); setPage(1); }}> + + Tous + {(Object.keys(TYPE_LABELS) as TypeVeille[]).map((t) => ( + {TYPE_LABELS[t]} + ))} + + + + {/* Filtres */} + + + {/* Contenu */} + {itemsQuery.isLoading ? ( +
+ +
+ ) : items.length === 0 ? ( +
+ +

Aucun résultat trouvé

+

Modifiez vos filtres ou importez des données

+
+ ) : viewMode === "list" ? ( + + ) : ( + + )} + + {/* Pagination */} + {totalPages > 1 && ( +
+ + Page {page} / {totalPages} + +
+ )} +
+ ); +} + +// ─── Vue Liste ──────────────────────────────────────────────────────────────── + +function VeilleListView({ items }: { items: VeilleItem[] }) { + return ( +
+
+ + + + + + + + + + + + + + + {items.map((item, idx) => ( + + + + + + + + + + + ))} + +
#TitreTypeCatégorieNiveauTerritoireDateLien
{idx + 1} +
+

{item.titre}

+ {item.resume &&

{item.resume}

} +
+
+ + {TYPE_LABELS[item.typeVeille as TypeVeille] || item.typeVeille} + + {item.categorie || "—"}{item.niveau || "—"}{item.territoire || "—"}{formatDate(item.datePublication) || "—"} + {item.lien && ( + + + + )} +
+
+
+ ); +} + +// ─── Vue Vignettes ──────────────────────────────────────────────────────────── + +function VeilleGridView({ items }: { items: VeilleItem[] }) { + return ( +
+ {items.map((item) => ( + + +
+ + {TYPE_LABELS[item.typeVeille as TypeVeille] || item.typeVeille} + + {item.lien && ( + + + + )} +
+

{item.titre}

+
+ + {item.resume &&

{item.resume}

} +
+ {item.categorie && {item.categorie}} + {item.territoire && {item.territoire}} + {item.niveau && {item.niveau}} +
+ {item.datePublication && ( +
+ + {formatDate(item.datePublication)} +
+ )} +
+
+ ))} +
+ ); +} diff --git a/drizzle/0000_quiet_yellow_claw.sql b/drizzle/0000_quiet_yellow_claw.sql new file mode 100644 index 0000000..d0cd6eb --- /dev/null +++ b/drizzle/0000_quiet_yellow_claw.sql @@ -0,0 +1,13 @@ +CREATE TABLE `users` ( + `id` int AUTO_INCREMENT NOT NULL, + `openId` varchar(64) NOT NULL, + `name` text, + `email` varchar(320), + `loginMethod` varchar(64), + `role` enum('user','admin') NOT NULL DEFAULT 'user', + `createdAt` timestamp NOT NULL DEFAULT (now()), + `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + `lastSignedIn` timestamp NOT NULL DEFAULT (now()), + CONSTRAINT `users_id` PRIMARY KEY(`id`), + CONSTRAINT `users_openId_unique` UNIQUE(`openId`) +); diff --git a/drizzle/0001_old_shocker.sql b/drizzle/0001_old_shocker.sql new file mode 100644 index 0000000..b2b8e22 --- /dev/null +++ b/drizzle/0001_old_shocker.sql @@ -0,0 +1,70 @@ +CREATE TABLE `aap_items` ( + `id` int AUTO_INCREMENT NOT NULL, + `dedupKey` varchar(64) NOT NULL, + `titre` text NOT NULL, + `categorie` enum('Handicap','PA','Enfance','Précarité','Sanitaire','Autre') NOT NULL, + `region` varchar(255), + `departement` varchar(255), + `dateCloture` timestamp, + `datePublication` timestamp, + `lien` text, + `importedAt` timestamp NOT NULL DEFAULT (now()), + CONSTRAINT `aap_items_id` PRIMARY KEY(`id`), + CONSTRAINT `aap_items_dedupKey_unique` UNIQUE(`dedupKey`) +); +--> statement-breakpoint +CREATE TABLE `app_settings` ( + `id` int AUTO_INCREMENT NOT NULL, + `key` varchar(128) NOT NULL, + `value` text, + `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `app_settings_id` PRIMARY KEY(`id`), + CONSTRAINT `app_settings_key_unique` UNIQUE(`key`) +); +--> statement-breakpoint +CREATE TABLE `import_logs` ( + `id` int AUTO_INCREMENT NOT NULL, + `fileType` enum('veille','aap') NOT NULL, + `source` varchar(512), + `status` enum('success','partial','error') NOT NULL, + `totalRows` int DEFAULT 0, + `newRows` int DEFAULT 0, + `skippedRows` int DEFAULT 0, + `errorMessage` text, + `details` json, + `startedAt` timestamp NOT NULL DEFAULT (now()), + `completedAt` timestamp, + CONSTRAINT `import_logs_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `local_users` ( + `id` int AUTO_INCREMENT NOT NULL, + `name` varchar(255) NOT NULL, + `email` varchar(320) NOT NULL, + `passwordHash` varchar(255) NOT NULL, + `role` enum('admin','user','readonly') NOT NULL DEFAULT 'user', + `isActive` boolean NOT NULL DEFAULT true, + `createdAt` timestamp NOT NULL DEFAULT (now()), + `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + `lastSignedIn` timestamp, + CONSTRAINT `local_users_id` PRIMARY KEY(`id`), + CONSTRAINT `local_users_email_unique` UNIQUE(`email`) +); +--> statement-breakpoint +CREATE TABLE `veille_items` ( + `id` int AUTO_INCREMENT NOT NULL, + `dedupKey` varchar(64) NOT NULL, + `titre` text NOT NULL, + `categorie` varchar(128), + `niveau` varchar(128), + `territoire` varchar(255), + `resume` text, + `source` varchar(512), + `passage` text, + `lien` text, + `typeVeille` enum('reglementaire','concurrentielle','technologique','generale') NOT NULL, + `datePublication` timestamp, + `importedAt` timestamp NOT NULL DEFAULT (now()), + CONSTRAINT `veille_items_id` PRIMARY KEY(`id`), + CONSTRAINT `veille_items_dedupKey_unique` UNIQUE(`dedupKey`) +); diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..c9eda4c --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,110 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "82e6146d-1ea1-4bf8-b64c-f2a2a8ef266e", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "openId": { + "name": "openId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "loginMethod": { + "name": "loginMethod", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('user','admin')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastSignedIn": { + "name": "lastSignedIn", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_openId_unique": { + "name": "users_openId_unique", + "columns": [ + "openId" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..34bc287 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,565 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "8f5ba9eb-1f6d-4c0f-8eeb-262ea8031bee", + "prevId": "82e6146d-1ea1-4bf8-b64c-f2a2a8ef266e", + "tables": { + "aap_items": { + "name": "aap_items", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "dedupKey": { + "name": "dedupKey", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "titre": { + "name": "titre", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "categorie": { + "name": "categorie", + "type": "enum('Handicap','PA','Enfance','Précarité','Sanitaire','Autre')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "departement": { + "name": "departement", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dateCloture": { + "name": "dateCloture", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "datePublication": { + "name": "datePublication", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lien": { + "name": "lien", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "importedAt": { + "name": "importedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "aap_items_id": { + "name": "aap_items_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "aap_items_dedupKey_unique": { + "name": "aap_items_dedupKey_unique", + "columns": [ + "dedupKey" + ] + } + }, + "checkConstraint": {} + }, + "app_settings": { + "name": "app_settings", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "key": { + "name": "key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "app_settings_id": { + "name": "app_settings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "app_settings_key_unique": { + "name": "app_settings_key_unique", + "columns": [ + "key" + ] + } + }, + "checkConstraint": {} + }, + "import_logs": { + "name": "import_logs", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "fileType": { + "name": "fileType", + "type": "enum('veille','aap')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('success','partial','error')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "totalRows": { + "name": "totalRows", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "newRows": { + "name": "newRows", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "skippedRows": { + "name": "skippedRows", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "startedAt": { + "name": "startedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "import_logs_id": { + "name": "import_logs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "local_users": { + "name": "local_users", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "passwordHash": { + "name": "passwordHash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('admin','user','readonly')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastSignedIn": { + "name": "lastSignedIn", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "local_users_id": { + "name": "local_users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "local_users_email_unique": { + "name": "local_users_email_unique", + "columns": [ + "email" + ] + } + }, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "openId": { + "name": "openId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "loginMethod": { + "name": "loginMethod", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('user','admin')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastSignedIn": { + "name": "lastSignedIn", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_openId_unique": { + "name": "users_openId_unique", + "columns": [ + "openId" + ] + } + }, + "checkConstraint": {} + }, + "veille_items": { + "name": "veille_items", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "dedupKey": { + "name": "dedupKey", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "titre": { + "name": "titre", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "categorie": { + "name": "categorie", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "niveau": { + "name": "niveau", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "territoire": { + "name": "territoire", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resume": { + "name": "resume", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "passage": { + "name": "passage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lien": { + "name": "lien", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "typeVeille": { + "name": "typeVeille", + "type": "enum('reglementaire','concurrentielle','technologique','generale')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "datePublication": { + "name": "datePublication", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "importedAt": { + "name": "importedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "veille_items_id": { + "name": "veille_items_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "veille_items_dedupKey_unique": { + "name": "veille_items_dedupKey_unique", + "columns": [ + "dedupKey" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 22fb7e8..cfcbba5 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -1,5 +1,20 @@ { "version": "7", "dialect": "mysql", - "entries": [] -} + "entries": [ + { + "idx": 0, + "version": "5", + "when": 1773670845236, + "tag": "0000_quiet_yellow_claw", + "breakpoints": true + }, + { + "idx": 1, + "version": "5", + "when": 1773671031809, + "tag": "0001_old_shocker", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 96f47f2..5ec111e 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -1,17 +1,18 @@ -import { int, mysqlEnum, mysqlTable, text, timestamp, varchar } from "drizzle-orm/mysql-core"; +import { + boolean, + int, + mysqlEnum, + mysqlTable, + text, + timestamp, + varchar, + json, +} from "drizzle-orm/mysql-core"; + +// ─── Utilisateurs (Manus OAuth + locaux) ──────────────────────────────────── -/** - * Core user table backing auth flow. - * Extend this file with additional tables as your product grows. - * Columns use camelCase to match both database fields and generated types. - */ export const users = mysqlTable("users", { - /** - * Surrogate primary key. Auto-incremented numeric value managed by the database. - * Use this for relations between tables. - */ id: int("id").autoincrement().primaryKey(), - /** Manus OAuth identifier (openId) returned from the OAuth callback. Unique per user. */ openId: varchar("openId", { length: 64 }).notNull().unique(), name: text("name"), email: varchar("email", { length: 320 }), @@ -25,4 +26,92 @@ export const users = mysqlTable("users", { export type User = typeof users.$inferSelect; export type InsertUser = typeof users.$inferInsert; -// TODO: Add your tables here \ No newline at end of file +// ─── Utilisateurs locaux (auth interne) ───────────────────────────────────── + +export const localUsers = mysqlTable("local_users", { + id: int("id").autoincrement().primaryKey(), + name: varchar("name", { length: 255 }).notNull(), + email: varchar("email", { length: 320 }).notNull().unique(), + passwordHash: varchar("passwordHash", { length: 255 }).notNull(), + role: mysqlEnum("role", ["admin", "user", "readonly"]).default("user").notNull(), + isActive: boolean("isActive").default(true).notNull(), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), + lastSignedIn: timestamp("lastSignedIn"), +}); + +export type LocalUser = typeof localUsers.$inferSelect; +export type InsertLocalUser = typeof localUsers.$inferInsert; + +// ─── Paramètres de l'application ──────────────────────────────────────────── + +export const appSettings = mysqlTable("app_settings", { + id: int("id").autoincrement().primaryKey(), + key: varchar("key", { length: 128 }).notNull().unique(), + value: text("value"), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), +}); + +export type AppSetting = typeof appSettings.$inferSelect; +export type InsertAppSetting = typeof appSettings.$inferInsert; + +// ─── Entrées de veille stratégique ────────────────────────────────────────── + +export const veilleItems = mysqlTable("veille_items", { + id: int("id").autoincrement().primaryKey(), + // Clé de déduplication : hash du titre + lien + dedupKey: varchar("dedupKey", { length: 64 }).notNull().unique(), + titre: text("titre").notNull(), + categorie: varchar("categorie", { length: 128 }), + niveau: varchar("niveau", { length: 128 }), + territoire: varchar("territoire", { length: 255 }), + resume: text("resume"), + source: varchar("source", { length: 512 }), + passage: text("passage"), + lien: text("lien"), + // Type de veille (feuille d'origine) + typeVeille: mysqlEnum("typeVeille", ["reglementaire", "concurrentielle", "technologique", "generale"]).notNull(), + // Date extraite de la colonne Source (qui contient parfois une date ISO) + datePublication: timestamp("datePublication"), + importedAt: timestamp("importedAt").defaultNow().notNull(), +}); + +export type VeilleItem = typeof veilleItems.$inferSelect; +export type InsertVeilleItem = typeof veilleItems.$inferInsert; + +// ─── Entrées des appels à projets ──────────────────────────────────────────── + +export const aapItems = mysqlTable("aap_items", { + id: int("id").autoincrement().primaryKey(), + dedupKey: varchar("dedupKey", { length: 64 }).notNull().unique(), + titre: text("titre").notNull(), + categorie: mysqlEnum("categorie", ["Handicap", "PA", "Enfance", "Précarité", "Sanitaire", "Autre"]).notNull(), + region: varchar("region", { length: 255 }), + departement: varchar("departement", { length: 255 }), + dateCloture: timestamp("dateCloture"), + datePublication: timestamp("datePublication"), + lien: text("lien"), + importedAt: timestamp("importedAt").defaultNow().notNull(), +}); + +export type AapItem = typeof aapItems.$inferSelect; +export type InsertAapItem = typeof aapItems.$inferInsert; + +// ─── Logs d'import ─────────────────────────────────────────────────────────── + +export const importLogs = mysqlTable("import_logs", { + id: int("id").autoincrement().primaryKey(), + fileType: mysqlEnum("fileType", ["veille", "aap"]).notNull(), + source: varchar("source", { length: 512 }), + status: mysqlEnum("status", ["success", "partial", "error"]).notNull(), + totalRows: int("totalRows").default(0), + newRows: int("newRows").default(0), + skippedRows: int("skippedRows").default(0), + errorMessage: text("errorMessage"), + details: json("details"), + startedAt: timestamp("startedAt").defaultNow().notNull(), + completedAt: timestamp("completedAt"), +}); + +export type ImportLog = typeof importLogs.$inferSelect; +export type InsertImportLog = typeof importLogs.$inferInsert; diff --git a/package.json b/package.json index 21d786b..3b4320d 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,11 @@ "@trpc/client": "^11.6.0", "@trpc/react-query": "^11.6.0", "@trpc/server": "^11.6.0", + "@types/bcryptjs": "^3.0.0", + "@types/node-cron": "^3.0.11", "axios": "^1.12.0", + "basic-ftp": "^5.2.0", + "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -63,6 +67,7 @@ "mysql2": "^3.15.0", "nanoid": "^5.1.5", "next-themes": "^0.4.6", + "node-cron": "^4.2.1", "react": "^19.2.1", "react-day-picker": "^9.11.1", "react-dom": "^19.2.1", @@ -76,6 +81,7 @@ "tailwindcss-animate": "^1.0.7", "vaul": "^1.1.2", "wouter": "^3.3.5", + "xlsx": "^0.18.5", "zod": "^4.1.12" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25a9528..08d8b53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,9 +115,21 @@ importers: '@trpc/server': specifier: ^11.6.0 version: 11.6.0(typescript@5.9.3) + '@types/bcryptjs': + specifier: ^3.0.0 + version: 3.0.0 + '@types/node-cron': + specifier: ^3.0.11 + version: 3.0.11 axios: specifier: ^1.12.0 version: 1.12.2 + basic-ftp: + specifier: ^5.2.0 + version: 5.2.0 + bcryptjs: + specifier: ^3.0.3 + version: 3.0.3 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -166,6 +178,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + node-cron: + specifier: ^4.2.1 + version: 4.2.1 react: specifier: ^19.2.1 version: 19.2.1 @@ -205,6 +220,9 @@ importers: wouter: specifier: ^3.3.5 version: 3.7.1(patch_hash=4e16e6ff3fde7d6c1024d3e0c8605dc9eb6afb690d0d49958c2f449091813072)(react@19.2.1) + xlsx: + specifier: ^0.18.5 + version: 0.18.5 zod: specifier: ^4.1.12 version: 4.1.12 @@ -2156,6 +2174,10 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/bcryptjs@3.0.0': + resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==} + deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed. + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -2294,6 +2316,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node-cron@3.0.11': + resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==} + '@types/node@24.7.0': resolution: {integrity: sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==} @@ -2379,6 +2404,10 @@ packages: add@2.0.6: resolution: {integrity: sha512-j5QzrmsokwWWp6kUcJQySpbG+xfOBqqKnup3OIk1pz+kB/80SLorZ9V8zHFLO92Lcd+hbvq8bT+zOGoPkmBV0Q==} + adler-32@1.3.1: + resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} + engines: {node: '>=0.8'} + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -2414,6 +2443,14 @@ packages: resolution: {integrity: sha512-vAPMQdnyKCBtkmQA6FMCBvU9qFIppS3nzyXnEM+Lo2IAhG4Mpjv9cCxMudhgV3YdNNJv6TNqXy97dfRVL2LmaQ==} hasBin: true + basic-ftp@5.2.0: + resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==} + engines: {node: '>=10.0.0'} + + bcryptjs@3.0.3: + resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} + hasBin: true + body-parser@1.20.3: resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -2451,6 +2488,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + cfb@1.2.2: + resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} + engines: {node: '>=0.8'} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -2496,6 +2537,10 @@ packages: react: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc + codepage@1.15.0: + resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} + engines: {node: '>=0.8'} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -2549,6 +2594,11 @@ packages: cose-base@2.2.0: resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -3045,6 +3095,10 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + frac@1.1.2: + resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} + engines: {node: '>=0.8'} + fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} @@ -3611,6 +3665,10 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + node-cron@4.2.1: + resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==} + engines: {node: '>=6.0.0'} + node-releases@2.0.23: resolution: {integrity: sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==} @@ -3958,6 +4016,10 @@ packages: resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} engines: {node: '>= 0.6'} + ssf@0.11.2: + resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} + engines: {node: '>=0.8'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -4303,11 +4365,24 @@ packages: engines: {node: '>=8'} hasBin: true + wmf@1.0.2: + resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} + engines: {node: '>=0.8'} + + word@0.3.0: + resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} + engines: {node: '>=0.8'} + wouter@3.7.1: resolution: {integrity: sha512-od5LGmndSUzntZkE2R5CHhoiJ7YMuTIbiXsa0Anytc2RATekgv4sfWRAxLEULBrp7ADzinWQw8g470lkT8+fOw==} peerDependencies: react: '>=16.8.0' + xlsx@0.18.5: + resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} + engines: {node: '>=0.8'} + hasBin: true + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -6468,6 +6543,10 @@ snapshots: dependencies: '@babel/types': 7.28.4 + '@types/bcryptjs@3.0.0': + dependencies: + bcryptjs: 3.0.3 + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -6638,6 +6717,8 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node-cron@3.0.11': {} + '@types/node@24.7.0': dependencies: undici-types: 7.14.0 @@ -6739,6 +6820,8 @@ snapshots: add@2.0.6: {} + adler-32@1.3.1: {} + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 @@ -6773,6 +6856,10 @@ snapshots: baseline-browser-mapping@2.8.12: {} + basic-ftp@5.2.0: {} + + bcryptjs@3.0.3: {} + body-parser@1.20.3: dependencies: bytes: 3.1.2 @@ -6820,6 +6907,11 @@ snapshots: ccount@2.0.1: {} + cfb@1.2.2: + dependencies: + adler-32: 1.3.1 + crc-32: 1.2.2 + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -6872,6 +6964,8 @@ snapshots: - '@types/react' - '@types/react-dom' + codepage@1.15.0: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -6912,6 +7006,8 @@ snapshots: dependencies: layout-base: 2.0.1 + crc-32@1.2.2: {} + cssesc@3.0.0: {} csstype@3.1.3: {} @@ -7399,6 +7495,8 @@ snapshots: forwarded@0.2.0: {} + frac@1.1.2: {} + fraction.js@4.3.7: {} framer-motion@12.23.22(react-dom@19.2.1(react@19.2.1))(react@19.2.1): @@ -8221,6 +8319,8 @@ snapshots: react: 19.2.1 react-dom: 19.2.1(react@19.2.1) + node-cron@4.2.1: {} + node-releases@2.0.23: {} normalize-range@0.1.2: {} @@ -8661,6 +8761,10 @@ snapshots: sqlstring@2.3.3: {} + ssf@0.11.2: + dependencies: + frac: 1.1.2 + stackback@0.0.2: {} statuses@2.0.1: {} @@ -9006,6 +9110,10 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wmf@1.0.2: {} + + word@0.3.0: {} + wouter@3.7.1(patch_hash=4e16e6ff3fde7d6c1024d3e0c8605dc9eb6afb690d0d49958c2f449091813072)(react@19.2.1): dependencies: mitt: 3.0.1 @@ -9013,6 +9121,16 @@ snapshots: regexparam: 3.0.0 use-sync-external-store: 1.6.0(react@19.2.1) + xlsx@0.18.5: + dependencies: + adler-32: 1.3.1 + cfb: 1.2.2 + codepage: 1.15.0 + crc-32: 1.2.2 + ssf: 0.11.2 + wmf: 1.0.2 + word: 0.3.0 + yallist@3.1.1: {} yallist@5.0.0: {} diff --git a/server/_core/index.ts b/server/_core/index.ts index f472331..756e58f 100644 --- a/server/_core/index.ts +++ b/server/_core/index.ts @@ -3,47 +3,72 @@ import express from "express"; import { createServer } from "http"; import net from "net"; import { createExpressMiddleware } from "@trpc/server/adapters/express"; +import * as cron from "node-cron"; import { registerOAuthRoutes } from "./oauth"; import { appRouter } from "../routers"; import { createContext } from "./context"; import { serveStatic, setupVite } from "./vite"; +import { runFullImport } from "../importer"; +import { ensureAdminExists } from "../localAuth"; +import { getSetting } from "../db"; function isPortAvailable(port: number): Promise { return new Promise(resolve => { const server = net.createServer(); - server.listen(port, () => { - server.close(() => resolve(true)); - }); + server.listen(port, () => { server.close(() => resolve(true)); }); server.on("error", () => resolve(false)); }); } async function findAvailablePort(startPort: number = 3000): Promise { for (let port = startPort; port < startPort + 20; port++) { - if (await isPortAvailable(port)) { - return port; - } + if (await isPortAvailable(port)) return port; } throw new Error(`No available port found starting from ${startPort}`); } +// ─── Tâche d'import quotidien ───────────────────────────────────────────────── + +let cronJob: ReturnType | null = null; + +async function scheduleDailyImport() { + // Heure configurable, défaut 06:00 + const importTime = (await getSetting("import_time")) || "06:00"; + const [hour, minute] = importTime.split(":").map(Number); + const cronExpr = `0 ${minute ?? 0} ${hour ?? 6} * * *`; + + if (cronJob) { + cronJob.stop(); + cronJob = null; + } + + cronJob = cron.schedule(cronExpr, async () => { + console.log(`[Cron] Import automatique démarré à ${new Date().toISOString()}`); + try { + const result = await runFullImport(); + console.log(`[Cron] Import terminé — Veille: +${result.veille.newRows} | AAP: +${result.aap.newRows}`); + } catch (e) { + console.error("[Cron] Erreur lors de l'import:", e); + } + }); + + console.log(`[Cron] Import quotidien planifié à ${importTime} (${cronExpr})`); +} + async function startServer() { const app = express(); const server = createServer(app); - // Configure body parser with larger size limit for file uploads + app.use(express.json({ limit: "50mb" })); app.use(express.urlencoded({ limit: "50mb", extended: true })); - // OAuth callback under /api/oauth/callback + registerOAuthRoutes(app); - // tRPC API + app.use( "/api/trpc", - createExpressMiddleware({ - router: appRouter, - createContext, - }) + createExpressMiddleware({ router: appRouter, createContext }) ); - // development mode uses Vite, production mode uses static files + if (process.env.NODE_ENV === "development") { await setupVite(app, server); } else { @@ -57,8 +82,16 @@ async function startServer() { console.log(`Port ${preferredPort} is busy, using port ${port} instead`); } - server.listen(port, () => { + server.listen(port, async () => { console.log(`Server running on http://localhost:${port}/`); + + // Initialisation post-démarrage + try { + await ensureAdminExists(); + await scheduleDailyImport(); + } catch (e) { + console.error("[Init] Erreur d'initialisation:", e); + } }); } diff --git a/server/db.ts b/server/db.ts index 795c205..bce1572 100644 --- a/server/db.ts +++ b/server/db.ts @@ -1,11 +1,19 @@ -import { eq } from "drizzle-orm"; +import { eq, desc, and, like, gte, lte, or, sql } from "drizzle-orm"; import { drizzle } from "drizzle-orm/mysql2"; -import { InsertUser, users } from "../drizzle/schema"; -import { ENV } from './_core/env'; +import { + InsertUser, + users, + localUsers, + veilleItems, + aapItems, + appSettings, + importLogs, + InsertLocalUser, +} from "../drizzle/schema"; +import { ENV } from "./_core/env"; let _db: ReturnType | null = null; -// Lazily create the drizzle instance so local tooling can run without a DB. export async function getDb() { if (!_db && process.env.DATABASE_URL) { try { @@ -18,75 +26,292 @@ export async function getDb() { return _db; } +// ─── Users (Manus OAuth) ───────────────────────────────────────────────────── + export async function upsertUser(user: InsertUser): Promise { - if (!user.openId) { - throw new Error("User openId is required for upsert"); - } - + if (!user.openId) throw new Error("User openId is required for upsert"); const db = await getDb(); - if (!db) { - console.warn("[Database] Cannot upsert user: database not available"); - return; + if (!db) { console.warn("[Database] Cannot upsert user: database not available"); return; } + + const values: InsertUser = { openId: user.openId }; + const updateSet: Record = {}; + const textFields = ["name", "email", "loginMethod"] as const; + + for (const field of textFields) { + const value = user[field]; + if (value === undefined) continue; + const normalized = value ?? null; + values[field] = normalized; + updateSet[field] = normalized; } - try { - const values: InsertUser = { - openId: user.openId, - }; - const updateSet: Record = {}; + if (user.lastSignedIn !== undefined) { values.lastSignedIn = user.lastSignedIn; updateSet.lastSignedIn = user.lastSignedIn; } + if (user.role !== undefined) { values.role = user.role; updateSet.role = user.role; } + else if (user.openId === ENV.ownerOpenId) { values.role = "admin"; updateSet.role = "admin"; } + if (!values.lastSignedIn) values.lastSignedIn = new Date(); + if (Object.keys(updateSet).length === 0) updateSet.lastSignedIn = new Date(); - const textFields = ["name", "email", "loginMethod"] as const; - type TextField = (typeof textFields)[number]; - - const assignNullable = (field: TextField) => { - const value = user[field]; - if (value === undefined) return; - const normalized = value ?? null; - values[field] = normalized; - updateSet[field] = normalized; - }; - - textFields.forEach(assignNullable); - - if (user.lastSignedIn !== undefined) { - values.lastSignedIn = user.lastSignedIn; - updateSet.lastSignedIn = user.lastSignedIn; - } - if (user.role !== undefined) { - values.role = user.role; - updateSet.role = user.role; - } else if (user.openId === ENV.ownerOpenId) { - values.role = 'admin'; - updateSet.role = 'admin'; - } - - if (!values.lastSignedIn) { - values.lastSignedIn = new Date(); - } - - if (Object.keys(updateSet).length === 0) { - updateSet.lastSignedIn = new Date(); - } - - await db.insert(users).values(values).onDuplicateKeyUpdate({ - set: updateSet, - }); - } catch (error) { - console.error("[Database] Failed to upsert user:", error); - throw error; - } + await db.insert(users).values(values).onDuplicateKeyUpdate({ set: updateSet }); } export async function getUserByOpenId(openId: string) { const db = await getDb(); - if (!db) { - console.warn("[Database] Cannot get user: database not available"); - return undefined; - } - + if (!db) return undefined; const result = await db.select().from(users).where(eq(users.openId, openId)).limit(1); - - return result.length > 0 ? result[0] : undefined; + return result[0]; } -// TODO: add feature queries here as your schema grows. +// ─── Local Users ───────────────────────────────────────────────────────────── + +export async function getLocalUsers() { + const db = await getDb(); + if (!db) return []; + return db + .select({ + id: localUsers.id, + name: localUsers.name, + email: localUsers.email, + role: localUsers.role, + isActive: localUsers.isActive, + createdAt: localUsers.createdAt, + lastSignedIn: localUsers.lastSignedIn, + }) + .from(localUsers) + .orderBy(desc(localUsers.createdAt)); +} + +export async function createLocalUser(data: Omit) { + const db = await getDb(); + if (!db) throw new Error("DB unavailable"); + await db.insert(localUsers).values(data); +} + +export async function updateLocalUser(id: number, data: Partial) { + const db = await getDb(); + if (!db) throw new Error("DB unavailable"); + await db.update(localUsers).set(data).where(eq(localUsers.id, id)); +} + +export async function deleteLocalUser(id: number) { + const db = await getDb(); + if (!db) throw new Error("DB unavailable"); + await db.delete(localUsers).where(eq(localUsers.id, id)); +} + +// ─── Veille Items ───────────────────────────────────────────────────────────── + +export interface VeilleFilters { + typeVeille?: string; + categorie?: string; + niveau?: string; + territoire?: string; + search?: string; + dateFrom?: Date; + dateTo?: Date; + page?: number; + pageSize?: number; +} + +export async function getVeilleItems(filters: VeilleFilters = {}) { + const db = await getDb(); + if (!db) return { items: [], total: 0 }; + + const { page = 1, pageSize = 50, ...f } = filters; + const offset = (page - 1) * pageSize; + + const conditions = []; + if (f.typeVeille) conditions.push(eq(veilleItems.typeVeille, f.typeVeille as "reglementaire" | "concurrentielle" | "technologique" | "generale")); + if (f.categorie) conditions.push(like(veilleItems.categorie, `%${f.categorie}%`)); + if (f.niveau) conditions.push(like(veilleItems.niveau, `%${f.niveau}%`)); + if (f.territoire) conditions.push(like(veilleItems.territoire, `%${f.territoire}%`)); + if (f.search) { + conditions.push( + or( + like(veilleItems.titre, `%${f.search}%`), + like(veilleItems.resume, `%${f.search}%`) + ) + ); + } + if (f.dateFrom) conditions.push(gte(veilleItems.datePublication, f.dateFrom)); + if (f.dateTo) conditions.push(lte(veilleItems.datePublication, f.dateTo)); + + const where = conditions.length > 0 ? and(...conditions) : undefined; + + const [items, countResult] = await Promise.all([ + db + .select() + .from(veilleItems) + .where(where) + .orderBy(desc(veilleItems.datePublication), desc(veilleItems.importedAt)) + .limit(pageSize) + .offset(offset), + db + .select({ count: sql`count(*)` }) + .from(veilleItems) + .where(where), + ]); + + return { items, total: Number(countResult[0]?.count ?? 0) }; +} + +export async function getVeilleDistinctValues() { + const db = await getDb(); + if (!db) return { categories: [], niveaux: [], territoires: [] }; + + const [cats, niveaux, territoires] = await Promise.all([ + db.selectDistinct({ value: veilleItems.categorie }).from(veilleItems).where(sql`${veilleItems.categorie} IS NOT NULL`), + db.selectDistinct({ value: veilleItems.niveau }).from(veilleItems).where(sql`${veilleItems.niveau} IS NOT NULL`), + db.selectDistinct({ value: veilleItems.territoire }).from(veilleItems).where(sql`${veilleItems.territoire} IS NOT NULL`), + ]); + + return { + categories: cats.map((r) => r.value!).filter(Boolean).sort(), + niveaux: niveaux.map((r) => r.value!).filter(Boolean).sort(), + territoires: territoires.map((r) => r.value!).filter(Boolean).sort(), + }; +} + +// ─── AAP Items ──────────────────────────────────────────────────────────────── + +export interface AapFilters { + categorie?: string; + region?: string; + departement?: string; + search?: string; + dateFrom?: Date; + dateTo?: Date; + clotureFrom?: Date; + clotureTo?: Date; + page?: number; + pageSize?: number; +} + +export async function getAapItems(filters: AapFilters = {}) { + const db = await getDb(); + if (!db) return { items: [], total: 0 }; + + const { page = 1, pageSize = 50, ...f } = filters; + const offset = (page - 1) * pageSize; + + const conditions = []; + if (f.categorie) conditions.push(eq(aapItems.categorie, f.categorie as "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre")); + if (f.region) conditions.push(like(aapItems.region, `%${f.region}%`)); + if (f.departement) conditions.push(like(aapItems.departement, `%${f.departement}%`)); + if (f.search) conditions.push(like(aapItems.titre, `%${f.search}%`)); + if (f.dateFrom) conditions.push(gte(aapItems.datePublication, f.dateFrom)); + if (f.dateTo) conditions.push(lte(aapItems.datePublication, f.dateTo)); + if (f.clotureFrom) conditions.push(gte(aapItems.dateCloture, f.clotureFrom)); + if (f.clotureTo) conditions.push(lte(aapItems.dateCloture, f.clotureTo)); + + const where = conditions.length > 0 ? and(...conditions) : undefined; + + const [items, countResult] = await Promise.all([ + db + .select() + .from(aapItems) + .where(where) + .orderBy(desc(aapItems.datePublication), desc(aapItems.importedAt)) + .limit(pageSize) + .offset(offset), + db + .select({ count: sql`count(*)` }) + .from(aapItems) + .where(where), + ]); + + return { items, total: Number(countResult[0]?.count ?? 0) }; +} + +export async function getAapDistinctValues() { + const db = await getDb(); + if (!db) return { regions: [], departements: [] }; + + const [regions, departements] = await Promise.all([ + db.selectDistinct({ value: aapItems.region }).from(aapItems).where(sql`${aapItems.region} IS NOT NULL`), + db.selectDistinct({ value: aapItems.departement }).from(aapItems).where(sql`${aapItems.departement} IS NOT NULL`), + ]); + + return { + regions: regions.map((r) => r.value!).filter(Boolean).sort(), + departements: departements.map((r) => r.value!).filter(Boolean).sort(), + }; +} + +// ─── App Settings ───────────────────────────────────────────────────────────── + +export async function getSetting(key: string): Promise { + const db = await getDb(); + if (!db) return null; + const rows = await db.select().from(appSettings).where(eq(appSettings.key, key)).limit(1); + return rows[0]?.value ?? null; +} + +export async function getAllSettings(): Promise> { + const db = await getDb(); + if (!db) return {}; + const rows = await db.select().from(appSettings); + const map: Record = {}; + for (const r of rows) { + if (r.key && r.value !== null && r.value !== undefined) map[r.key] = r.value; + } + return map; +} + +export async function setSetting(key: string, value: string): Promise { + const db = await getDb(); + if (!db) throw new Error("DB unavailable"); + await db + .insert(appSettings) + .values({ key, value }) + .onDuplicateKeyUpdate({ set: { value } }); +} + +export async function setSettings(settings: Record): Promise { + const db = await getDb(); + if (!db) throw new Error("DB unavailable"); + for (const [key, value] of Object.entries(settings)) { + await db + .insert(appSettings) + .values({ key, value }) + .onDuplicateKeyUpdate({ set: { value } }); + } +} + +// ─── Import Logs ────────────────────────────────────────────────────────────── + +export async function getImportLogs(limit = 50) { + const db = await getDb(); + if (!db) return []; + return db + .select() + .from(importLogs) + .orderBy(desc(importLogs.startedAt)) + .limit(limit); +} + +export async function getImportStats() { + const db = await getDb(); + if (!db) return { totalVeille: 0, totalAap: 0, lastImport: null, total: 0, success: 0, errors: 0, totalNewRows: 0 }; + + const [veilleCount, aapCount, lastLog, allLogs] = await Promise.all([ + db.select({ count: sql`count(*)` }).from(veilleItems), + db.select({ count: sql`count(*)` }).from(aapItems), + db.select().from(importLogs).orderBy(desc(importLogs.startedAt)).limit(1), + db.select().from(importLogs), + ]); + + const total = allLogs.length; + const success = allLogs.filter(l => l.status === 'success').length; + const errors = allLogs.filter(l => l.status === 'error').length; + const totalNewRows = allLogs.reduce((sum, l) => sum + (l.newRows ?? 0), 0); + + return { + totalVeille: Number(veilleCount[0]?.count ?? 0), + totalAap: Number(aapCount[0]?.count ?? 0), + lastImport: lastLog[0] ?? null, + total, + success, + errors, + totalNewRows, + }; +} diff --git a/server/importer.ts b/server/importer.ts new file mode 100644 index 0000000..30c085a --- /dev/null +++ b/server/importer.ts @@ -0,0 +1,390 @@ +import * as XLSX from "xlsx"; +import * as crypto from "crypto"; +import * as fs from "fs"; +import * as path from "path"; +import * as ftp from "basic-ftp"; +import * as https from "https"; +import * as http from "http"; +import { getDb } from "./db"; +import { veilleItems, aapItems, importLogs, appSettings } from "../drizzle/schema"; +import { eq, inArray } from "drizzle-orm"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export type SourceType = "local" | "onedrive" | "ftp" | "sharepoint"; + +export interface ImportConfig { + sourceType: SourceType; + veilleFilePath?: string; + aapFilePath?: string; + ftpHost?: string; + ftpPort?: number; + ftpUser?: string; + ftpPassword?: string; + ftpSecure?: boolean; + onedriveToken?: string; + sharepointSiteUrl?: string; + sharepointToken?: string; +} + +export interface ImportResult { + fileType: "veille" | "aap"; + totalRows: number; + newRows: number; + skippedRows: number; + errors: string[]; + status: "success" | "partial" | "error"; +} + +// ─── Utilitaires ───────────────────────────────────────────────────────────── + +function makeDedupKey(titre: string, lien?: string | null): string { + const raw = `${(titre || "").trim().toLowerCase()}|${(lien || "").trim().toLowerCase()}`; + return crypto.createHash("md5").update(raw).digest("hex"); +} + +function parseDate(value: unknown): Date | null { + if (!value) return null; + if (value instanceof Date) return isNaN(value.getTime()) ? null : value; + if (typeof value === "string") { + const cleaned = value.replace("Z", "").trim(); + const d = new Date(cleaned); + return isNaN(d.getTime()) ? null : d; + } + if (typeof value === "number") { + // Excel serial date + const d = XLSX.SSF.parse_date_code(value); + if (d) return new Date(d.y, d.m - 1, d.d); + } + return null; +} + +function normalizeStr(v: unknown): string | null { + if (v === null || v === undefined) return null; + const s = String(v).trim(); + return s === "" || s === "Non renseigné" ? null : s; +} + +// ─── Téléchargement des fichiers selon la source ───────────────────────────── + +async function downloadFile( + filePath: string, + config: ImportConfig +): Promise { + switch (config.sourceType) { + case "local": { + if (!fs.existsSync(filePath)) { + throw new Error(`Fichier introuvable : ${filePath}`); + } + return fs.readFileSync(filePath); + } + + case "ftp": { + const client = new ftp.Client(); + client.ftp.verbose = false; + try { + await client.access({ + host: config.ftpHost!, + port: config.ftpPort || 21, + user: config.ftpUser!, + password: config.ftpPassword!, + secure: config.ftpSecure || false, + }); + const tmpPath = `/tmp/veille_import_${Date.now()}.xlsx`; + await client.downloadTo(tmpPath, filePath); + const buf = fs.readFileSync(tmpPath); + fs.unlinkSync(tmpPath); + return buf; + } finally { + client.close(); + } + } + + case "onedrive": + case "sharepoint": { + const token = + config.sourceType === "onedrive" + ? config.onedriveToken + : config.sharepointToken; + if (!token) throw new Error("Token d'authentification manquant"); + + return new Promise((resolve, reject) => { + const url = new URL(filePath); + const options = { + hostname: url.hostname, + path: url.pathname + url.search, + headers: { Authorization: `Bearer ${token}` }, + }; + const protocol = url.protocol === "https:" ? https : http; + protocol + .get(options, (res) => { + const chunks: Buffer[] = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => resolve(Buffer.concat(chunks))); + res.on("error", reject); + }) + .on("error", reject); + }); + } + + default: + throw new Error(`Source non supportée : ${config.sourceType}`); + } +} + +// ─── Lecture des paramètres depuis la BDD ──────────────────────────────────── + +export async function getImportConfig(): Promise { + const db = await getDb(); + if (!db) return { sourceType: "local" }; + + const rows = await db.select().from(appSettings); + const map: Record = {}; + for (const r of rows) { + if (r.key && r.value) map[r.key] = r.value; + } + + return { + sourceType: (map["source_type"] as SourceType) || "local", + veilleFilePath: map["veille_file_path"] || "", + aapFilePath: map["aap_file_path"] || "", + ftpHost: map["ftp_host"], + ftpPort: map["ftp_port"] ? parseInt(map["ftp_port"]) : 21, + ftpUser: map["ftp_user"], + ftpPassword: map["ftp_password"], + ftpSecure: map["ftp_secure"] === "true", + onedriveToken: map["onedrive_token"], + sharepointSiteUrl: map["sharepoint_site_url"], + sharepointToken: map["sharepoint_token"], + }; +} + +// ─── Import Veille Stratégique ─────────────────────────────────────────────── + +const VEILLE_SHEETS: Record = { + réglementaire: "reglementaire", + reglementaire: "reglementaire", + concurrentielle: "concurrentielle", + technologique: "technologique", + générale: "generale", + generale: "generale", +}; + +export async function importVeille(config: ImportConfig): Promise { + const startedAt = new Date(); + const errors: string[] = []; + let totalRows = 0; + let newRows = 0; + let skippedRows = 0; + + const db = await getDb(); + if (!db) throw new Error("Base de données indisponible"); + + const filePath = config.veilleFilePath; + if (!filePath) throw new Error("Chemin du fichier Veille non configuré"); + + let buffer: Buffer; + try { + buffer = await downloadFile(filePath, config); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + await logImport(db, "veille", filePath, "error", 0, 0, 0, msg, null, startedAt); + return { fileType: "veille", totalRows: 0, newRows: 0, skippedRows: 0, errors: [msg], status: "error" }; + } + + const workbook = XLSX.read(buffer, { type: "buffer", cellDates: true }); + + for (const sheetName of workbook.SheetNames) { + const normalized = sheetName.toLowerCase().trim(); + if (normalized === "poubelle") continue; + const typeVeille = VEILLE_SHEETS[normalized]; + if (!typeVeille) continue; + + const sheet = workbook.Sheets[sheetName]; + const rows = XLSX.utils.sheet_to_json>(sheet, { defval: null }); + + for (const row of rows) { + totalRows++; + const titre = normalizeStr(row["Titre"]); + if (!titre) { skippedRows++; continue; } + + const lien = normalizeStr(row["Lien"]); + const dedupKey = makeDedupKey(titre, lien); + + // Vérifier si déjà présent + const existing = await db + .select({ id: veilleItems.id }) + .from(veilleItems) + .where(eq(veilleItems.dedupKey, dedupKey)) + .limit(1); + + if (existing.length > 0) { skippedRows++; continue; } + + // Extraire la date depuis la colonne Source (qui contient une date ISO) + const sourceRaw = row["Source"]; + const datePublication = parseDate(sourceRaw); + + // La vraie source (URL) semble être dans Lien pour certaines feuilles + const sourceStr = normalizeStr(sourceRaw instanceof Date ? null : sourceRaw); + + try { + await db.insert(veilleItems).values({ + dedupKey, + titre, + categorie: normalizeStr(row["Catégorie"]), + niveau: normalizeStr(row["Niveau"]), + territoire: normalizeStr(row["Territoire"]), + resume: normalizeStr(row[" Résumé"] ?? row["Résumé"] ?? row["Resume"]), + source: sourceStr, + passage: normalizeStr(row["passage"] ?? row["Passage"]), + lien, + typeVeille, + datePublication, + }); + newRows++; + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + errors.push(`[${sheetName}] ${titre?.substring(0, 50)}: ${msg}`); + skippedRows++; + } + } + } + + const status = errors.length === 0 ? "success" : newRows > 0 ? "partial" : "error"; + await logImport(db, "veille", filePath, status, totalRows, newRows, skippedRows, errors.join("\n") || null, { errors }, startedAt); + + return { fileType: "veille", totalRows, newRows, skippedRows, errors, status }; +} + +// ─── Import Appels à Projets ───────────────────────────────────────────────── + +const AAP_SHEETS: Record = { + handicap: "Handicap", + pa: "PA", + enfance: "Enfance", + "précarité": "Précarité", + precarite: "Précarité", + sanitaire: "Sanitaire", + autre: "Autre", +}; + +export async function importAAP(config: ImportConfig): Promise { + const startedAt = new Date(); + const errors: string[] = []; + let totalRows = 0; + let newRows = 0; + let skippedRows = 0; + + const db = await getDb(); + if (!db) throw new Error("Base de données indisponible"); + + const filePath = config.aapFilePath; + if (!filePath) throw new Error("Chemin du fichier AAP non configuré"); + + let buffer: Buffer; + try { + buffer = await downloadFile(filePath, config); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + await logImport(db, "aap", filePath, "error", 0, 0, 0, msg, null, startedAt); + return { fileType: "aap", totalRows: 0, newRows: 0, skippedRows: 0, errors: [msg], status: "error" }; + } + + const workbook = XLSX.read(buffer, { type: "buffer", cellDates: true }); + + for (const sheetName of workbook.SheetNames) { + const normalized = sheetName.toLowerCase().trim().replace(/é/g, "e").replace(/è/g, "e"); + const categorie = AAP_SHEETS[sheetName.toLowerCase().trim()] || AAP_SHEETS[normalized]; + if (!categorie) continue; + + const sheet = workbook.Sheets[sheetName]; + const rows = XLSX.utils.sheet_to_json>(sheet, { defval: null }); + + for (const row of rows) { + totalRows++; + const titre = normalizeStr(row["Titre"]); + if (!titre) { skippedRows++; continue; } + + const lien = normalizeStr(row["Lien"]); + const dedupKey = makeDedupKey(titre, lien); + + const existing = await db + .select({ id: aapItems.id }) + .from(aapItems) + .where(eq(aapItems.dedupKey, dedupKey)) + .limit(1); + + if (existing.length > 0) { skippedRows++; continue; } + + const datePublication = parseDate(row["Date publication"]); + const dateCloture = parseDate(row["Date clôture"]); + + try { + await db.insert(aapItems).values({ + dedupKey, + titre, + categorie, + region: normalizeStr(row["Région"]), + departement: normalizeStr(row["Département"]), + dateCloture, + datePublication, + lien, + }); + newRows++; + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + errors.push(`[${sheetName}] ${titre?.substring(0, 50)}: ${msg}`); + skippedRows++; + } + } + } + + const status = errors.length === 0 ? "success" : newRows > 0 ? "partial" : "error"; + await logImport(db, "aap", filePath, status, totalRows, newRows, skippedRows, errors.join("\n") || null, { errors }, startedAt); + + return { fileType: "aap", totalRows, newRows, skippedRows, errors, status }; +} + +// ─── Import complet (veille + AAP) ─────────────────────────────────────────── + +export async function runFullImport(): Promise<{ veille: ImportResult; aap: ImportResult }> { + const config = await getImportConfig(); + const [veille, aap] = await Promise.all([ + importVeille(config), + importAAP(config), + ]); + return { veille, aap }; +} + +// ─── Enregistrement des logs ───────────────────────────────────────────────── + +async function logImport( + db: Awaited>, + fileType: "veille" | "aap", + source: string, + status: "success" | "partial" | "error", + totalRows: number, + newRows: number, + skippedRows: number, + errorMessage: string | null, + details: unknown, + startedAt: Date +) { + if (!db) return; + try { + await db.insert(importLogs).values({ + fileType, + source, + status, + totalRows, + newRows, + skippedRows, + errorMessage, + details: details as Record | null, + startedAt, + completedAt: new Date(), + }); + } catch (e) { + console.error("[Import] Erreur lors de l'enregistrement du log:", e); + } +} diff --git a/server/localAuth.ts b/server/localAuth.ts new file mode 100644 index 0000000..3badf70 --- /dev/null +++ b/server/localAuth.ts @@ -0,0 +1,96 @@ +import bcrypt from "bcryptjs"; +import { getDb } from "./db"; +import { localUsers } from "../drizzle/schema"; +import { eq } from "drizzle-orm"; +import { SignJWT, jwtVerify } from "jose"; +import { ENV } from "./_core/env"; + +const SALT_ROUNDS = 12; +const JWT_EXPIRY = "7d"; +const LOCAL_AUTH_COOKIE = "veille_local_auth"; + +export async function hashPassword(password: string): Promise { + return bcrypt.hash(password, SALT_ROUNDS); +} + +export async function verifyPassword(password: string, hash: string): Promise { + return bcrypt.compare(password, hash); +} + +export async function generateLocalToken(userId: number, role: string): Promise { + const secret = new TextEncoder().encode(ENV.cookieSecret); + return new SignJWT({ sub: String(userId), role, type: "local" }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime(JWT_EXPIRY) + .sign(secret); +} + +export async function verifyLocalToken(token: string): Promise<{ userId: number; role: string } | null> { + try { + const secret = new TextEncoder().encode(ENV.cookieSecret); + const { payload } = await jwtVerify(token, secret); + if (payload.type !== "local" || !payload.sub) return null; + return { userId: parseInt(payload.sub), role: payload.role as string }; + } catch { + return null; + } +} + +export async function loginLocalUser(email: string, password: string) { + const db = await getDb(); + if (!db) throw new Error("Base de données indisponible"); + + const users = await db + .select() + .from(localUsers) + .where(eq(localUsers.email, email.toLowerCase().trim())) + .limit(1); + + const user = users[0]; + if (!user || !user.isActive) { + throw new Error("Identifiants incorrects ou compte désactivé"); + } + + const valid = await verifyPassword(password, user.passwordHash); + if (!valid) throw new Error("Identifiants incorrects ou compte désactivé"); + + // Mise à jour lastSignedIn + await db + .update(localUsers) + .set({ lastSignedIn: new Date() }) + .where(eq(localUsers.id, user.id)); + + const token = await generateLocalToken(user.id, user.role); + return { token, user: { id: user.id, name: user.name, email: user.email, role: user.role } }; +} + +export async function getLocalUserById(id: number) { + const db = await getDb(); + if (!db) return null; + const users = await db.select().from(localUsers).where(eq(localUsers.id, id)).limit(1); + return users[0] ?? null; +} + +export async function ensureAdminExists() { + const db = await getDb(); + if (!db) return; + + const admins = await db + .select({ id: localUsers.id }) + .from(localUsers) + .where(eq(localUsers.role, "admin")) + .limit(1); + + if (admins.length === 0) { + const hash = await hashPassword("Admin@Itinova2024!"); + await db.insert(localUsers).values({ + name: "Administrateur", + email: "admin@itinova.fr", + passwordHash: hash, + role: "admin", + isActive: true, + }); + console.log("[LocalAuth] Compte admin par défaut créé : admin@itinova.fr / Admin@Itinova2024!"); + } +} diff --git a/server/routers.ts b/server/routers.ts index 21836ad..f1e5c0b 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -1,28 +1,243 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; import { COOKIE_NAME } from "@shared/const"; import { getSessionCookieOptions } from "./_core/cookies"; import { systemRouter } from "./_core/systemRouter"; -import { publicProcedure, router } from "./_core/trpc"; +import { publicProcedure, protectedProcedure, router } from "./_core/trpc"; +import { + getVeilleItems, + getVeilleDistinctValues, + getAapItems, + getAapDistinctValues, + getAllSettings, + setSettings, + getImportLogs, + getImportStats, + getLocalUsers, + createLocalUser, + updateLocalUser, + deleteLocalUser, +} from "./db"; +import { importVeille, importAAP, runFullImport, getImportConfig } from "./importer"; +import { loginLocalUser, hashPassword, ensureAdminExists } from "./localAuth"; + +// ─── Middleware admin ───────────────────────────────────────────────────────── + +const adminProcedure = protectedProcedure.use(({ ctx, next }) => { + if (ctx.user.role !== "admin") { + throw new TRPCError({ code: "FORBIDDEN", message: "Accès réservé aux administrateurs" }); + } + return next({ ctx }); +}); + +// ─── Router principal ───────────────────────────────────────────────────────── export const appRouter = router({ - // if you need to use socket.io, read and register route in server/_core/index.ts, all api should start with '/api/' so that the gateway can route correctly system: systemRouter, + + // ─── Auth ─────────────────────────────────────────────────────────────────── auth: router({ - me: publicProcedure.query(opts => opts.ctx.user), + me: publicProcedure.query((opts) => opts.ctx.user), logout: publicProcedure.mutation(({ ctx }) => { const cookieOptions = getSessionCookieOptions(ctx.req); ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 }); - return { - success: true, - } as const; + return { success: true } as const; + }), + // Connexion locale + localLogin: publicProcedure + .input(z.object({ email: z.string().email(), password: z.string().min(1) })) + .mutation(async ({ input, ctx }) => { + const result = await loginLocalUser(input.email, input.password); + // Stocker le token dans un cookie + const cookieOptions = getSessionCookieOptions(ctx.req); + ctx.res.cookie("veille_local_auth", result.token, { + ...cookieOptions, + maxAge: 7 * 24 * 60 * 60 * 1000, + }); + return { success: true, user: result.user }; + }), + localLogout: publicProcedure.mutation(({ ctx }) => { + const cookieOptions = getSessionCookieOptions(ctx.req); + ctx.res.clearCookie("veille_local_auth", { ...cookieOptions, maxAge: -1 }); + return { success: true }; }), }), - // TODO: add feature routers here, e.g. - // todo: router({ - // list: protectedProcedure.query(({ ctx }) => - // db.getUserTodos(ctx.user.id) - // ), - // }), + // ─── Veille ───────────────────────────────────────────────────────────────── + veille: router({ + list: publicProcedure + .input( + z.object({ + typeVeille: z.enum(["reglementaire", "concurrentielle", "technologique", "generale"]).optional(), + categorie: z.string().optional(), + niveau: z.string().optional(), + territoire: z.string().optional(), + search: z.string().optional(), + dateFrom: z.date().optional(), + dateTo: z.date().optional(), + page: z.number().int().positive().default(1), + pageSize: z.number().int().positive().max(200).default(50), + }) + ) + .query(async ({ input }) => { + return getVeilleItems(input); + }), + + filters: publicProcedure.query(async () => { + return getVeilleDistinctValues(); + }), + }), + + // ─── AAP ──────────────────────────────────────────────────────────────────── + aap: router({ + list: publicProcedure + .input( + z.object({ + categorie: z.enum(["Handicap", "PA", "Enfance", "Précarité", "Sanitaire", "Autre"]).optional(), + region: z.string().optional(), + departement: z.string().optional(), + search: z.string().optional(), + dateFrom: z.date().optional(), + dateTo: z.date().optional(), + clotureFrom: z.date().optional(), + clotureTo: z.date().optional(), + page: z.number().int().positive().default(1), + pageSize: z.number().int().positive().max(200).default(50), + }) + ) + .query(async ({ input }) => { + return getAapItems(input); + }), + + filters: publicProcedure.query(async () => { + return getAapDistinctValues(); + }), + }), + + // ─── Import ───────────────────────────────────────────────────────────────── + import: router({ + run: adminProcedure + .input(z.object({ type: z.enum(["veille", "aap", "all"]).default("all") })) + .mutation(async ({ input }) => { + const config = await getImportConfig(); + if (input.type === "all") return runFullImport(); + if (input.type === "veille") return { veille: await importVeille(config) }; + return { aap: await importAAP(config) }; + }), + + logs: adminProcedure + .input(z.object({ page: z.number().int().positive().default(1), pageSize: z.number().int().positive().max(100).default(20) })) + .query(async ({ input }) => { + const allLogs = await getImportLogs(500); + const start = (input.page - 1) * input.pageSize; + const logs = allLogs.slice(start, start + input.pageSize); + const stats = await getImportStats(); + return { logs, total: allLogs.length, stats }; + }), + + stats: publicProcedure.query(async () => { + return getImportStats(); + }), + }), + + // ─── Paramètres ───────────────────────────────────────────────────────────── + settings: router({ + get: adminProcedure.query(async () => { + const all = await getAllSettings(); + // Masquer les mots de passe + const safe = { ...all }; + if (safe.ftp_password) safe.ftp_password = "••••••••"; + if (safe.onedrive_token) safe.onedrive_token = "••••••••"; + if (safe.sharepoint_token) safe.sharepoint_token = "••••••••"; + return safe; + }), + + save: adminProcedure + .input( + z.object({ + source_type: z.enum(["local", "onedrive", "ftp", "sharepoint"]), + veille_file_path: z.string().optional(), + aap_file_path: z.string().optional(), + ftp_host: z.string().optional(), + ftp_port: z.string().optional(), + ftp_user: z.string().optional(), + ftp_password: z.string().optional(), + ftp_secure: z.string().optional(), + onedrive_token: z.string().optional(), + sharepoint_site_url: z.string().optional(), + sharepoint_token: z.string().optional(), + auth_mode: z.enum(["local", "free"]).optional(), + import_time: z.string().optional(), + }) + ) + .mutation(async ({ input }) => { + const toSave: Record = {}; + for (const [k, v] of Object.entries(input)) { + if (v !== undefined && v !== "••••••••") toSave[k] = v; + } + await setSettings(toSave); + return { success: true }; + }), + }), + + // ─── Utilisateurs locaux ───────────────────────────────────────────────────── + users: router({ + list: adminProcedure.query(async () => { + return getLocalUsers(); + }), + + create: adminProcedure + .input( + z.object({ + name: z.string().min(2).max(255), + email: z.string().email(), + password: z.string().min(8), + role: z.enum(["admin", "user", "readonly"]).default("user"), + }) + ) + .mutation(async ({ input }) => { + const passwordHash = await hashPassword(input.password); + await createLocalUser({ + name: input.name, + email: input.email.toLowerCase(), + passwordHash, + role: input.role, + isActive: true, + }); + return { success: true }; + }), + + update: adminProcedure + .input( + z.object({ + id: z.number().int().positive(), + name: z.string().min(2).max(255).optional(), + email: z.string().email().optional(), + password: z.string().min(8).optional(), + role: z.enum(["admin", "user", "readonly"]).optional(), + isActive: z.boolean().optional(), + }) + ) + .mutation(async ({ input }) => { + const { id, password, ...rest } = input; + const data: Record = { ...rest }; + if (password) data.passwordHash = await hashPassword(password); + await updateLocalUser(id, data as Parameters[1]); + return { success: true }; + }), + + delete: adminProcedure + .input(z.object({ id: z.number().int().positive() })) + .mutation(async ({ input }) => { + await deleteLocalUser(input.id); + return { success: true }; + }), + + ensureAdmin: publicProcedure.mutation(async () => { + await ensureAdminExists(); + return { success: true }; + }), + }), }); export type AppRouter = typeof appRouter; diff --git a/server/veille.test.ts b/server/veille.test.ts new file mode 100644 index 0000000..18592ca --- /dev/null +++ b/server/veille.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { appRouter } from "./routers"; +import type { TrpcContext } from "./_core/context"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeAdminCtx(): TrpcContext { + return { + user: { + id: 1, + openId: "admin-test", + email: "admin@itinova.fr", + name: "Admin Test", + loginMethod: "local", + role: "admin", + createdAt: new Date(), + updatedAt: new Date(), + lastSignedIn: new Date(), + }, + req: { protocol: "https", headers: {} } as TrpcContext["req"], + res: { + clearCookie: vi.fn(), + cookie: vi.fn(), + } as unknown as TrpcContext["res"], + }; +} + +function makeUserCtx(): TrpcContext { + return { + user: { + id: 2, + openId: "user-test", + email: "user@itinova.fr", + name: "User Test", + loginMethod: "local", + role: "user", + createdAt: new Date(), + updatedAt: new Date(), + lastSignedIn: new Date(), + }, + req: { protocol: "https", headers: {} } as TrpcContext["req"], + res: { + clearCookie: vi.fn(), + cookie: vi.fn(), + } as unknown as TrpcContext["res"], + }; +} + +function makeAnonCtx(): TrpcContext { + return { + user: null, + req: { protocol: "https", headers: {} } as TrpcContext["req"], + res: { + clearCookie: vi.fn(), + cookie: vi.fn(), + } as unknown as TrpcContext["res"], + }; +} + +// ─── Tests Auth ─────────────────────────────────────────────────────────────── + +describe("auth.logout", () => { + it("efface le cookie de session et retourne success", async () => { + const { ctx } = { ctx: makeAdminCtx() }; + const clearedCookies: string[] = []; + ctx.res.clearCookie = (name: string) => { clearedCookies.push(name); }; + + const caller = appRouter.createCaller(ctx); + const result = await caller.auth.logout(); + + expect(result.success).toBe(true); + expect(clearedCookies.length).toBeGreaterThan(0); + }); + + it("retourne l'utilisateur connecté via auth.me", async () => { + const ctx = makeAdminCtx(); + const caller = appRouter.createCaller(ctx); + const user = await caller.auth.me(); + expect(user).not.toBeNull(); + expect(user?.email).toBe("admin@itinova.fr"); + }); + + it("retourne null pour un utilisateur non connecté", async () => { + const ctx = makeAnonCtx(); + const caller = appRouter.createCaller(ctx); + const user = await caller.auth.me(); + expect(user).toBeNull(); + }); +}); + +// ─── Tests protection admin ─────────────────────────────────────────────────── + +describe("protection admin", () => { + it("refuse l'accès aux logs pour un utilisateur non admin", async () => { + const ctx = makeUserCtx(); + const caller = appRouter.createCaller(ctx); + await expect(caller.import.logs({ page: 1, pageSize: 10 })).rejects.toThrow(); + }); + + it("refuse l'accès aux paramètres pour un utilisateur non admin", async () => { + const ctx = makeUserCtx(); + const caller = appRouter.createCaller(ctx); + await expect(caller.settings.get()).rejects.toThrow(); + }); + + it("refuse la gestion des utilisateurs pour un non admin", async () => { + const ctx = makeUserCtx(); + const caller = appRouter.createCaller(ctx); + await expect(caller.users.list()).rejects.toThrow(); + }); +}); + +// ─── Tests accès public ─────────────────────────────────────────────────────── + +describe("accès public", () => { + it("veille.list est accessible sans authentification", async () => { + const ctx = makeAnonCtx(); + const caller = appRouter.createCaller(ctx); + // Ne devrait pas lever d'erreur UNAUTHORIZED + const result = await caller.veille.list({ page: 1, pageSize: 10 }); + expect(result).toHaveProperty("items"); + expect(result).toHaveProperty("total"); + }); + + it("aap.list est accessible sans authentification", async () => { + const ctx = makeAnonCtx(); + const caller = appRouter.createCaller(ctx); + const result = await caller.aap.list({ page: 1, pageSize: 10 }); + expect(result).toHaveProperty("items"); + expect(result).toHaveProperty("total"); + }); + + it("veille.filters est accessible sans authentification", async () => { + const ctx = makeAnonCtx(); + const caller = appRouter.createCaller(ctx); + const result = await caller.veille.filters(); + expect(result).toHaveProperty("categories"); + expect(result).toHaveProperty("niveaux"); + expect(result).toHaveProperty("territoires"); + }); + + it("aap.filters est accessible sans authentification", async () => { + const ctx = makeAnonCtx(); + const caller = appRouter.createCaller(ctx); + const result = await caller.aap.filters(); + expect(result).toHaveProperty("regions"); + expect(result).toHaveProperty("departements"); + }); +}); + +// ─── Tests import (admin seulement) ────────────────────────────────────────── + +describe("import.run protection", () => { + it("refuse l'import pour un utilisateur non authentifié", async () => { + const ctx = makeAnonCtx(); + const caller = appRouter.createCaller(ctx); + await expect(caller.import.run({ type: "all" })).rejects.toThrow(); + }); + + it("refuse l'import pour un utilisateur standard", async () => { + const ctx = makeUserCtx(); + const caller = appRouter.createCaller(ctx); + await expect(caller.import.run({ type: "all" })).rejects.toThrow(); + }); +}); diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..3c5a722 --- /dev/null +++ b/todo.md @@ -0,0 +1,27 @@ +# Veille Réglementaire Itinova — TODO + +## Fonctionnalités principales + +- [x] Schéma BDD : tables veille_entries, aap_entries, import_logs, app_settings, local_users +- [x] Migrations Drizzle poussées avec succès +- [x] Module d'import Excel (xlsx) avec déduplication intelligente +- [x] Support source locale (fichier local) +- [x] Support source OneDrive (Microsoft Graph API) +- [x] Support source FTP (basic-ftp) +- [x] Support source SharePoint (Microsoft Graph API) +- [x] Tâche cron quotidienne à 06h00 pour l'import automatique +- [x] Authentification locale (bcrypt + JWT) avec gestion des utilisateurs +- [x] API tRPC complète : veille, aap, import, settings, users, auth +- [x] Tableau de bord Veille Stratégique (4 onglets : réglementaire, concurrentielle, technologique, générale) +- [x] Tableau de bord Appels à Projets (6 onglets : Handicap, PA, Enfance, Précarité, Sanitaire, Autre) +- [x] Mode d'affichage Liste / Vignettes avec bouton de basculement +- [x] Filtres multi-critères (recherche texte, catégorie, niveau, territoire, région, département, date) +- [x] Tri chronologique du plus récent au plus ancien +- [x] Page Paramètres (source fichiers, chemins, planification, authentification) +- [x] Page Gestion des utilisateurs (création, modification, suppression, activation/désactivation) +- [x] Page Logs d'import (statistiques, historique, import manuel) +- [x] Layout sidebar avec navigation complète +- [x] Page de connexion élégante +- [x] Thème visuel Itinova (bleu marine, palette professionnelle) +- [x] 13 tests Vitest passants (auth + veille) +- [x] Compte admin par défaut créé au démarrage du serveur