fix(audit): nettoyage accès, garde-fous build, unification quotas
Semaine 1 — fuites et accès : - /archive rendu accessible via sidebar (était invisible) - Suppression pages orphelines : /chat, /graph, /support, test-title-suggestions - Fixes i18n : search-modal (clé non interpolée), sidebar tooltips, export PDF - 15 locales mises à jour Semaine 2 — garde-fous : - 8 erreurs TS fixees → ignoreBuildErrors: false (build type-safe) - reactStrictMode: true (dev-only, zero impact prod) - reserveUsageOrThrow → wrapper deprecie vers reserveAiUsageOrThrow - auth-guard.ts : requireAuthOrResponse() + requireAdminOrResponse() Tests 212/212 | Lint 0 erreur | Build OK | tsc 0 erreur
This commit is contained in:
@@ -19,6 +19,7 @@ export async function POST(request: NextRequest) {
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
const userId = session.user.id
|
||||
|
||||
// GDPR AI Consent check
|
||||
if (!(await hasUserAiConsent())) {
|
||||
@@ -61,12 +62,12 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const suggestions = await withAiQuota(
|
||||
session.user.id,
|
||||
userId,
|
||||
'auto_tag',
|
||||
() =>
|
||||
autoLabelCreationService.suggestLabels(
|
||||
notebookId,
|
||||
session.user!.id,
|
||||
userId,
|
||||
language,
|
||||
),
|
||||
{ lane: 'tags' },
|
||||
|
||||
@@ -10,6 +10,7 @@ export async function POST(req: NextRequest) {
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = session.user.id
|
||||
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
||||
@@ -32,11 +33,11 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const suggestedNotebook = await withAiQuota(
|
||||
session.user.id,
|
||||
userId,
|
||||
'auto_tag',
|
||||
() => notebookSuggestionService.suggestNotebook(
|
||||
noteContent,
|
||||
session.user!.id,
|
||||
userId,
|
||||
language
|
||||
),
|
||||
{ lane: 'tags' },
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
// ── Stopwords FR + EN ─────────────────────────────────────────────────────────
|
||||
const STOPWORDS = new Set([
|
||||
'le','la','les','de','du','des','un','une','et','en','au','aux','ce','se',
|
||||
'sa','son','ses','mon','ma','mes','ton','ta','tes','que','qui','quoi','dont',
|
||||
'il','elle','ils','elles','nous','vous','je','tu','on','par','pour','sur',
|
||||
'sous','avec','dans','est','sont','pas','ne','plus','très','tout','comme',
|
||||
'mais','donc','car','cet','cette','ces','leur','leurs','note','notes',
|
||||
'the','a','an','and','or','but','in','on','at','to','for','of','with','by',
|
||||
'from','is','are','was','were','be','been','have','has','had','do','does',
|
||||
'did','will','would','could','should','may','might','this','that','these',
|
||||
'those','it','its','they','them','their','he','she','we','you','not','no',
|
||||
'so','if','as','up','out','about','also','just','can','all','any','get',
|
||||
])
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]+>/g, ' ').replace(/&\w+;/g, ' ')
|
||||
}
|
||||
|
||||
function extractKeywords(text: string): Set<string> {
|
||||
return new Set(
|
||||
stripHtml(text)
|
||||
.toLowerCase()
|
||||
.split(/[\s\p{P}]+/u)
|
||||
.filter(w => w.length >= 3 && !STOPWORDS.has(w) && !/^\d+$/.test(w))
|
||||
)
|
||||
}
|
||||
|
||||
function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
|
||||
if (a.size === 0 || b.size === 0) return 0
|
||||
let intersection = 0
|
||||
for (const w of a) if (b.has(w)) intersection++
|
||||
return intersection / (a.size + b.size - intersection)
|
||||
}
|
||||
|
||||
type EdgeType = 'title_mention' | 'shared_label' | 'jaccard' | 'explicit_link' | 'semantic_echo'
|
||||
|
||||
interface GraphEdge { source: string; target: string; weight: number; type: EdgeType }
|
||||
|
||||
// GET /api/graph — connexions automatiques à 3 niveaux
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const userId = session.user.id
|
||||
const { searchParams } = new URL(request.url)
|
||||
const notebookId = searchParams.get('notebookId') || undefined
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { userId, trashedAt: null, ...(notebookId ? { notebookId } : {}) },
|
||||
select: {
|
||||
id: true, title: true, content: true, notebookId: true, createdAt: true,
|
||||
labelRelations: { select: { id: true } },
|
||||
notebook: { select: { id: true, name: true } },
|
||||
},
|
||||
take: 500,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
|
||||
if (notes.length === 0) return NextResponse.json({ nodes: [], edges: [] })
|
||||
|
||||
const ids = notes.map(n => n.id)
|
||||
|
||||
// Query NoteLink manually created relationships
|
||||
const noteLinks = await (prisma as any).noteLink.findMany({
|
||||
where: {
|
||||
sourceNoteId: { in: ids },
|
||||
targetNoteId: { in: ids }
|
||||
},
|
||||
select: {
|
||||
sourceNoteId: true,
|
||||
targetNoteId: true,
|
||||
contextSnippet: true
|
||||
}
|
||||
})
|
||||
|
||||
// Query MemoryEchoInsight semantic relationships
|
||||
const echoInsights = await (prisma as any).memoryEchoInsight.findMany({
|
||||
where: {
|
||||
userId,
|
||||
dismissed: false,
|
||||
note1Id: { in: ids },
|
||||
note2Id: { in: ids }
|
||||
},
|
||||
select: {
|
||||
note1Id: true,
|
||||
note2Id: true,
|
||||
similarityScore: true,
|
||||
insight: true
|
||||
}
|
||||
})
|
||||
|
||||
// Pré-calcul
|
||||
const keywordsMap = new Map<string, Set<string>>()
|
||||
const labelMap = new Map<string, Set<string>>()
|
||||
for (const note of notes) {
|
||||
keywordsMap.set(note.id, extractKeywords(`${note.title ?? ''} ${note.content}`))
|
||||
labelMap.set(note.id, new Set(note.labelRelations.map((l: any) => l.id)))
|
||||
}
|
||||
|
||||
const EDGE_TYPE_PRIORITY: Record<EdgeType, number> = {
|
||||
explicit_link: 5,
|
||||
semantic_echo: 4,
|
||||
title_mention: 3,
|
||||
shared_label: 2,
|
||||
jaccard: 1,
|
||||
}
|
||||
|
||||
const edgeMap = new Map<string, GraphEdge>()
|
||||
function upsertEdge(a: string, b: string, weight: number, type: EdgeType) {
|
||||
const key = a < b ? `${a}--${b}` : `${b}--${a}`
|
||||
const ex = edgeMap.get(key)
|
||||
if (!ex) {
|
||||
edgeMap.set(key, { source: a < b ? a : b, target: a < b ? b : a, weight, type })
|
||||
} else {
|
||||
const exPriority = EDGE_TYPE_PRIORITY[ex.type] || 0
|
||||
const curPriority = EDGE_TYPE_PRIORITY[type] || 0
|
||||
if (curPriority > exPriority || (curPriority === exPriority && weight > ex.weight)) {
|
||||
edgeMap.set(key, { source: a < b ? a : b, target: a < b ? b : a, weight, type })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Niveau 1 : Title Mention (comme Obsidian "unlinked mentions") ──────────
|
||||
for (const noteA of notes) {
|
||||
const title = (noteA.title ?? '').trim().toLowerCase()
|
||||
if (title.length < 3) continue
|
||||
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const re = new RegExp(`(?<!\\p{L})${escaped}(?!\\p{L})`, 'ui')
|
||||
for (const noteB of notes) {
|
||||
if (noteA.id === noteB.id) continue
|
||||
if (re.test(stripHtml(noteB.content))) upsertEdge(noteA.id, noteB.id, 1.0, 'title_mention')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Niveau 2 : Labels partagés ────────────────────────────────────────────
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
for (let j = i + 1; j < ids.length; j++) {
|
||||
const la = labelMap.get(ids[i])!
|
||||
const lb = labelMap.get(ids[j])!
|
||||
const shared = [...la].filter(l => lb.has(l)).length
|
||||
if (shared > 0) upsertEdge(ids[i], ids[j], Math.min(0.5 + shared * 0.15, 0.9), 'shared_label')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Niveau 3 : Jaccard (désactivé > 500 notes) ───────────────────────────
|
||||
if (notes.length <= 500) {
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const kwI = keywordsMap.get(ids[i])!
|
||||
const candidates: { j: number; score: number }[] = []
|
||||
for (let j = i + 1; j < ids.length; j++) {
|
||||
const score = jaccardSimilarity(kwI, keywordsMap.get(ids[j])!)
|
||||
if (score >= 0.12) candidates.push({ j, score })
|
||||
}
|
||||
candidates.sort((a, b) => b.score - a.score).slice(0, 10)
|
||||
.forEach(({ j, score }) => upsertEdge(ids[i], ids[j], score * 0.8, 'jaccard'))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Niveau 4 : WikiLinks explicites (NoteLink) ─────────────────────────────
|
||||
for (const link of noteLinks) {
|
||||
upsertEdge(link.sourceNoteId, link.targetNoteId, 1.0, 'explicit_link')
|
||||
}
|
||||
|
||||
// ── Niveau 5 : Échos sémantiques IA (MemoryEchoInsight) ────────────────────
|
||||
for (const echo of echoInsights) {
|
||||
upsertEdge(echo.note1Id, echo.note2Id, echo.similarityScore, 'semantic_echo')
|
||||
}
|
||||
|
||||
const degreeMap = new Map<string, number>()
|
||||
for (const e of edgeMap.values()) {
|
||||
degreeMap.set(e.source, (degreeMap.get(e.source) ?? 0) + 1)
|
||||
degreeMap.set(e.target, (degreeMap.get(e.target) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const nodes = notes.map(n => ({
|
||||
id: n.id,
|
||||
title: n.title || 'Sans titre',
|
||||
notebookId: n.notebookId,
|
||||
createdAt: n.createdAt,
|
||||
degree: degreeMap.get(n.id) ?? 0,
|
||||
}))
|
||||
|
||||
// Build clusters (notebooks)
|
||||
const notebookMap = new Map<string, string>()
|
||||
for (const n of notes) {
|
||||
if (n.notebook) notebookMap.set(n.notebook.id, n.notebook.name)
|
||||
}
|
||||
const clusters = [...notebookMap.entries()].map(([id, name]) => ({ id, name }))
|
||||
|
||||
return NextResponse.json({ nodes, edges: Array.from(edgeMap.values()), clusters })
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
const WIKILINK_RE = /\[\[([^\]|#]+?)(?:[|#][^\]]+)?\]\]/g
|
||||
|
||||
function extractWikilinks(content: string): { title: string; snippet: string }[] {
|
||||
const plain = content.replace(/<[^>]+>/g, ' ')
|
||||
const results: { title: string; snippet: string }[] = []
|
||||
const seen = new Set<string>()
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
WIKILINK_RE.lastIndex = 0
|
||||
while ((match = WIKILINK_RE.exec(plain)) !== null) {
|
||||
const title = match[1].trim()
|
||||
if (!title || seen.has(title.toLowerCase())) continue
|
||||
seen.add(title.toLowerCase())
|
||||
const start = Math.max(0, match.index - 50)
|
||||
const end = Math.min(plain.length, match.index + match[0].length + 50)
|
||||
const snippet = plain.slice(start, end).replace(/\s+/g, ' ').trim()
|
||||
results.push({ title, snippet })
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/graph/sync-all
|
||||
* Batch-sync [[wikilinks]] for ALL notes of the authenticated user.
|
||||
* Call once to populate the NoteLink table from existing notes.
|
||||
*/
|
||||
export async function POST() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = session.user.id
|
||||
|
||||
// Get all non-trashed notes with content
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { userId, trashedAt: null, content: { not: '' } },
|
||||
select: { id: true, content: true, notebookId: true },
|
||||
})
|
||||
|
||||
let totalLinks = 0
|
||||
|
||||
for (const note of notes) {
|
||||
if (!note.content.includes('[[')) continue
|
||||
|
||||
const wikilinks = extractWikilinks(note.content)
|
||||
if (wikilinks.length === 0) continue
|
||||
|
||||
for (const { title, snippet } of wikilinks) {
|
||||
const targetNote = await prisma.note.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
title: { equals: title, mode: 'insensitive' },
|
||||
trashedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (!targetNote) {
|
||||
// Skip stubs in batch sync — we only link existing notes
|
||||
continue
|
||||
}
|
||||
|
||||
if (targetNote.id === note.id) continue
|
||||
|
||||
try {
|
||||
await (prisma as any).noteLink.upsert({
|
||||
where: { sourceNoteId_targetNoteId: { sourceNoteId: note.id, targetNoteId: targetNote.id } },
|
||||
update: { contextSnippet: snippet },
|
||||
create: { sourceNoteId: note.id, targetNoteId: targetNote.id, contextSnippet: snippet },
|
||||
})
|
||||
totalLinks++
|
||||
} catch {
|
||||
// ignore duplicate constraint errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ synced: notes.length, links: totalLinks })
|
||||
}
|
||||
Reference in New Issue
Block a user