diff --git a/client/public/__manus__/version.json b/client/public/__manus__/version.json index 73f0f37..f682767 100644 --- a/client/public/__manus__/version.json +++ b/client/public/__manus__/version.json @@ -1,4 +1,4 @@ { - "version": "69e54e0a", - "timestamp": 1781255928989 + "version": "8d20c2f9", + "timestamp": 1781593709171 } \ No newline at end of file diff --git a/client/src/components/AppLayout.tsx b/client/src/components/AppLayout.tsx index 26a8f45..111fe07 100644 --- a/client/src/components/AppLayout.tsx +++ b/client/src/components/AppLayout.tsx @@ -89,6 +89,12 @@ export function AppLayout({ children, user, onLogout }: AppLayoutProps) { const isAdmin = user?.role === "admin"; + // Compteurs non lus + const veilleUnreadQuery = trpc.veille.unreadCount.useQuery(); + const aapUnreadQuery = trpc.aap.unreadCount.useQuery(); + const veilleUnread = veilleUnreadQuery.data?.count ?? 0; + const aapUnread = aapUnreadQuery.data?.count ?? 0; + const importMutation = trpc.import.run.useMutation({ onSuccess: (data) => { const v = "veille" in data ? data.veille : null; @@ -174,6 +180,16 @@ export function AppLayout({ children, user, onLogout }: AppLayoutProps) { {item.badge} )} + {!collapsed && item.href === "/veille" && veilleUnread > 0 && ( + + {veilleUnread} + + )} + {!collapsed && item.href === "/aap" && aapUnread > 0 && ( + + {aapUnread} + + )} ); diff --git a/client/src/pages/AAPDashboard.tsx b/client/src/pages/AAPDashboard.tsx index 79cf569..9a4ebae 100644 --- a/client/src/pages/AAPDashboard.tsx +++ b/client/src/pages/AAPDashboard.tsx @@ -10,6 +10,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { LayoutGrid, List, + Eye, ExternalLink, Calendar, MapPin, @@ -53,6 +54,7 @@ interface AAPItem { iaCategorie: string | null; iaClassifiedBy: "ia" | "rules" | null; iaReason: string | null; + iaResume: string | null; } const CAT_COLORS: Record = { @@ -110,6 +112,20 @@ export default function AAPDashboard() { const { user } = useLocalAuth(); const isAdmin = user?.role === "admin"; const utils = trpc.useUtils(); + + // Marquage lu/non lu + const [readIds, setReadIds] = useState>(new Set()); + const markAsReadMutation = trpc.aap.markAsRead.useMutation({ + onSuccess: (_, vars) => { + setReadIds((prev) => { const next = new Set(prev); next.add(vars.articleId); return next; }); + }, + }); + const markAllAsReadMutation = trpc.aap.markAllAsRead.useMutation({ + onSuccess: () => { utils.aap.unreadCount.invalidate(); }, + }); + const unreadCountQuery = trpc.aap.unreadCount.useQuery(); + const unreadCount = unreadCountQuery.data?.count ?? 0; + const purgeMutation = trpc.aap.purge.useMutation({ onSuccess: (data) => { toast.success(`Purge effectuée — ${data.deleted} entrée(s) supprimée(s)`); @@ -175,12 +191,20 @@ export default function AAPDashboard() {

Appels à Projets

+ {unreadCount > 0 && ( + {unreadCount} + )}

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

+ {unreadCount > 0 && ( + + )} @@ -256,9 +280,9 @@ export default function AAPDashboard() {

Modifiez vos filtres ou importez des données

) : viewMode === "list" ? ( - + markAsReadMutation.mutate({ articleId: id })} /> ) : ( - + markAsReadMutation.mutate({ articleId: id })} /> )} {/* Pagination */} @@ -279,7 +303,7 @@ export default function AAPDashboard() { // ─── Vue Liste ──────────────────────────────────────────────────────────────── -function AAPListView({ items }: { items: AAPItem[] }) { +function AAPListView({ items, readIds, onMarkRead }: { items: AAPItem[]; readIds: Set; onMarkRead: (id: number) => void }) { return (
@@ -298,10 +322,18 @@ function AAPListView({ items }: { items: AAPItem[] }) { {items.map((item, idx) => ( - + {idx + 1} -

{item.titre}

+
+ {!readIds.has(item.id) && ( + + )} +
+

{item.titre}

+ {item.iaResume &&

{item.iaResume}

} +
+
@@ -313,11 +345,18 @@ function AAPListView({ items }: { items: AAPItem[] }) { {formatDate(item.datePublication) || "—"} - {item.lien && ( - - - - )} +
+ {!readIds.has(item.id) && ( + + )} + {item.lien && ( + + + + )} +
))} @@ -330,11 +369,11 @@ function AAPListView({ items }: { items: AAPItem[] }) { // ─── Vue Vignettes ──────────────────────────────────────────────────────────── -function AAPGridView({ items }: { items: AAPItem[] }) { +function AAPGridView({ items, readIds, onMarkRead }: { items: AAPItem[]; readIds: Set; onMarkRead: (id: number) => void }) { return (
{items.map((item) => ( - +
@@ -346,7 +385,10 @@ function AAPGridView({ items }: { items: AAPItem[] }) { )}
-

{item.titre}

+
+ {!readIds.has(item.id) && } +

{item.titre}

+
diff --git a/client/src/pages/VeilleDashboard.tsx b/client/src/pages/VeilleDashboard.tsx index 6cef061..fa935ca 100644 --- a/client/src/pages/VeilleDashboard.tsx +++ b/client/src/pages/VeilleDashboard.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from "react"; +import { useState, useMemo, useEffect } from "react"; import { useLocalAuth } from "@/contexts/LocalAuthContext"; import { toast } from "sonner"; import { trpc } from "@/lib/trpc"; @@ -65,6 +65,7 @@ interface VeilleItem { iaCategorie: string | null; iaClassifiedBy: "ia" | "rules" | null; iaReason: string | null; + iaResume: string | null; } const TYPE_LABELS: Record = { @@ -197,15 +198,18 @@ function VeilleDetailDialog({ )}
- {/* Résumé complet */} - {item.resume && ( + {/* Résumé IA ou résumé brut */} + {(item.iaResume || item.resume) && (

Résumé

+ {item.iaResume && ( + Généré par l'IA + )}
-

{item.resume}

+

{item.iaResume || item.resume}

)} @@ -237,6 +241,22 @@ export default function VeilleDashboard() { const { user } = useLocalAuth(); const isAdmin = user?.role === "admin"; const utils = trpc.useUtils(); + + // Marquage lu/non lu + const [readIds, setReadIds] = useState>(new Set()); + const markAsReadMutation = trpc.veille.markAsRead.useMutation({ + onSuccess: (_, vars) => { + setReadIds((prev) => { const next = new Set(prev); next.add(vars.articleId); return next; }); + }, + }); + const markAllAsReadMutation = trpc.veille.markAllAsRead.useMutation({ + onSuccess: () => { + utils.veille.unreadCount.invalidate(); + }, + }); + const unreadCountQuery = trpc.veille.unreadCount.useQuery(); + const unreadCount = unreadCountQuery.data?.count ?? 0; + const purgeMutation = trpc.veille.purge.useMutation({ onSuccess: (data) => { toast.success(`Purge effectuée — ${data.deleted} entrée(s) supprimée(s)`); @@ -283,6 +303,10 @@ export default function VeilleDashboard() { const openDetail = (item: VeilleItem) => { setSelectedItem(item); setDialogOpen(true); + // Marquer comme lu automatiquement à l'ouverture du détail + if (!readIds.has(item.id)) { + markAsReadMutation.mutate({ articleId: item.id }); + } }; const items = (itemsQuery.data?.items ?? []) as VeilleItem[]; @@ -305,12 +329,21 @@ export default function VeilleDashboard() {

Veille Stratégique

+ {unreadCount > 0 && ( + {unreadCount} + )}

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

+ {unreadCount > 0 && ( + + )} @@ -387,9 +420,9 @@ export default function VeilleDashboard() {

Modifiez vos filtres ou importez des données

) : viewMode === "list" ? ( - + ) : ( - + )} {/* Pagination */} @@ -417,7 +450,7 @@ export default function VeilleDashboard() { // ─── Vue Liste ──────────────────────────────────────────────────────────────── -function VeilleListView({ items, onDetail }: { items: VeilleItem[]; onDetail: (item: VeilleItem) => void }) { +function VeilleListView({ items, onDetail, readIds }: { items: VeilleItem[]; onDetail: (item: VeilleItem) => void; readIds: Set }) { return (
@@ -436,12 +469,17 @@ function VeilleListView({ items, onDetail }: { items: VeilleItem[]; onDetail: (i {items.map((item, idx) => ( - + {idx + 1} -
-

{item.titre}

- {item.resume &&

{item.resume}

} +
+ {!readIds.has(item.id) && ( + + )} +
+

{item.titre}

+ {(item.iaResume || item.resume) &&

{item.iaResume || item.resume}

} +
@@ -490,11 +528,11 @@ function VeilleListView({ items, onDetail }: { items: VeilleItem[]; onDetail: (i // ─── Vue Vignettes ──────────────────────────────────────────────────────────── -function VeilleGridView({ items, onDetail }: { items: VeilleItem[]; onDetail: (item: VeilleItem) => void }) { +function VeilleGridView({ items, onDetail, readIds }: { items: VeilleItem[]; onDetail: (item: VeilleItem) => void; readIds: Set }) { return (
{items.map((item) => ( - +
@@ -516,10 +554,13 @@ function VeilleGridView({ items, onDetail }: { items: VeilleItem[]; onDetail: (i )}
-

{item.titre}

+
+ {!readIds.has(item.id) && } +

{item.titre}

+
- {item.resume &&

{item.resume}

} + {(item.iaResume || item.resume) &&

{item.iaResume || item.resume}

}
{item.categorie && {item.categorie}} {item.territoire && ( diff --git a/drizzle/0008_worried_molten_man.sql b/drizzle/0008_worried_molten_man.sql new file mode 100644 index 0000000..50f2780 --- /dev/null +++ b/drizzle/0008_worried_molten_man.sql @@ -0,0 +1,11 @@ +CREATE TABLE `article_reads` ( + `id` int AUTO_INCREMENT NOT NULL, + `userId` int NOT NULL, + `articleType` enum('veille','aap') NOT NULL, + `articleId` int NOT NULL, + `readAt` timestamp NOT NULL DEFAULT (now()), + CONSTRAINT `article_reads_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +ALTER TABLE `aap_items` ADD `iaResume` text;--> statement-breakpoint +ALTER TABLE `veille_items` ADD `iaResume` text; \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..a9d1484 --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,985 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "88852e92-af9f-4778-8015-3c6318f59cb4", + "prevId": "7dc085c2-224c-40d4-817b-3fa3190acef0", + "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 + }, + "departements": { + "name": "departements", + "type": "text", + "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())" + }, + "iaRelevant": { + "name": "iaRelevant", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iaCategorie": { + "name": "iaCategorie", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iaClassifiedBy": { + "name": "iaClassifiedBy", + "type": "enum('ia','rules')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iaReason": { + "name": "iaReason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iaResume": { + "name": "iaResume", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "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": {} + }, + "article_reads": { + "name": "article_reads", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "articleType": { + "name": "articleType", + "type": "enum('veille','aap')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "articleId": { + "name": "articleId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "readAt": { + "name": "readAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "article_reads_id": { + "name": "article_reads_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "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 + }, + "territoires": { + "name": "territoires", + "type": "text", + "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())" + }, + "iaRelevant": { + "name": "iaRelevant", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iaCategorie": { + "name": "iaCategorie", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iaClassifiedBy": { + "name": "iaClassifiedBy", + "type": "enum('ia','rules')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iaReason": { + "name": "iaReason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iaResume": { + "name": "iaResume", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "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 edbda81..f37c075 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1780478622510, "tag": "0007_clammy_blue_marvel", "breakpoints": true + }, + { + "idx": 8, + "version": "5", + "when": 1781593251405, + "tag": "0008_worried_molten_man", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 12f3048..08930f5 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -82,6 +82,8 @@ export const veilleItems = mysqlTable("veille_items", { iaCategorie: varchar("iaCategorie", { length: 128 }), iaClassifiedBy: mysqlEnum("iaClassifiedBy", ["ia", "rules"]), iaReason: text("iaReason"), + // Résumé généré par l'IA + iaResume: text("iaResume"), }); export type VeilleItem = typeof veilleItems.$inferSelect; @@ -107,6 +109,8 @@ export const aapItems = mysqlTable("aap_items", { iaCategorie: varchar("iaCategorie", { length: 128 }), iaClassifiedBy: mysqlEnum("iaClassifiedBy", ["ia", "rules"]), iaReason: text("iaReason"), + // Résumé généré par l'IA + iaResume: text("iaResume"), }); export type AapItem = typeof aapItems.$inferSelect; @@ -196,3 +200,16 @@ export const rssSettings = mysqlTable("rss_settings", { export type RssSettings = typeof rssSettings.$inferSelect; export type InsertRssSettings = typeof rssSettings.$inferInsert; + +// ─── Suivi de lecture des articles ────────────────────────────────────────── + +export const articleReads = mysqlTable("article_reads", { + id: int("id").autoincrement().primaryKey(), + userId: int("userId").notNull(), + articleType: mysqlEnum("articleType", ["veille", "aap"]).notNull(), + articleId: int("articleId").notNull(), + readAt: timestamp("readAt").defaultNow().notNull(), +}); + +export type ArticleRead = typeof articleReads.$inferSelect; +export type InsertArticleRead = typeof articleReads.$inferInsert; diff --git a/server/aiClassifier.ts b/server/aiClassifier.ts index 7d90982..3f1a2ab 100644 --- a/server/aiClassifier.ts +++ b/server/aiClassifier.ts @@ -202,6 +202,49 @@ async function classifyCategory( } } +// ─── Prompt 3 : Génération de résumé IA ────────────────────────────────────────── + +const PROMPT_RESUME = `Tu es un expert des établissements et services sociaux et médico-sociaux (ESMS). +Tu dois rédiger un résumé clair et professionnel de l'article fourni, destiné aux professionnels du secteur médico-social. + +Consignes : +- Rédige 3 à 5 phrases en français, en langage clair et accessible +- Mets en avant les points clés : qui est concerné, quelle mesure ou information, quel impact pour les structures +- Reste factuel et objectif, sans jugement de valeur +- N'utilise pas de listes à puces, écris en prose +- Ne commence pas par "Cet article" ou "Le texte" +- Adapte le vocabulaire au secteur médico-social (ESMS, ARS, MDPH, etc.) + +Réponds UNIQUEMENT avec le texte du résumé, sans introduction ni conclusion.`; + +/** + * Génère un résumé IA de 3-5 phrases pour un article pertinent. + * Retourne null en cas d'échec (le résumé brut RSS sera utilisé à la place). + */ +export async function generateSummary( + titre: string, + resume: string +): Promise { + const userContent = `Titre : ${titre}\n\nContenu : ${resume}`; + + try { + const response = await invokeLLM({ + messages: [ + { role: "system", content: PROMPT_RESUME }, + { role: "user", content: userContent }, + ], + }); + + const rawContent = response?.choices?.[0]?.message?.content; + if (!rawContent) return null; + const text = typeof rawContent === "string" ? rawContent.trim() : null; + return text && text.length > 20 ? text : null; + } catch (e) { + console.error("[AI Classifier] Erreur génération résumé:", (e as Error).message); + return null; + } +} + // ─── Point d'entrée principal ───────────────────────────────────────────────── /** diff --git a/server/routers.ts b/server/routers.ts index e35eeb2..ff47864 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -37,8 +37,8 @@ import { scheduleRssFetch } from "./_core/index"; import { loginLocalUser, hashPassword, ensureAdminExists } from "./localAuth"; import { classifyArticle } from "./aiClassifier"; import { getDb } from "./db"; -import { veilleItems, aapItems } from "../drizzle/schema"; -import { isNull, or, eq as eqDrizzle } from "drizzle-orm"; +import { veilleItems, aapItems, articleReads } from "../drizzle/schema"; +import { isNull, or, eq as eqDrizzle, and, inArray, count } from "drizzle-orm"; // ─── Middleware admin ───────────────────────────────────────────────────────── @@ -152,6 +152,57 @@ export const appRouter = router({ return { processed, errors, total: rows.length }; }), + + // ─── Marquage lu/non lu ────────────────────────────────────────────────────────────────────── + markAsRead: protectedProcedure + .input(z.object({ articleId: z.number().int().positive() })) + .mutation(async ({ input, ctx }) => { + const db = await getDb(); + if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" }); + // Insérer seulement si pas déjà lu (ignore le doublon) + try { + await db.insert(articleReads).values({ + userId: ctx.user.id, + articleType: "veille", + articleId: input.articleId, + }); + } catch { /* doublon = déjà lu, on ignore */ } + return { success: true }; + }), + + markAllAsRead: protectedProcedure.mutation(async ({ ctx }) => { + const db = await getDb(); + if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" }); + // Récupérer tous les IDs veille + const allItems = await db.select({ id: veilleItems.id }).from(veilleItems); + const allIds = allItems.map((r: { id: number }) => r.id); + // Trouver ceux déjà lus + const alreadyRead = await db + .select({ articleId: articleReads.articleId }) + .from(articleReads) + .where(and(eqDrizzle(articleReads.userId, ctx.user.id), eqDrizzle(articleReads.articleType, "veille"))); + const alreadyReadIds = new Set(alreadyRead.map((r: { articleId: number }) => r.articleId)); + const toInsert = allIds.filter((id: number) => !alreadyReadIds.has(id)).map((id: number) => ({ + userId: ctx.user.id, articleType: "veille" as const, articleId: id, + })); + if (toInsert.length > 0) { + await db.insert(articleReads).values(toInsert); + } + return { success: true, marked: toInsert.length }; + }), + + unreadCount: protectedProcedure.query(async ({ ctx }) => { + const db = await getDb(); + if (!db) return { count: 0 }; + const totalRows = await db.select({ cnt: count() }).from(veilleItems); + const total = totalRows[0]?.cnt ?? 0; + const readRows = await db + .select({ cnt: count() }) + .from(articleReads) + .where(and(eqDrizzle(articleReads.userId, ctx.user.id), eqDrizzle(articleReads.articleType, "veille"))); + const read = readRows[0]?.cnt ?? 0; + return { count: Math.max(0, total - read) }; + }), }), // ─── AAPP ──────────────────────────────────────────────────────────────────── aap: router({ @@ -222,6 +273,54 @@ export const appRouter = router({ return { processed, errors, total: rows.length }; }), + + // ─── Marquage lu/non lu AAP ────────────────────────────────────────────────────────────────────── + markAsRead: protectedProcedure + .input(z.object({ articleId: z.number().int().positive() })) + .mutation(async ({ input, ctx }) => { + const db = await getDb(); + if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" }); + try { + await db.insert(articleReads).values({ + userId: ctx.user.id, + articleType: "aap", + articleId: input.articleId, + }); + } catch { /* doublon = déjà lu, on ignore */ } + return { success: true }; + }), + + markAllAsRead: protectedProcedure.mutation(async ({ ctx }) => { + const db = await getDb(); + if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" }); + const allItems = await db.select({ id: aapItems.id }).from(aapItems); + const allIds = allItems.map((r: { id: number }) => r.id); + const alreadyRead = await db + .select({ articleId: articleReads.articleId }) + .from(articleReads) + .where(and(eqDrizzle(articleReads.userId, ctx.user.id), eqDrizzle(articleReads.articleType, "aap"))); + const alreadyReadIds = new Set(alreadyRead.map((r: { articleId: number }) => r.articleId)); + const toInsert = allIds.filter((id: number) => !alreadyReadIds.has(id)).map((id: number) => ({ + userId: ctx.user.id, articleType: "aap" as const, articleId: id, + })); + if (toInsert.length > 0) { + await db.insert(articleReads).values(toInsert); + } + return { success: true, marked: toInsert.length }; + }), + + unreadCount: protectedProcedure.query(async ({ ctx }) => { + const db = await getDb(); + if (!db) return { count: 0 }; + const totalRows = await db.select({ cnt: count() }).from(aapItems); + const total = totalRows[0]?.cnt ?? 0; + const readRows = await db + .select({ cnt: count() }) + .from(articleReads) + .where(and(eqDrizzle(articleReads.userId, ctx.user.id), eqDrizzle(articleReads.articleType, "aap"))); + const read = readRows[0]?.cnt ?? 0; + return { count: Math.max(0, total - read) }; + }), }), // ─── Importt ───────────────────────────────────────────────────────────────── import: router({ diff --git a/server/rssEngine.ts b/server/rssEngine.ts index 445134e..5222929 100644 --- a/server/rssEngine.ts +++ b/server/rssEngine.ts @@ -22,7 +22,7 @@ import { type RssFeed, } from "../drizzle/schema"; import { eq, sql } from "drizzle-orm"; -import { classifyArticle } from "./aiClassifier"; +import { classifyArticle, generateSummary } from "./aiClassifier"; // ─── Types internes ─────────────────────────────────────────────────────────── @@ -371,6 +371,11 @@ async function processFeed(feed: RssFeed): Promise { const typeVeille = (aiResult.typeVeille ?? feed.defaultTypeVeille ?? "generale") as "reglementaire" | "concurrentielle" | "technologique" | "generale"; + // Générer le résumé IA uniquement pour les articles pertinents + const iaResume = aiResult.relevant + ? await generateSummary(title, description) + : null; + try { // Essayer d'insérer await db.insert(veilleItems).values({ @@ -389,6 +394,7 @@ async function processFeed(feed: RssFeed): Promise { iaCategorie: aiResult.categorie, iaClassifiedBy: aiResult.classifiedBy, iaReason: aiResult.reason, + iaResume: iaResume || null, }); result.newItems++; } catch (e: any) { @@ -434,6 +440,11 @@ async function processFeed(feed: RssFeed): Promise { const categorie = aiResult.categorie as "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre"; + // Générer le résumé IA uniquement pour les articles pertinents + const iaResume = aiResult.relevant + ? await generateSummary(title, description) + : null; + try { await db.insert(aapItems).values({ dedupKey, @@ -448,6 +459,7 @@ async function processFeed(feed: RssFeed): Promise { iaCategorie: aiResult.categorie, iaClassifiedBy: aiResult.classifiedBy, iaReason: aiResult.reason, + iaResume: iaResume || null, }); result.newItems++; } catch (e: any) { diff --git a/todo.md b/todo.md index 05842f6..c0dd761 100644 --- a/todo.md +++ b/todo.md @@ -109,7 +109,7 @@ - [x] VeilleDashboard.tsx : supprimer le badge IA violet dans la colonne Catégorie - [x] VeilleDashboard.tsx : supprimer le bouton "Classer avec l'IA" - [x] Tester visuellement en sandbox -- [ ] Déployer en recette +- [x] Déployer en recette ## Mise à jour AAPDashboard — Classification IA - [x] Vérifier que aap.list retourne bien les champs IA (iaRelevant, iaCategorie, iaClassifiedBy, iaReason) @@ -117,4 +117,20 @@ - [x] Afficher la catégorie IA dans la colonne Catégorie d'AAPDashboard (même logique que VeilleDashboard) - [x] Vérifier que rssEngine.ts classe bien les AAP via l'IA à l'insertion - [x] Tester visuellement en sandbox -- [ ] Déployer en recette +- [x] Déployer en recette + +## Résumé IA automatique + Marquage Lu/Non lu + +- [ ] Schéma BDD : ajouter iaResume (text) dans veille_items et aap_items +- [ ] Schéma BDD : créer table article_reads (id, userId, articleType, articleId, readAt) +- [ ] Migration pnpm db:push +- [ ] aiClassifier.ts : ajouter generateSummary() — résumé 3-5 lignes en français adapté au secteur médico-social +- [ ] rssEngine.ts : appeler generateSummary() après classifyArticle() pour chaque article pertinent +- [ ] Procédures tRPC : veille.markAsRead, veille.unreadCount +- [ ] Procédures tRPC : aap.markAsRead, aap.unreadCount +- [ ] VeilleDashboard : afficher iaResume dans la boîte de dialogue détail (à la place du résumé brut si disponible) +- [ ] VeilleDashboard : point bleu sur les articles non lus, clic sur article = markAsRead automatique +- [ ] AAPDashboard : même logique lu/non lu +- [ ] Sidebar DashboardLayout : compteur non lus sur les entrées Veille et AAP +- [ ] Tests visuels en sandbox +- [ ] Checkpoint