diff --git a/client/public/__manus__/version.json b/client/public/__manus__/version.json index b9d6c41..7da5bf7 100644 --- a/client/public/__manus__/version.json +++ b/client/public/__manus__/version.json @@ -1,4 +1,4 @@ { - "version": "39f91ba6", - "timestamp": 1776769592209 + "version": "30c14d7e", + "timestamp": 1777149849085 } \ No newline at end of file diff --git a/client/src/App.tsx b/client/src/App.tsx index 8a25c61..036c253 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -12,7 +12,8 @@ import AAPDashboard from "./pages/AAPDashboard"; import Settings from "./pages/Settings"; import UsersAdmin from "./pages/UsersAdmin"; import ImportLogs from "./pages/ImportLogs"; -import BoiteAIdees from "./pages/BoiteAIdees"; +import BoiteAIdees from "@/pages/BoiteAIdees"; +import RssFeeds from "@/pages/RssFeeds"; import { Loader2 } from "lucide-react"; // ─── Guard d'authentification ───────────────────────────────────────────────── @@ -108,6 +109,16 @@ function BoiteAIdeesPage() { ); } +function RssFeedsPage() { + return ( + + + + + + ); +} + // ─── Routeur principal ──────────────────────────────────────────────────────── function Router() { @@ -123,6 +134,7 @@ function Router() { + diff --git a/client/src/components/AppLayout.tsx b/client/src/components/AppLayout.tsx index b40fa44..a4f8411 100644 --- a/client/src/components/AppLayout.tsx +++ b/client/src/components/AppLayout.tsx @@ -17,6 +17,7 @@ import { Menu, X, Lightbulb, + Rss, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -64,6 +65,7 @@ const NAV_GROUPS: NavGroup[] = [ items: [ { label: "Logs d'import", href: "/admin/logs", icon: , adminOnly: true }, { label: "Utilisateurs", href: "/admin/users", icon: , adminOnly: true }, + { label: "Flux RSS", href: "/admin/rss", icon: , adminOnly: true }, { label: "Paramètres", href: "/admin/settings", icon: , adminOnly: true }, ], }, diff --git a/client/src/pages/RssFeeds.tsx b/client/src/pages/RssFeeds.tsx new file mode 100644 index 0000000..930ba34 --- /dev/null +++ b/client/src/pages/RssFeeds.tsx @@ -0,0 +1,834 @@ +import { useState } from "react"; +import { trpc } from "@/lib/trpc"; +import { toast } from "sonner"; +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 { Switch } from "@/components/ui/switch"; +import { Separator } from "@/components/ui/separator"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; +import { + Rss, + Plus, + Pencil, + Trash2, + CheckCircle2, + XCircle, + Clock, + AlertCircle, + Settings2, + Zap, + Globe, + ChevronDown, + ChevronUp, + Save, +} from "lucide-react"; +import { useLocalAuth } from "@/contexts/LocalAuthContext"; +import { cn } from "@/lib/utils"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type FeedType = "veille" | "aap"; +type TypeVeille = "reglementaire" | "concurrentielle" | "technologique" | "generale"; +type CategorieAap = "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre"; + +interface AutoRule { + keyword: string; + value: string; +} + +interface RssFeed { + id: number; + url: string; + name: string; + feedType: FeedType; + defaultTypeVeille: TypeVeille | null; + defaultCategorieAap: CategorieAap | null; + autoRules: AutoRule[] | null; + isActive: boolean; + lastFetchedAt: Date | null; + lastFetchStatus: "ok" | "error" | "pending" | null; + lastFetchError: string | null; +} + +// ─── Constantes ─────────────────────────────────────────────────────────────── + +const TYPE_VEILLE_LABELS: Record = { + reglementaire: "Réglementaire", + concurrentielle: "Concurrentielle", + technologique: "Technologique", + generale: "Générale", +}; + +const CATEGORIE_AAP_OPTIONS: CategorieAap[] = ["Handicap", "PA", "Enfance", "Précarité", "Sanitaire", "Autre"]; +const TYPE_VEILLE_OPTIONS: TypeVeille[] = ["reglementaire", "concurrentielle", "technologique", "generale"]; + +const INTERVAL_OPTIONS = [ + { value: 30, label: "30 minutes" }, + { value: 60, label: "1 heure" }, + { value: 120, label: "2 heures" }, + { value: 360, label: "6 heures" }, + { value: 720, label: "12 heures" }, + { value: 1440, label: "24 heures" }, +]; + +// ─── Formulaire de flux ─────────────────────────────────────────────────────── + +interface FeedFormData { + url: string; + name: string; + feedType: FeedType; + defaultTypeVeille: TypeVeille | ""; + defaultCategorieAap: CategorieAap | ""; + autoRules: AutoRule[]; + isActive: boolean; +} + +const EMPTY_FORM: FeedFormData = { + url: "", + name: "", + feedType: "veille", + defaultTypeVeille: "reglementaire", + defaultCategorieAap: "", + autoRules: [], + isActive: true, +}; + +function FeedFormDialog({ + open, + onClose, + feed, + onSaved, +}: { + open: boolean; + onClose: () => void; + feed: RssFeed | null; + onSaved: () => void; +}) { + const isEdit = !!feed; + const [form, setForm] = useState(() => + feed + ? { + url: feed.url, + name: feed.name, + feedType: feed.feedType, + defaultTypeVeille: feed.defaultTypeVeille ?? "", + defaultCategorieAap: feed.defaultCategorieAap ?? "", + autoRules: feed.autoRules ?? [], + isActive: feed.isActive, + } + : { ...EMPTY_FORM } + ); + const [newRuleKeyword, setNewRuleKeyword] = useState(""); + const [newRuleValue, setNewRuleValue] = useState(""); + + const createMutation = trpc.rss.create.useMutation({ + onSuccess: () => { + toast.success("Flux RSS ajouté"); + onSaved(); + onClose(); + }, + onError: (e) => toast.error("Erreur", { description: e.message }), + }); + + const updateMutation = trpc.rss.update.useMutation({ + onSuccess: () => { + toast.success("Flux RSS mis à jour"); + onSaved(); + onClose(); + }, + onError: (e) => toast.error("Erreur", { description: e.message }), + }); + + const isPending = createMutation.isPending || updateMutation.isPending; + + const handleSubmit = () => { + if (!form.url || !form.name) { + toast.error("URL et nom sont requis"); + return; + } + const payload = { + url: form.url, + name: form.name, + feedType: form.feedType, + defaultTypeVeille: form.feedType === "veille" && form.defaultTypeVeille ? form.defaultTypeVeille : undefined, + defaultCategorieAap: form.feedType === "aap" && form.defaultCategorieAap ? (form.defaultCategorieAap as CategorieAap) : undefined, + autoRules: form.autoRules.length > 0 ? form.autoRules : undefined, + isActive: form.isActive, + }; + if (isEdit && feed) { + updateMutation.mutate({ id: feed.id, ...payload }); + } else { + createMutation.mutate(payload); + } + }; + + const addRule = () => { + if (!newRuleKeyword.trim() || !newRuleValue.trim()) return; + setForm((f) => ({ + ...f, + autoRules: [...f.autoRules, { keyword: newRuleKeyword.trim(), value: newRuleValue.trim() }], + })); + setNewRuleKeyword(""); + setNewRuleValue(""); + }; + + const removeRule = (idx: number) => { + setForm((f) => ({ ...f, autoRules: f.autoRules.filter((_, i) => i !== idx) })); + }; + + return ( + !o && onClose()}> + + + + + {isEdit ? "Modifier le flux RSS" : "Ajouter un flux RSS"} + + + Configurez l'URL, le type de contenu et les règles d'automatisme pour ce flux. + + + +
+ {/* URL */} +
+ + setForm((f) => ({ ...f, url: e.target.value }))} + /> +
+ + {/* Nom */} +
+ + setForm((f) => ({ ...f, name: e.target.value }))} + /> +
+ + {/* Type de flux */} +
+ +
+ {(["veille", "aap"] as FeedType[]).map((type) => ( + + ))} +
+
+ + {/* Valeur par défaut selon le type */} + {form.feedType === "veille" && ( +
+ + +
+ )} + + {form.feedType === "aap" && ( +
+ + +
+ )} + + + + {/* Règles d'automatisme */} +
+
+ + +
+

+ Si le titre ou le résumé d'un article contient le mot-clé, la {form.feedType === "veille" ? "catégorie de veille" : "catégorie AAP"} sera automatiquement assignée. +

+ + {/* Règles existantes */} + {form.autoRules.length > 0 && ( +
+ {form.autoRules.map((rule, idx) => ( +
+ {rule.keyword} + + {rule.value} + +
+ ))} +
+ )} + + {/* Ajouter une règle */} +
+
+ + setNewRuleKeyword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && addRule()} + className="h-8 text-sm" + /> +
+ +
+ + {form.feedType === "veille" ? ( + + ) : ( + + )} +
+ +
+
+ + + + {/* Actif */} +
+
+ +

Désactivez pour suspendre la lecture sans supprimer le flux

+
+ setForm((f) => ({ ...f, isActive: v }))} + /> +
+
+ + + + + +
+
+ ); +} + +// ─── Carte d'un flux ────────────────────────────────────────────────────────── + +function FeedCard({ + feed, + isAdmin, + onEdit, + onDelete, + onToggle, +}: { + feed: RssFeed; + isAdmin: boolean; + onEdit: () => void; + onDelete: () => void; + onToggle: (active: boolean) => void; +}) { + const [rulesOpen, setRulesOpen] = useState(false); + const rules = feed.autoRules ?? []; + + const statusIcon = () => { + if (feed.lastFetchStatus === "ok") return ; + if (feed.lastFetchStatus === "error") return ; + return ; + }; + + const statusLabel = () => { + if (feed.lastFetchStatus === "ok" && feed.lastFetchedAt) { + return `Dernière lecture : ${new Date(feed.lastFetchedAt).toLocaleString("fr-FR")}`; + } + if (feed.lastFetchStatus === "error") return `Erreur : ${feed.lastFetchError ?? "inconnue"}`; + return "Jamais lu"; + }; + + return ( +
+
+ {/* Icône type */} +
+ {feed.feedType === "veille" ? : } +
+ + {/* Infos */} +
+
+ {feed.name} + + {feed.feedType === "veille" ? "Veille" : "AAP"} + + {feed.feedType === "veille" && feed.defaultTypeVeille && ( + + {TYPE_VEILLE_LABELS[feed.defaultTypeVeille]} + + )} + {feed.feedType === "aap" && feed.defaultCategorieAap && ( + + {feed.defaultCategorieAap} + + )} +
+

{feed.url}

+
+ {statusIcon()} + {statusLabel()} +
+ + {/* Règles d'automatisme */} + {rules.length > 0 && ( +
+ + {rulesOpen && ( +
+ {rules.map((rule, i) => ( + + {rule.keyword} + + {rule.value} + + ))} +
+ )} +
+ )} +
+ + {/* Actions */} + {isAdmin && ( +
+ + + +
+ )} +
+
+ ); +} + +// ─── Panneau de configuration ───────────────────────────────────────────────── + +function SettingsPanel({ isAdmin }: { isAdmin: boolean }) { + const { data: settings, refetch } = trpc.rss.getSettings.useQuery(); + const saveMutation = trpc.rss.saveSettings.useMutation({ + onSuccess: () => { + toast.success("Paramètres RSS sauvegardés"); + refetch(); + }, + onError: (e) => toast.error("Erreur", { description: e.message }), + }); + + const [fetchMode, setFetchMode] = useState<"interval" | "scheduled">(settings?.fetchMode ?? "scheduled"); + const [fetchIntervalMinutes, setFetchIntervalMinutes] = useState(settings?.fetchIntervalMinutes ?? 360); + const [scheduledTime, setScheduledTime] = useState(settings?.scheduledTime ?? "06:00"); + const [autoFetchEnabled, setAutoFetchEnabled] = useState(settings?.autoFetchEnabled ?? true); + + // Sync state when settings load + if (settings && settings.fetchMode !== fetchMode && !saveMutation.isPending) { + setFetchMode(settings.fetchMode); + setFetchIntervalMinutes(settings.fetchIntervalMinutes); + setScheduledTime(settings.scheduledTime ?? "06:00"); + setAutoFetchEnabled(settings.autoFetchEnabled); + } + + const handleSave = () => { + saveMutation.mutate({ + fetchMode, + fetchIntervalMinutes, + scheduledTime, + autoFetchEnabled, + }); + }; + + return ( + + + + + Paramètres de lecture automatique + + + Configurez la fréquence et le mode de lecture des flux RSS. + + + + {/* Activation */} +
+
+

Lecture automatique

+

Active la récupération périodique des flux

+
+ +
+ + {/* Mode */} +
+ +
+ {(["scheduled", "interval"] as const).map((mode) => ( + + ))} +
+
+ + {/* Paramètre selon le mode */} + {fetchMode === "scheduled" ? ( +
+ + setScheduledTime(e.target.value)} + disabled={!isAdmin} + className="w-36" + /> +
+ ) : ( +
+ + +
+ )} + + {isAdmin && ( + + )} +
+
+ ); +} + +// ─── Page principale ────────────────────────────────────────────────────────── + +export default function RssFeeds() { + const { user } = useLocalAuth(); + const isAdmin = user?.role === "admin"; + + const { data: feeds = [], refetch, isLoading } = trpc.rss.list.useQuery(); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editFeed, setEditFeed] = useState(null); + const [deleteId, setDeleteId] = useState(null); + const [filterType, setFilterType] = useState<"all" | "veille" | "aap">("all"); + + const toggleMutation = trpc.rss.toggleActive.useMutation({ + onSuccess: () => refetch(), + onError: (e) => toast.error("Erreur", { description: e.message }), + }); + + const deleteMutation = trpc.rss.delete.useMutation({ + onSuccess: () => { + toast.success("Flux supprimé"); + setDeleteId(null); + refetch(); + }, + onError: (e) => toast.error("Erreur", { description: e.message }), + }); + + const filteredFeeds = feeds.filter((f) => filterType === "all" || f.feedType === filterType); + const veilleCount = feeds.filter((f) => f.feedType === "veille").length; + const aapCount = feeds.filter((f) => f.feedType === "aap").length; + const activeCount = feeds.filter((f) => f.isActive).length; + + return ( +
+ {/* En-tête */} +
+
+

+ + Flux RSS +

+

+ Gérez les sources RSS qui alimentent automatiquement la veille réglementaire et les appels à projets. +

+
+ {isAdmin && ( + + )} +
+ + {/* Statistiques */} +
+ {[ + { label: "Total", value: feeds.length, color: "text-foreground" }, + { label: "Actifs", value: activeCount, color: "text-green-600" }, + { label: "Veille", value: veilleCount, color: "text-blue-600" }, + { label: "AAP", value: aapCount, color: "text-amber-600" }, + ].map((stat) => ( +
+

{stat.value}

+

{stat.label}

+
+ ))} +
+ + {/* Filtres */} +
+ {(["all", "veille", "aap"] as const).map((type) => ( + + ))} +
+ + {/* Liste des flux */} +
+ {isLoading ? ( +
+ +

Chargement des flux…

+
+ ) : filteredFeeds.length === 0 ? ( +
+ +

Aucun flux RSS configuré

+ {isAdmin && ( + + )} +
+ ) : ( + filteredFeeds.map((feed) => ( + { setEditFeed(feed as RssFeed); setDialogOpen(true); }} + onDelete={() => setDeleteId(feed.id)} + onToggle={(active) => toggleMutation.mutate({ id: feed.id, isActive: active })} + /> + )) + )} +
+ + {/* Paramètres de lecture */} + + + {/* Informations sur les automatismes */} + + +
+ +
+

Comment fonctionnent les règles d'automatisme ?

+

+ Lors de la lecture d'un flux, chaque article est analysé. Si son titre ou son résumé contient un mot-clé défini dans les règles, la catégorie ou le type de veille correspondant lui est automatiquement assigné. Si aucune règle ne correspond, la valeur par défaut du flux est utilisée. +

+
+
+
+
+ + {/* Dialog ajout/édition */} + { setDialogOpen(false); setEditFeed(null); }} + feed={editFeed} + onSaved={() => refetch()} + /> + + {/* Confirmation suppression */} + !o && setDeleteId(null)}> + + + Supprimer ce flux RSS ? + + Cette action est irréversible. Le flux sera définitivement supprimé mais les entrées déjà importées seront conservées. + + + + Annuler + deleteId && deleteMutation.mutate({ id: deleteId })} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Supprimer + + + + +
+ ); +} diff --git a/drizzle/0004_clear_edwin_jarvis.sql b/drizzle/0004_clear_edwin_jarvis.sql new file mode 100644 index 0000000..25c4865 --- /dev/null +++ b/drizzle/0004_clear_edwin_jarvis.sql @@ -0,0 +1,26 @@ +CREATE TABLE `rss_feeds` ( + `id` int AUTO_INCREMENT NOT NULL, + `url` text NOT NULL, + `name` varchar(255) NOT NULL, + `feedType` enum('veille','aap') NOT NULL, + `defaultTypeVeille` enum('reglementaire','concurrentielle','technologique','generale'), + `defaultCategorieAap` enum('Handicap','PA','Enfance','Précarité','Sanitaire','Autre'), + `autoRules` json, + `isActive` boolean NOT NULL DEFAULT true, + `lastFetchedAt` timestamp, + `lastFetchStatus` enum('ok','error','pending') DEFAULT 'pending', + `lastFetchError` text, + `createdAt` timestamp NOT NULL DEFAULT (now()), + `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `rss_feeds_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `rss_settings` ( + `id` int AUTO_INCREMENT NOT NULL, + `fetchIntervalMinutes` int NOT NULL DEFAULT 360, + `scheduledTime` varchar(5) DEFAULT '06:00', + `fetchMode` enum('interval','scheduled') NOT NULL DEFAULT 'scheduled', + `autoFetchEnabled` boolean NOT NULL DEFAULT true, + `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `rss_settings_id` PRIMARY KEY(`id`) +); diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..8d2c153 --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,848 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "91cbc9bd-a436-4462-8a36-915ac2e72e28", + "prevId": "c42bd6aa-6824-4752-9e80-d410188548cf", + "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": {} + }, + "ideas": { + "name": "ideas", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userName": { + "name": "userName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "titre": { + "name": "titre", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "statut": { + "name": "statut", + "type": "enum('ouvert','en_cours','resolu','ferme')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ouvert'" + }, + "reponseAdmin": { + "name": "reponseAdmin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reponduPar": { + "name": "reponduPar", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reponduAt": { + "name": "reponduAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ideas_id": { + "name": "ideas_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "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 + }, + "username": { + "name": "username", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "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_username_unique": { + "name": "local_users_username_unique", + "columns": [ + "username" + ] + } + }, + "checkConstraint": {} + }, + "rss_feeds": { + "name": "rss_feeds", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "feedType": { + "name": "feedType", + "type": "enum('veille','aap')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "defaultTypeVeille": { + "name": "defaultTypeVeille", + "type": "enum('reglementaire','concurrentielle','technologique','generale')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "defaultCategorieAap": { + "name": "defaultCategorieAap", + "type": "enum('Handicap','PA','Enfance','Précarité','Sanitaire','Autre')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autoRules": { + "name": "autoRules", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "lastFetchedAt": { + "name": "lastFetchedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastFetchStatus": { + "name": "lastFetchStatus", + "type": "enum('ok','error','pending')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "lastFetchError": { + "name": "lastFetchError", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "rss_feeds_id": { + "name": "rss_feeds_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "rss_settings": { + "name": "rss_settings", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "fetchIntervalMinutes": { + "name": "fetchIntervalMinutes", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 360 + }, + "scheduledTime": { + "name": "scheduledTime", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'06:00'" + }, + "fetchMode": { + "name": "fetchMode", + "type": "enum('interval','scheduled')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'scheduled'" + }, + "autoFetchEnabled": { + "name": "autoFetchEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "rss_settings_id": { + "name": "rss_settings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "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 36693d8..98d4b2b 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1776763582959, "tag": "0003_shocking_secret_warriors", "breakpoints": true + }, + { + "idx": 4, + "version": "5", + "when": 1777149207871, + "tag": "0004_clear_edwin_jarvis", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index f9c8d18..d0b2be7 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -135,3 +135,50 @@ export const ideas = mysqlTable("ideas", { export type Idea = typeof ideas.$inferSelect; export type InsertIdea = typeof ideas.$inferInsert; + +// ─── Flux RSS ──────────────────────────────────────────────────────────────────────────────────── + +export const rssFeeds = mysqlTable("rss_feeds", { + id: int("id").autoincrement().primaryKey(), + // URL du flux RSS + url: text("url").notNull(), + // Nom descriptif du flux + name: varchar("name", { length: 255 }).notNull(), + // Type de contenu alimenté par ce flux + feedType: mysqlEnum("feedType", ["veille", "aap"]).notNull(), + // Pour les flux de type veille : type de veille par défaut + defaultTypeVeille: mysqlEnum("defaultTypeVeille", ["reglementaire", "concurrentielle", "technologique", "generale"]), + // Pour les flux de type aap : catégorie par défaut + defaultCategorieAap: mysqlEnum("defaultCategorieAap", ["Handicap", "PA", "Enfance", "Précarité", "Sanitaire", "Autre"]), + // Règles d'automatisme JSON : [{keyword, typeVeille|categorieAap}] + autoRules: json("autoRules"), + // Actif ou non + isActive: boolean("isActive").default(true).notNull(), + // Dernière lecture réussie + lastFetchedAt: timestamp("lastFetchedAt"), + // Dernier statut de lecture + lastFetchStatus: mysqlEnum("lastFetchStatus", ["ok", "error", "pending"]).default("pending"), + lastFetchError: text("lastFetchError"), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), +}); + +export type RssFeed = typeof rssFeeds.$inferSelect; +export type InsertRssFeed = typeof rssFeeds.$inferInsert; + +// Paramètres globaux RSS (fréquence de lecture, etc.) +export const rssSettings = mysqlTable("rss_settings", { + id: int("id").autoincrement().primaryKey(), + // Fréquence de lecture en minutes (ex: 60, 360, 1440) + fetchIntervalMinutes: int("fetchIntervalMinutes").default(360).notNull(), + // Heure de lecture automatique (format HH:MM, si mode planifié) + scheduledTime: varchar("scheduledTime", { length: 5 }).default("06:00"), + // Mode : interval (toutes les N minutes) ou scheduled (heure fixe) + fetchMode: mysqlEnum("fetchMode", ["interval", "scheduled"]).default("scheduled").notNull(), + // Activer/désactiver la lecture automatique + autoFetchEnabled: boolean("autoFetchEnabled").default(true).notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), +}); + +export type RssSettings = typeof rssSettings.$inferSelect; +export type InsertRssSettings = typeof rssSettings.$inferInsert; diff --git a/server/db.ts b/server/db.ts index f3104e3..c93a6f6 100644 --- a/server/db.ts +++ b/server/db.ts @@ -11,6 +11,12 @@ import { InsertLocalUser, ideas, InsertIdea, + rssFeeds, + rssSettings, + type InsertRssFeed, + type InsertRssSettings, + type RssFeed, + type RssSettings, } from "../drizzle/schema"; import { ENV } from "./_core/env"; @@ -360,3 +366,70 @@ export async function updateIdeaStatut(id: number, statut: "ouvert" | "en_cours" if (!db) throw new Error("Database not available"); await db.update(ideas).set({ statut }).where(eq(ideas.id, id)); } + +// ─── Flux RSS ──────────────────────────────────────────────────────────────────────────────────── + +export async function getRssFeeds(): Promise { + const db = await getDb(); + if (!db) return []; + return db.select().from(rssFeeds).orderBy(rssFeeds.name); +} + +export async function getRssFeedById(id: number): Promise { + const db = await getDb(); + if (!db) return null; + const rows = await db.select().from(rssFeeds).where(eq(rssFeeds.id, id)).limit(1); + return rows[0] ?? null; +} + +export async function createRssFeed(data: Omit): Promise { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + const result = await db.insert(rssFeeds).values(data); + return (result[0] as any).insertId as number; +} + +export async function updateRssFeed(id: number, data: Partial>): Promise { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + await db.update(rssFeeds).set(data).where(eq(rssFeeds.id, id)); +} + +export async function deleteRssFeed(id: number): Promise { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + await db.delete(rssFeeds).where(eq(rssFeeds.id, id)); +} + +export async function getRssSettings(): Promise { + const db = await getDb(); + if (!db) return null; + const rows = await db.select().from(rssSettings).limit(1); + if (rows.length > 0) return rows[0]; + // Créer les paramètres par défaut si inexistants + await db.insert(rssSettings).values({ + fetchIntervalMinutes: 360, + scheduledTime: "06:00", + fetchMode: "scheduled", + autoFetchEnabled: true, + }); + const newRows = await db.select().from(rssSettings).limit(1); + return newRows[0] ?? null; +} + +export async function saveRssSettings(data: Partial>): Promise { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + const existing = await db.select().from(rssSettings).limit(1); + if (existing.length > 0) { + await db.update(rssSettings).set(data).where(eq(rssSettings.id, existing[0].id)); + } else { + await db.insert(rssSettings).values({ + fetchIntervalMinutes: 360, + scheduledTime: "06:00", + fetchMode: "scheduled", + autoFetchEnabled: true, + ...data, + }); + } +} diff --git a/server/routers.ts b/server/routers.ts index ea12b04..740ba13 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -22,6 +22,13 @@ import { getIdeasByUser, repondreIdea, updateIdeaStatut, + getRssFeeds, + getRssFeedById, + createRssFeed, + updateRssFeed, + deleteRssFeed, + getRssSettings, + saveRssSettings, } from "./db"; import { importVeille, importAAP, runFullImport, getImportConfig } from "./importer"; import { loginLocalUser, hashPassword, ensureAdminExists } from "./localAuth"; @@ -307,6 +314,94 @@ export const appRouter = router({ return { success: true }; }), }), -}); + // ─── Flux RSS ─────────────────────────────────────────────────────────────────────────────────── + rss: router({ + // Lister tous les flux + list: protectedProcedure.query(async () => { + return getRssFeeds(); + }), + // Créer un flux + create: adminProcedure + .input(z.object({ + url: z.string().url("URL invalide"), + name: z.string().min(1, "Nom requis").max(255), + feedType: z.enum(["veille", "aap"]), + defaultTypeVeille: z.enum(["reglementaire", "concurrentielle", "technologique", "generale"]).optional(), + defaultCategorieAap: z.enum(["Handicap", "PA", "Enfance", "Précarité", "Sanitaire", "Autre"]).optional(), + autoRules: z.array(z.object({ + keyword: z.string(), + value: z.string(), + })).optional(), + isActive: z.boolean().optional().default(true), + })) + .mutation(async ({ input }) => { + const id = await createRssFeed({ + url: input.url, + name: input.name, + feedType: input.feedType, + defaultTypeVeille: input.defaultTypeVeille ?? null, + defaultCategorieAap: input.defaultCategorieAap ?? null, + autoRules: input.autoRules ?? null, + isActive: input.isActive ?? true, + }); + return { id }; + }), + + // Modifier un flux + update: adminProcedure + .input(z.object({ + id: z.number().int().positive(), + url: z.string().url("URL invalide").optional(), + name: z.string().min(1).max(255).optional(), + feedType: z.enum(["veille", "aap"]).optional(), + defaultTypeVeille: z.enum(["reglementaire", "concurrentielle", "technologique", "generale"]).nullable().optional(), + defaultCategorieAap: z.enum(["Handicap", "PA", "Enfance", "Précarité", "Sanitaire", "Autre"]).nullable().optional(), + autoRules: z.array(z.object({ + keyword: z.string(), + value: z.string(), + })).nullable().optional(), + isActive: z.boolean().optional(), + })) + .mutation(async ({ input }) => { + const { id, ...data } = input; + await updateRssFeed(id, data); + return { success: true }; + }), + + // Supprimer un flux + delete: adminProcedure + .input(z.object({ id: z.number().int().positive() })) + .mutation(async ({ input }) => { + await deleteRssFeed(input.id); + return { success: true }; + }), + + // Activer / désactiver un flux + toggleActive: adminProcedure + .input(z.object({ id: z.number().int().positive(), isActive: z.boolean() })) + .mutation(async ({ input }) => { + await updateRssFeed(input.id, { isActive: input.isActive }); + return { success: true }; + }), + + // Lire les paramètres globaux RSS + getSettings: protectedProcedure.query(async () => { + return getRssSettings(); + }), + + // Sauvegarder les paramètres globaux RSS + saveSettings: adminProcedure + .input(z.object({ + fetchIntervalMinutes: z.number().int().min(5).max(10080).optional(), + scheduledTime: z.string().regex(/^\d{2}:\d{2}$/, "Format HH:MM requis").optional(), + fetchMode: z.enum(["interval", "scheduled"]).optional(), + autoFetchEnabled: z.boolean().optional(), + })) + .mutation(async ({ input }) => { + await saveRssSettings(input); + return { success: true }; + }), + }), +}); export type AppRouter = typeof appRouter; diff --git a/todo.md b/todo.md index 26de0a9..93b6190 100644 --- a/todo.md +++ b/todo.md @@ -63,4 +63,13 @@ - [x] Migration BDD recette : ajouter colonne username dans local_users et recréer compte adminItinova ## Bugs recette -- [ ] BUG: Déconnexion lors de l'import Excel sur le VPS — la session expire et redirige vers /login pendant l'upload +- [x] BUG: Déconnexion lors de l'import Excel sur le VPS — la session expire et redirige vers /login pendant l'upload +- [x] BUG: Déconnexion immédiate après sélection du fichier Excel à importer (avant même l'upload) + +## Flux RSS +- [x] Schéma BDD : tables rss_feeds et rss_settings +- [x] Helpers DB pour CRUD flux RSS et paramètres +- [x] Procédures tRPC : rss.list, rss.create, rss.update, rss.delete, rss.settings.get, rss.settings.save +- [x] Page RssFeeds.tsx : liste des flux, ajout/édition/suppression, config fréquence, règles d'automatisme +- [x] Navigation : ajouter l'entrée RSS dans le menu latéral (DashboardLayout) +- [ ] Déploiement VPS via Gitea CI/CD