Checkpoint: Implémentation du résumé automatique généré par l'IA pour chaque article RSS (veille et AAP), marquage lu/non lu avec point bleu et fond teinté, bouton "Tout marquer comme lu", compteurs non lus dans la sidebar et dans les titres de page. Migration BDD : table article_reads + colonne iaResume dans veille_items et aap_items.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "69e54e0a",
|
||||
"timestamp": 1781255928989
|
||||
"version": "8d20c2f9",
|
||||
"timestamp": 1781593709171
|
||||
}
|
||||
@@ -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}
|
||||
</Badge>
|
||||
)}
|
||||
{!collapsed && item.href === "/veille" && veilleUnread > 0 && (
|
||||
<span className="ml-auto inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-primary text-primary-foreground text-[10px] font-bold">
|
||||
{veilleUnread}
|
||||
</span>
|
||||
)}
|
||||
{!collapsed && item.href === "/aap" && aapUnread > 0 && (
|
||||
<span className="ml-auto inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-primary text-primary-foreground text-[10px] font-bold">
|
||||
{aapUnread}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -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<string, string> = {
|
||||
@@ -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<Set<number>>(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() {
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Target size={22} className="text-primary" />
|
||||
<h1 className="text-2xl font-bold text-foreground">Appels à Projets</h1>
|
||||
{unreadCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full bg-primary text-primary-foreground text-[11px] font-bold">{unreadCount}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Handicap, Personnes Âgées, Enfance, Précarité, Sanitaire et Autre
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{unreadCount > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={() => markAllAsReadMutation.mutate()} disabled={markAllAsReadMutation.isPending} className="gap-2 text-muted-foreground">
|
||||
Tout marquer comme lu
|
||||
</Button>
|
||||
)}
|
||||
<Button variant={viewMode === "list" ? "default" : "outline"} size="sm" onClick={() => setViewMode("list")} className="gap-2">
|
||||
<List size={15} />Liste
|
||||
</Button>
|
||||
@@ -256,9 +280,9 @@ export default function AAPDashboard() {
|
||||
<p className="text-muted-foreground/60 text-sm mt-1">Modifiez vos filtres ou importez des données</p>
|
||||
</div>
|
||||
) : viewMode === "list" ? (
|
||||
<AAPListView items={items} />
|
||||
<AAPListView items={items} readIds={readIds} onMarkRead={(id) => markAsReadMutation.mutate({ articleId: id })} />
|
||||
) : (
|
||||
<AAPGridView items={items} />
|
||||
<AAPGridView items={items} readIds={readIds} onMarkRead={(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<number>; onMarkRead: (id: number) => void }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border overflow-hidden shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
@@ -298,10 +322,18 @@ function AAPListView({ items }: { items: AAPItem[] }) {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{items.map((item, idx) => (
|
||||
<tr key={item.id} className={cn("hover:bg-muted/30 transition-colors border-l-4", CAT_ACCENT[item.iaCategorie || item.categorie] || "border-l-transparent")}>
|
||||
<tr key={item.id} className={cn("hover:bg-muted/30 transition-colors border-l-4", CAT_ACCENT[item.iaCategorie || item.categorie] || "border-l-transparent", !readIds.has(item.id) && "bg-blue-50/30")}>
|
||||
<td className="px-4 py-3 text-muted-foreground/50 text-xs">{idx + 1}</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium text-foreground line-clamp-2 max-w-sm leading-snug">{item.titre}</p>
|
||||
<div className="flex items-start gap-2 max-w-sm">
|
||||
{!readIds.has(item.id) && (
|
||||
<span className="mt-1.5 w-2 h-2 rounded-full bg-primary flex-shrink-0" title="Non lu" />
|
||||
)}
|
||||
<div>
|
||||
<p className={cn("font-medium line-clamp-2 leading-snug", readIds.has(item.id) ? "text-muted-foreground" : "text-foreground")}>{item.titre}</p>
|
||||
{item.iaResume && <p className="text-xs text-muted-foreground mt-1 line-clamp-2">{item.iaResume}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline" className={cn("text-xs", CAT_COLORS[item.iaCategorie || item.categorie])}>
|
||||
@@ -313,11 +345,18 @@ function AAPListView({ items }: { items: AAPItem[] }) {
|
||||
<td className="px-4 py-3 text-muted-foreground text-xs whitespace-nowrap">{formatDate(item.datePublication) || "—"}</td>
|
||||
<td className="px-4 py-3"><ClotureStatus date={item.dateCloture} /></td>
|
||||
<td className="px-4 py-3">
|
||||
{item.lien && (
|
||||
<a href={item.lien} target="_blank" rel="noopener noreferrer" className="text-accent hover:text-accent/80 transition-colors">
|
||||
<ExternalLink size={15} />
|
||||
</a>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!readIds.has(item.id) && (
|
||||
<button onClick={() => onMarkRead(item.id)} className="text-muted-foreground hover:text-primary transition-colors" title="Marquer comme lu">
|
||||
<Eye size={13} />
|
||||
</button>
|
||||
)}
|
||||
{item.lien && (
|
||||
<a href={item.lien} target="_blank" rel="noopener noreferrer" className="text-accent hover:text-accent/80 transition-colors">
|
||||
<ExternalLink size={15} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -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<number>; onMarkRead: (id: number) => void }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{items.map((item) => (
|
||||
<Card key={item.id} className={cn("group hover:shadow-md transition-all duration-200 border-border overflow-hidden border-l-4", CAT_ACCENT[item.iaCategorie || item.categorie] || "")}>
|
||||
<Card key={item.id} className={cn("group hover:shadow-md transition-all duration-200 border-border overflow-hidden border-l-4", CAT_ACCENT[item.iaCategorie || item.categorie] || "", !readIds.has(item.id) && "ring-1 ring-primary/20")}>
|
||||
<CardHeader className="pb-2 pt-4 px-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<Badge variant="outline" className={cn("text-xs flex-shrink-0", CAT_COLORS[item.iaCategorie || item.categorie])}>
|
||||
@@ -346,7 +385,10 @@ function AAPGridView({ items }: { items: AAPItem[] }) {
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-semibold text-sm text-foreground leading-snug line-clamp-3 mt-2">{item.titre}</h3>
|
||||
<div className="flex items-start gap-1.5 mt-2">
|
||||
{!readIds.has(item.id) && <span className="mt-1 w-2 h-2 rounded-full bg-primary flex-shrink-0" title="Non lu" />}
|
||||
<h3 className={cn("font-semibold text-sm leading-snug line-clamp-3", readIds.has(item.id) ? "text-muted-foreground" : "text-foreground")}>{item.titre}</h3>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4 space-y-2">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
|
||||
@@ -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<TypeVeille, string> = {
|
||||
@@ -197,15 +198,18 @@ function VeilleDetailDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Résumé complet */}
|
||||
{item.resume && (
|
||||
{/* Résumé IA ou résumé brut */}
|
||||
{(item.iaResume || item.resume) && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen size={14} className="text-primary" />
|
||||
<h3 className="text-sm font-semibold text-foreground">Résumé</h3>
|
||||
{item.iaResume && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-50 text-violet-600 border border-violet-200 font-medium">Généré par l'IA</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-muted/20 border border-border/50">
|
||||
<p className="text-sm text-foreground leading-relaxed whitespace-pre-wrap">{item.resume}</p>
|
||||
<p className="text-sm text-foreground leading-relaxed whitespace-pre-wrap">{item.iaResume || item.resume}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -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<Set<number>>(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() {
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<FileSearch size={22} className="text-primary" />
|
||||
<h1 className="text-2xl font-bold text-foreground">Veille Stratégique</h1>
|
||||
{unreadCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full bg-primary text-primary-foreground text-[11px] font-bold">{unreadCount}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Suivi réglementaire, concurrentiel, technologique et général
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{unreadCount > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={() => markAllAsReadMutation.mutate()} disabled={markAllAsReadMutation.isPending} className="gap-2 text-muted-foreground">
|
||||
<Eye size={15} />
|
||||
Tout marquer comme lu
|
||||
</Button>
|
||||
)}
|
||||
<Button variant={viewMode === "list" ? "default" : "outline"} size="sm" onClick={() => setViewMode("list")} className="gap-2">
|
||||
<List size={15} />Liste
|
||||
</Button>
|
||||
@@ -387,9 +420,9 @@ export default function VeilleDashboard() {
|
||||
<p className="text-muted-foreground/60 text-sm mt-1">Modifiez vos filtres ou importez des données</p>
|
||||
</div>
|
||||
) : viewMode === "list" ? (
|
||||
<VeilleListView items={items} onDetail={openDetail} />
|
||||
<VeilleListView items={items} onDetail={openDetail} readIds={readIds} />
|
||||
) : (
|
||||
<VeilleGridView items={items} onDetail={openDetail} />
|
||||
<VeilleGridView items={items} onDetail={openDetail} readIds={readIds} />
|
||||
)}
|
||||
|
||||
{/* 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<number> }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border overflow-hidden shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
@@ -436,12 +469,17 @@ function VeilleListView({ items, onDetail }: { items: VeilleItem[]; onDetail: (i
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{items.map((item, idx) => (
|
||||
<tr key={item.id} className={cn("hover:bg-muted/30 transition-colors border-l-4", TYPE_ACCENT[item.typeVeille] || "border-l-transparent")}>
|
||||
<tr key={item.id} className={cn("hover:bg-muted/30 transition-colors border-l-4", TYPE_ACCENT[item.typeVeille] || "border-l-transparent", !readIds.has(item.id) && "bg-blue-50/30")}>
|
||||
<td className="px-4 py-3 text-muted-foreground/50 text-xs">{idx + 1}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="max-w-md">
|
||||
<p className="font-medium text-foreground line-clamp-2 leading-snug">{item.titre}</p>
|
||||
{item.resume && <p className="text-xs text-muted-foreground mt-1 line-clamp-2">{item.resume}</p>}
|
||||
<div className="max-w-md flex items-start gap-2">
|
||||
{!readIds.has(item.id) && (
|
||||
<span className="mt-1.5 w-2 h-2 rounded-full bg-primary flex-shrink-0" title="Non lu" />
|
||||
)}
|
||||
<div>
|
||||
<p className={cn("font-medium line-clamp-2 leading-snug", readIds.has(item.id) ? "text-muted-foreground" : "text-foreground")}>{item.titre}</p>
|
||||
{(item.iaResume || item.resume) && <p className="text-xs text-muted-foreground mt-1 line-clamp-2">{item.iaResume || item.resume}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
@@ -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<number> }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{items.map((item) => (
|
||||
<Card key={item.id} className={cn("group hover:shadow-md transition-all duration-200 border-border overflow-hidden border-l-4", TYPE_ACCENT[item.typeVeille] || "")}>
|
||||
<Card key={item.id} className={cn("group hover:shadow-md transition-all duration-200 border-border overflow-hidden border-l-4", TYPE_ACCENT[item.typeVeille] || "", !readIds.has(item.id) && "ring-1 ring-primary/20")}>
|
||||
<CardHeader className="pb-2 pt-4 px-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<Badge variant="outline" className={cn("text-xs flex-shrink-0", TYPE_COLORS[item.typeVeille])}>
|
||||
@@ -516,10 +554,13 @@ function VeilleGridView({ items, onDetail }: { items: VeilleItem[]; onDetail: (i
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-semibold text-sm text-foreground leading-snug line-clamp-3 mt-2">{item.titre}</h3>
|
||||
<div className="flex items-start gap-1.5 mt-2">
|
||||
{!readIds.has(item.id) && <span className="mt-1 w-2 h-2 rounded-full bg-primary flex-shrink-0" title="Non lu" />}
|
||||
<h3 className={cn("font-semibold text-sm leading-snug line-clamp-3", readIds.has(item.id) ? "text-muted-foreground" : "text-foreground")}>{item.titre}</h3>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4 space-y-2">
|
||||
{item.resume && <p className="text-xs text-muted-foreground line-clamp-3 leading-relaxed">{item.resume}</p>}
|
||||
{(item.iaResume || item.resume) && <p className="text-xs text-muted-foreground line-clamp-3 leading-relaxed">{item.iaResume || item.resume}</p>}
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{item.categorie && <span className="inline-flex items-center gap-1 text-xs text-muted-foreground"><Tag size={10} />{item.categorie}</span>}
|
||||
{item.territoire && (
|
||||
|
||||
11
drizzle/0008_worried_molten_man.sql
Normal file
11
drizzle/0008_worried_molten_man.sql
Normal file
@@ -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;
|
||||
985
drizzle/meta/0008_snapshot.json
Normal file
985
drizzle/meta/0008_snapshot.json
Normal file
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string | null> {
|
||||
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 ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<FetchResult> {
|
||||
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<FetchResult> {
|
||||
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<FetchResult> {
|
||||
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<FetchResult> {
|
||||
iaCategorie: aiResult.categorie,
|
||||
iaClassifiedBy: aiResult.classifiedBy,
|
||||
iaReason: aiResult.reason,
|
||||
iaResume: iaResume || null,
|
||||
});
|
||||
result.newItems++;
|
||||
} catch (e: any) {
|
||||
|
||||
20
todo.md
20
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
|
||||
|
||||
Reference in New Issue
Block a user