feat(dashboard): tableau de bord Second Brain configurable avec chargement progressif
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m3s
CI / Deploy production (on server) (push) Successful in 23s

Briefing granulaire, pistes rapides puis enrichissement async, layout persisté v5,
suggestions agents, intégration Gmail et navigation sidebar alignée sur /home.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-07-14 16:50:53 +00:00
parent d38a99586b
commit 30da592ba2
62 changed files with 7741 additions and 335 deletions

View File

@@ -1,11 +1,13 @@
'use client'
import { useState, useEffect } from 'react'
import { BookOpen, Loader2, Check, X, RefreshCw, Trash2, CalendarDays } from 'lucide-react'
import { BookOpen, Loader2, Check, X, RefreshCw, Trash2, CalendarDays, Mail } from 'lucide-react'
import { toast } from 'sonner'
import { SettingsHelpBox } from '@/components/settings/settings-help-box'
import { useLanguage } from '@/lib/i18n'
export default function IntegrationsPage() {
const { t } = useLanguage()
// ── Readwise ───────────────────────────────────────────────────────────
const [rwToken, setRwToken] = useState('')
const [rwConnected, setRwConnected] = useState(false)
@@ -19,32 +21,47 @@ export default function IntegrationsPage() {
const [calEvents, setCalEvents] = useState<any[]>([])
const [calFetching, setCalFetching] = useState(false)
// ── Gmail ────────────────────────────────────────────────────────────
const [gmailConnected, setGmailConnected] = useState(false)
const [gmailLoading, setGmailLoading] = useState(true)
const [gmailRecent, setGmailRecent] = useState(0)
const [gmailScanning, setGmailScanning] = useState(false)
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([
fetch('/api/integrations/readwise').then((r) => r.json()),
fetch('/api/integrations/calendar').then((r) => r.json()),
]).then(([rw, cal]) => {
fetch('/api/integrations/gmail').then((r) => r.json()),
]).then(([rw, cal, gmail]) => {
setRwConnected(rw.connected)
setCalConnected(cal.connected)
setGmailConnected(gmail.connected)
setGmailRecent(gmail.recentCaptures ?? 0)
}).finally(() => {
setLoading(false)
setCalLoading(false)
setGmailLoading(false)
})
// Handle redirect params
const params = new URLSearchParams(window.location.search)
if (params.get('connected') === 'calendar') {
setCalConnected(true)
toast.success('Google Calendar connecté !')
toast.success(t('integrations.calendarConnected'))
window.history.replaceState({}, '', '/settings/integrations')
}
if (params.get('connected') === 'gmail') {
setGmailConnected(true)
toast.success(t('integrations.gmail.connected'))
window.history.replaceState({}, '', '/settings/integrations')
}
if (params.get('error')) {
toast.error(`Erreur: ${params.get('error')}`)
toast.error(t('integrations.errorWith', { error: params.get('error') || '' }))
window.history.replaceState({}, '', '/settings/integrations')
}
}, [])
}, [t])
// ── Readwise handlers ──────────────────────────────────────────────────
const handleRwConnect = async () => {
@@ -57,12 +74,12 @@ export default function IntegrationsPage() {
body: JSON.stringify({ token: rwToken.trim() }),
})
const data = await res.json()
if (!res.ok) { toast.error(data.error || 'Erreur Readwise'); return }
if (!res.ok) { toast.error(data.error || t('integrations.readwiseError')); return }
setRwConnected(true)
setRwToken('')
setRwLastSync({ created: data.created, updated: data.updated })
toast.success(`Readwise connecté — ${data.created} notes créées, ${data.updated} mises à jour`)
} catch { toast.error('Erreur de connexion Readwise') } finally { setRwConnecting(false) }
toast.success(t('integrations.readwiseConnected', { created: data.created, updated: data.updated }))
} catch { toast.error(t('integrations.readwiseConnectError')) } finally { setRwConnecting(false) }
}
const handleRwSync = async () => {
@@ -74,16 +91,16 @@ export default function IntegrationsPage() {
body: JSON.stringify({}),
})
const data = await res.json()
if (!res.ok) { toast.error(data.error || 'Erreur de sync'); return }
if (!res.ok) { toast.error(data.error || t('integrations.syncError')); return }
setRwLastSync({ created: data.created, updated: data.updated })
toast.success(`Sync Readwise — ${data.created} créées, ${data.updated} mises à jour`)
} catch { toast.error('Erreur de synchronisation') } finally { setRwSyncing(false) }
toast.success(t('integrations.readwiseSynced', { created: data.created, updated: data.updated }))
} catch { toast.error(t('integrations.syncFailed')) } finally { setRwSyncing(false) }
}
const handleRwDisconnect = async () => {
await fetch('/api/integrations/readwise', { method: 'DELETE' })
setRwConnected(false)
toast.success('Readwiseconnecté')
toast.success(t('integrations.readwiseDisconnected'))
}
// ── Calendar handlers ──────────────────────────────────────────────────
@@ -96,10 +113,10 @@ export default function IntegrationsPage() {
try {
const res = await fetch('/api/integrations/calendar?events=1')
const data = await res.json()
if (!res.ok) { toast.error(data.error || 'Erreur'); return }
if (!res.ok) { toast.error(data.error || t('common.error')); return }
setCalEvents(data.events ?? [])
if (data.events.length === 0) toast.info('Aucun événement aujourd\'hui')
} catch { toast.error('Erreur de chargement des événements') } finally { setCalFetching(false) }
if (data.events.length === 0) toast.info(t('integrations.noEvents'))
} catch { toast.error(t('integrations.eventsLoadError')) } finally { setCalFetching(false) }
}
const handleCreateMeetingNote = async (event: any) => {
@@ -110,11 +127,11 @@ export default function IntegrationsPage() {
})
const data = await res.json()
if (data.success) {
toast.success(`Note de réunion créée : ${event.summary}`, {
action: { label: 'Ouvrir', onClick: () => window.location.href = `/home?openNote=${data.note.id}` },
toast.success(t('integrations.meetingNoteCreated', { summary: event.summary }), {
action: { label: t('integrations.open'), onClick: () => window.location.href = `/home?openNote=${data.note.id}` },
})
} else {
toast.error('Erreur lors de la création de la note')
toast.error(t('integrations.createNoteError'))
}
}
@@ -122,25 +139,114 @@ export default function IntegrationsPage() {
await fetch('/api/integrations/calendar', { method: 'DELETE' })
setCalConnected(false)
setCalEvents([])
toast.success('Google Calendarconnecté')
toast.success(t('integrations.calendarDisconnected'))
}
// ── Gmail handlers ─────────────────────────────────────────────────────
const handleGmailConnect = () => {
window.location.href = '/api/integrations/gmail?connect=1'
}
const handleGmailScan = async () => {
setGmailScanning(true)
try {
const res = await fetch('/api/integrations/gmail?scan=1')
const data = await res.json()
if (!res.ok) { toast.error(data.error || t('common.error')); return }
setGmailRecent(prev => prev + (data.created ?? 0))
toast.success(t('integrations.gmail.scanDone', { created: data.created ?? 0 }))
} catch {
toast.error(t('integrations.gmailScanError'))
} finally {
setGmailScanning(false)
}
}
const handleGmailDisconnect = async () => {
await fetch('/api/integrations/gmail', { method: 'DELETE' })
setGmailConnected(false)
setGmailRecent(0)
toast.success(t('integrations.gmail.disconnected'))
}
const StatusBadge = ({ connected }: { connected: boolean }) =>
connected ? (
<span className="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-950/40 border border-emerald-200 dark:border-emerald-800/50 rounded-full px-2.5 py-1">
<Check size={11} /> Connecté
<Check size={11} /> {t('integrations.connected')}
</span>
) : (
<span className="flex items-center gap-1.5 text-[11px] font-semibold text-concrete bg-border/20 border border-border/40 rounded-full px-2.5 py-1">
<X size={11} /> Non connecté
<X size={11} /> {t('integrations.notConnected')}
</span>
)
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-serif font-medium text-ink italic tracking-tight">Intégrations</h2>
<p className="text-sm text-concrete mt-1">Connectez des services externes à Memento.</p>
<h2 className="text-2xl font-serif font-medium text-ink italic tracking-tight">{t('settings.integrationsTitle')}</h2>
<p className="text-sm text-concrete mt-1">{t('settings.integrationsDesc')}</p>
</div>
{/* ── Gmail ──────────────────────────────────────────────────────── */}
<div className="border border-border/40 rounded-2xl p-6 bg-paper space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<Mail size={20} className="text-red-600 dark:text-red-400" />
</div>
<div>
<h3 className="font-semibold text-ink text-sm">{t('integrations.gmail.title')}</h3>
<p className="text-[12px] text-concrete">{t('integrations.gmail.description')}</p>
</div>
</div>
{gmailLoading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={gmailConnected} />}
</div>
{!gmailConnected ? (
<div className="space-y-3">
<SettingsHelpBox
title={t('integrations.gmail.helpTitle')}
steps={[
{ text: t('integrations.gmail.helpStep1') },
{ text: t('integrations.gmail.helpStep2') },
{ text: t('integrations.gmail.helpStep3') },
{ text: t('integrations.gmail.helpStep4') },
]}
/>
<button
onClick={handleGmailConnect}
className="px-4 py-2 text-sm font-semibold bg-ink text-paper rounded-xl hover:bg-ink/80 transition-all flex items-center gap-2"
>
<Mail size={14} />
{t('integrations.gmail.connect')}
</button>
</div>
) : (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<button
onClick={handleGmailScan}
disabled={gmailScanning}
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold border border-border/40 rounded-xl hover:bg-ink/5 transition-all disabled:opacity-50"
>
{gmailScanning ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
{t('integrations.gmail.scanNow')}
</button>
<button
onClick={handleGmailDisconnect}
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-800/40 rounded-xl hover:bg-rose-50 dark:hover:bg-rose-950/20 transition-all"
>
<Trash2 size={14} />
{t('integrations.gmail.disconnect')}
</button>
</div>
{gmailRecent > 0 && (
<p className="text-[11px] text-concrete">
{t('integrations.gmail.recentCaptures', { count: gmailRecent })}
</p>
)}
</div>
)}
</div>
{/* ── Google Calendar ────────────────────────────────────────────── */}
@@ -152,7 +258,7 @@ export default function IntegrationsPage() {
</div>
<div>
<h3 className="font-semibold text-ink text-sm">Google Calendar</h3>
<p className="text-[12px] text-concrete">Accédez à vos événements et créez des notes de réunion</p>
<p className="text-[12px] text-concrete">{t('integrations.calendarDesc')}</p>
</div>
</div>
{calLoading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={calConnected} />}
@@ -161,12 +267,12 @@ export default function IntegrationsPage() {
{!calConnected ? (
<div className="space-y-3">
<SettingsHelpBox
title="Comment fonctionne Google Calendar ?"
title={t('integrations.calendarInfo')}
steps={[
{ text: 'Cliquez "Connecter Google Calendar" — vous serez redirigé vers Google pour autoriser l\'accès.' },
{ text: 'Une fois connecté, revenez ici et cliquez "Événements aujourd\'hui" pour voir votre agenda.' },
{ text: 'Sur chaque événement, cliquez "+ Note" pour créer automatiquement une note de réunion avec template (Ordre du jour / Notes / Actions).' },
{ text: 'La note s\'ouvre directement dans Memento — ajoutez vos notes en temps réel pendant la réunion.' },
{ text: t('integrations.calendarHelpStep1') },
{ text: t('integrations.calendarHelpStep2') },
{ text: t('integrations.calendarHelpStep3') },
{ text: t('integrations.calendarHelpStep4') },
]}
/>
<button
@@ -174,7 +280,7 @@ export default function IntegrationsPage() {
className="px-4 py-2 text-sm font-semibold bg-ink text-paper rounded-xl hover:bg-ink/80 transition-all flex items-center gap-2"
>
<CalendarDays size={14} />
Connecter Google Calendar
{t('integrations.connectCalendar')}
</button>
</div>
) : (
@@ -186,14 +292,14 @@ export default function IntegrationsPage() {
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold border border-border/40 rounded-xl hover:bg-ink/5 transition-all disabled:opacity-50"
>
{calFetching ? <Loader2 size={14} className="animate-spin" /> : <CalendarDays size={14} />}
Événements aujourd&apos;hui
{t('integrations.todayEvents')}
</button>
<button
onClick={handleCalDisconnect}
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-800/40 rounded-xl hover:bg-rose-50 dark:hover:bg-rose-950/20 transition-all"
>
<Trash2 size={14} />
Déconnecter
{t('integrations.disconnect')}
</button>
</div>
@@ -211,7 +317,7 @@ export default function IntegrationsPage() {
onClick={() => handleCreateMeetingNote(ev)}
className="shrink-0 text-[11px] font-semibold px-3 py-1.5 border border-border/40 rounded-full hover:bg-ink/5 transition-all"
>
+ Note
{t('integrations.addNote')}
</button>
</div>
))}
@@ -230,20 +336,20 @@ export default function IntegrationsPage() {
</div>
<div>
<h3 className="font-semibold text-ink text-sm">Readwise</h3>
<p className="text-[12px] text-concrete">Importez vos surlignages de livres, articles et Kindle</p>
<p className="text-[12px] text-concrete">{t('integrations.readwiseDesc')}</p>
</div>
</div>
{loading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={rwConnected} />}
</div>
<SettingsHelpBox
title="Comment fonctionne Readwise ?"
title={t('integrations.readwiseInfo')}
steps={[
{ text: 'Copiez votre token d\'accès Readwise.', link: { label: 'readwise.io/access_token', href: 'https://readwise.io/access_token' } },
{ text: 'Collez-le dans le champ ci-dessous et cliquez "Connecter". La première synchronisation importe tous vos livres et articles.' },
{ text: 'Chaque livre devient une note dans un carnet "Readwise 📚" — avec tous vos surlignages organisés.' },
{ text: 'Pour mettre à jour avec de nouveaux surlignages, revenez ici et cliquez "Synchroniser maintenant".' },
{ icon: '💡', text: 'Astuce : créez des flashcards IA depuis une note Readwise (bouton 🎓 dans l\'éditeur) pour réviser vos lectures.' },
{ text: t('integrations.readwiseHelpStep1'), link: { label: 'readwise.io/access_token', href: 'https://readwise.io/access_token' } },
{ text: t('integrations.readwiseHelpStep2') },
{ text: t('integrations.readwiseHelpStep3') },
{ text: t('integrations.readwiseHelpStep4') },
{ icon: '💡', text: t('integrations.readwiseHelpStep5') },
]}
/>
@@ -254,7 +360,7 @@ export default function IntegrationsPage() {
type="password"
value={rwToken}
onChange={(e) => setRwToken(e.target.value)}
placeholder="Token Readwise…"
placeholder={t('integrations.readwiseTokenPlaceholder')}
className="flex-1 text-sm border border-border/40 rounded-xl px-3 py-2 bg-paper text-ink placeholder:text-concrete/50 focus:outline-none focus:ring-1 focus:ring-brand-accent/40"
onKeyDown={(e) => e.key === 'Enter' && handleRwConnect()}
/>
@@ -264,7 +370,7 @@ export default function IntegrationsPage() {
className="px-4 py-2 text-sm font-semibold bg-ink text-paper rounded-xl hover:bg-ink/80 transition-all disabled:opacity-50 flex items-center gap-2"
>
{rwConnecting ? <Loader2 size={14} className="animate-spin" /> : null}
Connecter
{t('integrations.connect')}
</button>
</div>
</div>
@@ -278,28 +384,28 @@ export default function IntegrationsPage() {
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold border border-border/40 rounded-xl hover:bg-ink/5 transition-all disabled:opacity-50"
>
{rwSyncing ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
Synchroniser maintenant
{t('integrations.syncNow')}
</button>
<button
onClick={handleRwDisconnect}
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-800/40 rounded-xl hover:bg-rose-50 dark:hover:bg-rose-950/20 transition-all"
>
<Trash2 size={14} />
Déconnecter
{t('integrations.disconnect')}
</button>
</div>
)}
{rwLastSync && (
<p className="text-[11px] text-concrete">
Dernière sync : <strong>{rwLastSync.created}</strong> notes créées, <strong>{rwLastSync.updated}</strong> mises à jour
{t('integrations.lastSync')} <strong>{rwLastSync.created}</strong> {t('integrations.createdLabel')}, <strong>{rwLastSync.updated}</strong> {t('integrations.updatedLabel')}
</p>
)}
</div>
{/* Placeholder */}
<div className="border border-dashed border-border/40 rounded-2xl p-6 text-center">
<p className="text-sm text-concrete italic">D'autres intégrations arrivent bientôt Zapier, GitHub, Notion import</p>
<p className="text-sm text-concrete italic">{t('integrations.moreComing')}</p>
</div>
</div>
)

View File

@@ -1082,6 +1082,27 @@ export async function getRecentNotes(limit: number = 3) {
}
}
// Get count of unassigned notes (Inbox count) — notes without a notebook
export async function getInboxCount() {
const session = await auth()
if (!session?.user?.id) return 0
try {
const count = await prisma.note.count({
where: {
userId: session.user.id,
notebookId: null,
isArchived: false,
trashedAt: null,
},
})
return count
} catch (error) {
console.error('Error counting inbox notes:', error)
return 0
}
}
// Dismiss a note from Recent section
export async function dismissFromRecent(id: string) {
const session = await auth();

View File

@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const result = await agentSuggestionService.accept(session.user.id, id)
if (!result) {
return NextResponse.json({ error: 'Suggestion introuvable' }, { status: 404 })
}
return NextResponse.json({ success: true, agentId: result.agentId })
}

View File

@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const result = await agentSuggestionService.dismiss(session.user.id, id)
if (!result) {
return NextResponse.json({ error: 'Suggestion introuvable' }, { status: 404 })
}
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const suggestions = await agentSuggestionService.getPending(session.user.id, 3)
return NextResponse.json({
suggestions: suggestions.map(s => ({
id: s.id,
topic: s.topic,
reason: s.reason,
suggestedType: s.suggestedType,
suggestedFrequency: s.suggestedFrequency,
relatedNoteCount: (() => {
try {
return JSON.parse(s.relatedNoteIds).length
} catch {
return 0
}
})(),
clusterId: s.clusterId,
createdAt: s.createdAt.toISOString(),
})),
})
}

View File

@@ -0,0 +1,204 @@
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
import { bridgeNotesService } from '@/lib/ai/services/bridge-notes.service'
import { buildDashboardPaths, buildOpenLoops } from '@/lib/dashboard/paths.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { getSystemConfig } from '@/lib/config'
import { getChatProvider } from '@/lib/ai/factory'
function excerptNoteContent(content: string | null | undefined, max = 200): string {
if (!content) return ''
const plain = content.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
if (plain.length <= max) return plain
return `${plain.slice(0, max)}`
}
interface PathsBriefingSnapshot {
recentNote?: {
id: string
title: string | null
content: string
notebookId: string | null
} | null
inboxCount?: number
dueFlashcards?: number
insights?: Array<{
id: string
insight: string
score: number
viewed: boolean
note1: { id: string; title: string | null }
note2: { id: string; title: string | null }
note1Excerpt?: string
note2Excerpt?: string
}>
bridgeSuggestions?: Array<{
clusterAId: number
clusterBId: number
clusterAName: string
clusterBName: string
suggestedTitle: string
suggestedContent: string
justification: string
}>
agentSuggestions?: Array<{
id: string
topic: string
reason: string
clusterId: number | null
}>
includeOpenLoops?: boolean
}
async function resolvePathsPayload(userId: string, snapshot?: PathsBriefingSnapshot | null) {
const now = new Date()
const hasSnapshot = !!snapshot?.recentNote
const needsAgents = !snapshot?.agentSuggestions
const needsBridges = !snapshot?.bridgeSuggestions
const needsInsights = !snapshot?.insights
const needsCounts = snapshot?.inboxCount === undefined || snapshot?.dueFlashcards === undefined
const needsFocusNote = !snapshot?.recentNote
const [
aiConsent,
aiSettings,
systemConfig,
recentNotes,
inboxCount,
dueFlashcards,
unviewedInsights,
agentSuggestions,
bridgeSuggestions,
] = await Promise.all([
hasUserAiConsent(),
prisma.userAISettings.findUnique({
where: { userId },
select: { memoryEcho: true },
}),
getSystemConfig(),
needsFocusNote
? prisma.note.findMany({
where: { userId, trashedAt: null, isArchived: false },
select: { id: true, title: true, content: true, notebookId: true, updatedAt: true },
orderBy: { updatedAt: 'desc' },
take: 1,
})
: Promise.resolve([]),
needsCounts
? prisma.note.count({
where: { userId, notebookId: null, isArchived: false, trashedAt: null },
})
: Promise.resolve(snapshot!.inboxCount!),
needsCounts
? prisma.flashcard.count({
where: { deck: { userId }, nextReviewAt: { lte: now } },
})
: Promise.resolve(snapshot!.dueFlashcards!),
needsInsights
? prisma.memoryEchoInsight.findMany({
where: { userId, dismissed: false },
select: {
id: true, insight: true, similarityScore: true, viewed: true,
note1: { select: { id: true, title: true, content: true } },
note2: { select: { id: true, title: true, content: true } },
},
orderBy: { insightDate: 'desc' },
take: 5,
}).catch(() => [])
: Promise.resolve([]),
needsAgents
? agentSuggestionService.getPending(userId, 3).catch(() => [])
: Promise.resolve([]),
needsBridges
? bridgeNotesService.getBridgeSuggestions(userId).catch(() => [])
: Promise.resolve([]),
])
const aiProviderReady = !!getChatProvider(systemConfig)
const memoryEchoEnabled = aiSettings?.memoryEcho ?? true
const aiActive = aiConsent && aiProviderReady && memoryEchoEnabled
const focusNotes = hasSnapshot && snapshot?.recentNote
? [{
...snapshot.recentNote,
updatedAt: now,
}]
: recentNotes
const insights = snapshot?.insights ?? unviewedInsights.map(i => ({
id: i.id,
insight: i.insight,
score: i.similarityScore,
viewed: i.viewed,
note1: { id: i.note1.id, title: i.note1.title },
note2: { id: i.note2.id, title: i.note2.title },
note1Excerpt: excerptNoteContent(i.note1.content),
note2Excerpt: excerptNoteContent(i.note2.content),
}))
const resolvedBridges = snapshot?.bridgeSuggestions
?? bridgeSuggestions.slice(0, 3).map(s => ({
clusterAId: s.clusterAId,
clusterBId: s.clusterBId,
clusterAName: s.clusterAName,
clusterBName: s.clusterBName,
suggestedTitle: s.suggestedTitle,
suggestedContent: s.suggestedContent,
justification: s.justification,
}))
const resolvedAgents = snapshot?.agentSuggestions
?? agentSuggestions.map(s => ({
id: s.id,
topic: s.topic,
reason: s.reason,
clusterId: s.clusterId,
}))
const paths = await buildDashboardPaths({
userId,
aiActive,
recentNotes: focusNotes,
inboxCount: needsCounts ? inboxCount : snapshot!.inboxCount!,
dueFlashcards: needsCounts ? dueFlashcards : snapshot!.dueFlashcards!,
insights,
bridgeSuggestions: resolvedBridges,
agentSuggestions: resolvedAgents,
}).catch(() => [])
const openLoops = snapshot?.includeOpenLoops === false
? []
: await buildOpenLoops(userId).catch(() => [])
return { paths, openLoops }
}
/**
* GET /api/briefing/paths — pistes enrichies (fallback sans snapshot client).
*/
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const payload = await resolvePathsPayload(session.user.id)
return NextResponse.json(payload)
}
/**
* POST /api/briefing/paths — enrichissement avec snapshot briefing (évite re-fetch agents/ponts).
*/
export async function POST(request: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const snapshot = await request.json().catch(() => null) as PathsBriefingSnapshot | null
const payload = await resolvePathsPayload(session.user.id, snapshot)
return NextResponse.json(payload)
}

View File

@@ -0,0 +1,240 @@
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
import { bridgeNotesService } from '@/lib/ai/services/bridge-notes.service'
import { gmailScannerService } from '@/lib/integrations/gmail-scanner.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { getSystemConfig } from '@/lib/config'
import { getChatProvider } from '@/lib/ai/factory'
function excerptNoteContent(content: string | null | undefined, max = 200): string {
if (!content) return ''
const plain = content.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
if (plain.length <= max) return plain
return `${plain.slice(0, max)}`
}
async function fetchWritingActivity(userId: string, since: Date) {
const rows = await prisma.$queryRaw<Array<{ date: string; count: number }>>`
SELECT to_char("updatedAt" AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS date,
COUNT(*)::int AS count
FROM "Note"
WHERE "userId" = ${userId}
AND "trashedAt" IS NULL
AND "updatedAt" >= ${since}
GROUP BY 1
ORDER BY 1 ASC
`
return rows.map(r => ({ date: r.date, count: Number(r.count) }))
}
/**
* GET /api/briefing — agrégat rapide pour le tableau de bord.
* Pas de génération IA synchrone ; les pistes sont dans GET /api/briefing/paths.
*/
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const now = new Date()
const ninetyDaysAgo = new Date(now)
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90)
const [aiConsent, aiSettings, systemConfig] = await Promise.all([
hasUserAiConsent(),
prisma.userAISettings.findUnique({
where: { userId },
select: { memoryEcho: true },
}),
getSystemConfig(),
])
const aiProviderReady = !!getChatProvider(systemConfig)
const memoryEchoEnabled = aiSettings?.memoryEcho ?? true
const aiActive = aiConsent && aiProviderReady && memoryEchoEnabled
const [
recentNotes,
inboxCount,
dueFlashcards,
upcomingReminders,
unviewedInsights,
recentAgentActions,
pinnedNotes,
writingActivity,
gmailStatus,
agentSuggestions,
bridgeSuggestions,
] = await Promise.all([
prisma.note.findMany({
where: { userId, trashedAt: null, isArchived: false },
select: {
id: true, title: true, content: true, color: true,
notebookId: true, updatedAt: true, createdAt: true,
},
orderBy: { updatedAt: 'desc' },
take: 6,
}),
prisma.note.count({
where: { userId, notebookId: null, isArchived: false, trashedAt: null },
}),
prisma.flashcard.count({
where: { deck: { userId }, nextReviewAt: { lte: now } },
}),
prisma.note.findMany({
where: {
userId, reminder: { gte: now }, isReminderDone: false, trashedAt: null,
},
select: { id: true, title: true, reminder: true, notebookId: true },
orderBy: { reminder: 'asc' },
take: 5,
}),
prisma.memoryEchoInsight.findMany({
where: { userId, viewed: false, dismissed: false },
select: {
id: true, insight: true, similarityScore: true,
insightDate: true, viewed: true,
note1: { select: { id: true, title: true, content: true } },
note2: { select: { id: true, title: true, content: true } },
},
orderBy: { insightDate: 'desc' },
take: 5,
}).catch(() => []),
prisma.agentAction.findMany({
where: {
agent: { userId },
status: 'completed',
createdAt: { gte: new Date(now.getTime() - 48 * 60 * 60 * 1000) },
},
select: {
id: true, result: true, createdAt: true,
agent: { select: { id: true, name: true, type: true } },
},
orderBy: { createdAt: 'desc' },
take: 3,
}).catch(() => []),
prisma.note.findMany({
where: { userId, isPinned: true, trashedAt: null, isArchived: false },
select: { id: true, title: true, notebookId: true, updatedAt: true },
orderBy: { updatedAt: 'desc' },
take: 5,
}),
fetchWritingActivity(userId, ninetyDaysAgo).catch(() => []),
gmailScannerService.getStatus(userId).catch(() => ({
connected: false,
recentCaptures: 0,
})),
agentSuggestionService.getPending(userId, 3).catch(() => []),
bridgeNotesService.getBridgeSuggestions(userId).catch(() => []),
])
let insightRows = unviewedInsights
if (insightRows.length < 5) {
const viewedRecent = await prisma.memoryEchoInsight.findMany({
where: {
userId,
dismissed: false,
viewed: true,
id: { notIn: insightRows.map(i => i.id) },
},
select: {
id: true, insight: true, similarityScore: true,
insightDate: true, viewed: true,
note1: { select: { id: true, title: true, content: true } },
note2: { select: { id: true, title: true, content: true } },
},
orderBy: { insightDate: 'desc' },
take: 5 - insightRows.length,
}).catch(() => [])
insightRows = [...insightRows, ...viewedRecent]
}
const notebookIds = [...new Set(recentNotes.map(n => n.notebookId).filter(Boolean))] as string[]
const notebooks = notebookIds.length > 0
? await prisma.notebook.findMany({
where: { id: { in: notebookIds } },
select: { id: true, name: true, color: true, icon: true },
})
: []
const notebookMap = new Map(notebooks.map(n => [n.id, n]))
return NextResponse.json({
recentNotes: recentNotes.map(n => ({
...n,
notebook: n.notebookId ? notebookMap.get(n.notebookId) || null : null,
})),
inboxCount,
dueFlashcards,
upcomingReminders: upcomingReminders.map(r => ({
id: r.id,
title: r.title,
reminder: r.reminder?.toISOString() || null,
notebookId: r.notebookId,
})),
insights: insightRows.map(i => ({
id: i.id,
insight: i.insight,
score: i.similarityScore,
date: i.insightDate.toISOString(),
viewed: i.viewed,
note1: { id: i.note1.id, title: i.note1.title },
note2: { id: i.note2.id, title: i.note2.title },
note1Excerpt: excerptNoteContent(i.note1.content),
note2Excerpt: excerptNoteContent(i.note2.content),
})),
bridgeSuggestions: bridgeSuggestions.slice(0, 3).map(s => ({
clusterAId: s.clusterAId,
clusterBId: s.clusterBId,
clusterAName: s.clusterAName,
clusterBName: s.clusterBName,
suggestedTitle: s.suggestedTitle,
suggestedContent: s.suggestedContent,
justification: s.justification,
})),
ai: {
consent: aiConsent,
memoryEchoEnabled,
providerReady: aiProviderReady,
active: aiActive,
},
agentActions: recentAgentActions.map(a => ({
id: a.id,
agentName: a.agent?.name || 'Agent',
agentType: a.agent?.type || 'custom',
result: a.result ? String(a.result).slice(0, 200) : null,
createdAt: a.createdAt.toISOString(),
})),
gmail: gmailStatus,
pinnedNotes: pinnedNotes.map(n => ({
id: n.id,
title: n.title,
notebookId: n.notebookId,
updatedAt: n.updatedAt.toISOString(),
})),
writingActivity,
agentSuggestions: agentSuggestions.map(s => ({
id: s.id,
topic: s.topic,
reason: s.reason,
suggestedType: s.suggestedType,
suggestedFrequency: s.suggestedFrequency,
relatedNoteCount: (() => {
try { return JSON.parse(s.relatedNoteIds).length } catch { return 0 }
})(),
clusterId: s.clusterId,
})),
})
}

View File

@@ -0,0 +1,87 @@
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { redis } from '@/lib/redis'
import { detectUserLanguage } from '@/lib/i18n/detect-user-language'
const CACHE_TTL_SEC = 3600
/**
* GET /api/briefing/sentiment
* Analyzes the emotional tone of recent notes (last 7 days) using LLM.
*/
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const locale = await detectUserLanguage()
const cacheKey = `briefing:sentiment:${userId}:${locale}`
try {
const cached = await redis.get(cacheKey)
if (cached) return NextResponse.json(JSON.parse(cached))
} catch {}
const weekAgo = new Date()
weekAgo.setDate(weekAgo.getDate() - 7)
const recentNotes = await prisma.note.findMany({
where: {
userId,
trashedAt: null,
isArchived: false,
updatedAt: { gte: weekAgo },
},
select: { title: true, content: true },
take: 20,
orderBy: { updatedAt: 'desc' },
})
if (recentNotes.length < 3) {
return NextResponse.json({
available: false,
reason: 'Not enough notes this week',
})
}
const snippets = recentNotes.map(n => {
const text = n.content.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
return `${n.title || ''}: ${text.slice(0, 200)}`
}).join('\n---\n')
try {
const config = await getSystemConfig()
const provider = getChatProvider(config)
if (!provider) {
return NextResponse.json({ available: false, reason: 'No AI provider' })
}
const localeLabel = locale === 'fr' ? 'French' : locale === 'en' ? 'English' : locale
const prompt = `Analyze the emotional patterns in these notes from the past week. Return ONLY valid JSON (no markdown, no code fences).
Write "summary" and "topTopic" in ${localeLabel} (locale code: ${locale}). Keep dominantEmotion keys in English as listed.
{"dominantEmotion":"focused|curious|enthusiastic|frustrated|calm|anxious|creative|reflective","sentimentScore":number from -1 to 1,"emotions":{"focused":number,"curious":number,"enthusiastic":number,"frustrated":number,"calm":number,"anxious":number,"creative":number,"reflective":number},"summary":"one sentence describing the emotional pattern","topTopic":"most discussed topic"}
Notes:
${snippets.slice(0, 3000)}`
const result = await provider.generateText(prompt)
const jsonMatch = result.match(/\{[\s\S]*\}/)
if (!jsonMatch) {
return NextResponse.json({ available: false, reason: 'Parse error' })
}
const parsed = JSON.parse(jsonMatch[0])
const payload = { available: true, ...parsed }
try { await redis.setex(cacheKey, CACHE_TTL_SEC, JSON.stringify(payload)) } catch {}
return NextResponse.json(payload)
} catch (error) {
console.error('[briefing/sentiment]', error)
return NextResponse.json({ available: false, reason: 'Analysis failed' })
}
}

View File

@@ -20,8 +20,54 @@ export async function GET(request: NextRequest) {
// Check for stored results (even if stale/périmés)
const stored = await clusteringService.getStoredClusters(userId)
const lite = request.nextUrl.searchParams.get('lite') === '1'
if (stored) {
const cached = stored.clusters
if (lite) {
const [bridgeNotesData, totalNotes] = await Promise.all([
prisma.bridgeNote.findMany({
where: { userId },
orderBy: { bridgeScore: 'desc' },
take: 5,
select: { noteId: true, bridgeScore: true, clustersConnected: true },
}),
prisma.note.count({ where: { userId, trashedAt: null } }),
])
let enrichedBridgeNotes: object[] = []
if (bridgeNotesData.length > 0) {
const bridgeNoteDetails = await prisma.note.findMany({
where: { id: { in: bridgeNotesData.map(b => b.noteId) } },
select: { id: true, title: true },
})
const bridgeNoteDetailsMap = new Map(bridgeNoteDetails.map(n => [n.id, n]))
enrichedBridgeNotes = bridgeNotesData.map(b => {
const clustersConnected = JSON.parse(b.clustersConnected) as number[]
return {
noteId: b.noteId,
bridgeScore: b.bridgeScore,
clustersConnected,
clusterNames: clustersConnected.map(
cid => cached.find(c => c.clusterId === cid)?.name || `Cluster ${cid}`,
),
note: bridgeNoteDetailsMap.get(b.noteId),
}
})
}
return NextResponse.json({
clusters: cached,
bridgeNotes: enrichedBridgeNotes,
cached: true,
stale: stored.stale,
lastCalculated: stored.lastCalculated,
totalNotes,
})
}
// Fetch notes with their cluster assignments
const notes = await prisma.note.findMany({
where: { userId, trashedAt: null },

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { agentSuggestionService } from '@/lib/ai/services/agent-suggestion.service'
export const dynamic = 'force-dynamic'
function authorizeCron(request: NextRequest): boolean {
const cronSecret = process.env.CRON_SECRET
if (!cronSecret) return false
const authHeader = request.headers.get('authorization')
const bearer = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
const querySecret = new URL(request.url).searchParams.get('secret')
return bearer === cronSecret || querySecret === cronSecret
}
/**
* POST /api/cron/agent-suggestions
* Génère des suggestions d'agents à partir des clusters sémantiques existants.
*/
export async function POST(request: NextRequest) {
if (!authorizeCron(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const users = await prisma.user.findMany({
where: { noteClusters: { some: {} } },
select: { id: true },
take: 30,
})
let total = 0
for (const user of users) {
try {
total += await agentSuggestionService.generateForUser(user.id)
} catch (err) {
console.error('[CronAgentSuggestions] user', user.id, err)
}
}
return NextResponse.json({ success: true, users: users.length, suggestionsUpserted: total })
}

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { gmailScannerService } from '@/lib/integrations/gmail-scanner.service'
export const dynamic = 'force-dynamic'
function authorize(request: NextRequest): boolean {
const cronSecret = process.env.CRON_SECRET
if (!cronSecret) return false
const authHeader = request.headers.get('authorization')
const bearer = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
return bearer === cronSecret
}
/** POST /api/cron/gmail — scan Gmail for connected users (1×/jour en prod via entrypoint) */
export async function POST(request: NextRequest) {
if (!authorize(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const users = await prisma.userAISettings.findMany({
select: { userId: true, integrationTokens: true },
take: 200,
})
const connected = users.filter(u => {
if (!u.integrationTokens) return false
try {
const meta = typeof u.integrationTokens === 'string'
? JSON.parse(u.integrationTokens)
: u.integrationTokens
return !!(meta?.gmailAccessToken && meta?.gmailRefreshToken)
} catch {
return false
}
})
let totalCreated = 0
for (const row of connected) {
try {
const { created } = await gmailScannerService.scanUser(row.userId)
totalCreated += created
} catch (err) {
console.error('[CronGmail]', row.userId, err)
}
}
return NextResponse.json({ success: true, users: connected.length, notesCreated: totalCreated })
}

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server'
import { Prisma } from '@prisma/client'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import {
getDefaultDashboardLayout,
normalizeDashboardLayout,
type DashboardLayout,
} from '@/lib/dashboard/layout'
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const settings = await prisma.userAISettings.findUnique({
where: { userId: session.user.id },
select: { dashboardLayout: true },
})
const layout = normalizeDashboardLayout(settings?.dashboardLayout ?? getDefaultDashboardLayout())
return NextResponse.json({ layout })
}
export async function PUT(request: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
let body: { layout?: DashboardLayout }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const layout = normalizeDashboardLayout(body.layout ?? getDefaultDashboardLayout())
const layoutJson = layout as unknown as Prisma.InputJsonValue
await prisma.userAISettings.upsert({
where: { userId: session.user.id },
create: { userId: session.user.id, dashboardLayout: layoutJson },
update: { dashboardLayout: layoutJson },
})
return NextResponse.json({ layout, ok: true })
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server'
import { getGoogleOAuthCredentials, readIntegrationMeta, writeIntegrationMeta } from '@/lib/integrations/google-integration-tokens'
export async function GET(req: NextRequest) {
const url = new URL(req.url)
const code = url.searchParams.get('code')
const userId = url.searchParams.get('state')
const error = url.searchParams.get('error')
if (error || !code || !userId) {
return NextResponse.redirect(`${url.origin}/settings/integrations?error=gmail_auth_failed`)
}
const { clientId, clientSecret } = getGoogleOAuthCredentials()
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
code,
redirect_uri: `${url.origin}/api/integrations/gmail/callback`,
grant_type: 'authorization_code',
}),
})
const tokenData = await tokenRes.json()
if (!tokenData.access_token) {
return NextResponse.redirect(`${url.origin}/settings/integrations?error=gmail_token_failed`)
}
const meta = await readIntegrationMeta(userId)
meta.gmailAccessToken = tokenData.access_token
if (tokenData.refresh_token) meta.gmailRefreshToken = tokenData.refresh_token
await writeIntegrationMeta(userId, meta)
return NextResponse.redirect(`${url.origin}/settings/integrations?connected=gmail`)
}

View File

@@ -0,0 +1,67 @@
/**
* Google Gmail Integration (OAuth + scan trigger)
*/
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import {
getGoogleOAuthCredentials,
getGoogleTokens,
readIntegrationMeta,
writeIntegrationMeta,
} from '@/lib/integrations/google-integration-tokens'
import { gmailScannerService } from '@/lib/integrations/gmail-scanner.service'
const GMAIL_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly'
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
function redirectUri(origin: string) {
return `${origin}/api/integrations/gmail/callback`
}
export async function GET(req: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const url = new URL(req.url)
const userId = session.user.id
if (url.searchParams.get('connect') === '1') {
const { clientId } = getGoogleOAuthCredentials()
if (!clientId) {
return NextResponse.json({ error: 'Google OAuth not configured' }, { status: 503 })
}
const authUrl = new URL(GOOGLE_AUTH_URL)
authUrl.searchParams.set('client_id', clientId)
authUrl.searchParams.set('redirect_uri', redirectUri(url.origin))
authUrl.searchParams.set('response_type', 'code')
authUrl.searchParams.set('scope', GMAIL_SCOPE)
authUrl.searchParams.set('access_type', 'offline')
authUrl.searchParams.set('prompt', 'consent')
authUrl.searchParams.set('state', userId)
return NextResponse.redirect(authUrl.toString())
}
if (url.searchParams.get('scan') === '1') {
const result = await gmailScannerService.scanUser(userId)
return NextResponse.json(result)
}
const status = await gmailScannerService.getStatus(userId)
return NextResponse.json(status)
}
export async function DELETE() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const meta = await readIntegrationMeta(session.user.id)
delete meta.gmailAccessToken
delete meta.gmailRefreshToken
await writeIntegrationMeta(session.user.id, meta)
return NextResponse.json({ success: true })
}