feat(dashboard): tableau de bord Second Brain configurable avec chargement progressif
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:
204
memento-note/lib/integrations/gmail-scanner.service.ts
Normal file
204
memento-note/lib/integrations/gmail-scanner.service.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Gmail Scanner — extrait vols, colis, abonnements → notes + rappels
|
||||
*/
|
||||
|
||||
import prisma from '@/lib/prisma'
|
||||
import { getChatProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { getGoogleTokens, refreshGoogleAccessToken } from '@/lib/integrations/google-integration-tokens'
|
||||
|
||||
const GMAIL_API = 'https://gmail.googleapis.com/gmail/v1/users/me'
|
||||
|
||||
const SCAN_QUERIES: { category: string; q: string }[] = [
|
||||
{ category: 'flight', q: 'subject:(flight OR boarding OR vol OR "boarding pass") newer_than:7d' },
|
||||
{ category: 'package', q: 'from:(amazon OR ups OR fedex OR dhl OR laposte OR colissimo) newer_than:7d' },
|
||||
{ category: 'subscription', q: 'subject:(renewal OR reconduction OR renouvellement OR abonnement) newer_than:30d' },
|
||||
]
|
||||
|
||||
function decodeBase64Url(data: string): string {
|
||||
const normalized = data.replace(/-/g, '+').replace(/_/g, '/')
|
||||
return Buffer.from(normalized, 'base64').toString('utf-8')
|
||||
}
|
||||
|
||||
function extractBody(payload: any): string {
|
||||
if (!payload) return ''
|
||||
if (payload.body?.data) {
|
||||
const raw = decodeBase64Url(payload.body.data)
|
||||
return raw.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 4000)
|
||||
}
|
||||
if (Array.isArray(payload.parts)) {
|
||||
for (const part of payload.parts) {
|
||||
if (part.mimeType === 'text/plain' && part.body?.data) {
|
||||
return decodeBase64Url(part.body.data).slice(0, 4000)
|
||||
}
|
||||
}
|
||||
for (const part of payload.parts) {
|
||||
const nested = extractBody(part)
|
||||
if (nested) return nested
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function gmailFetch(userId: string, path: string, accessToken: string, refreshToken: string) {
|
||||
let token = accessToken
|
||||
let res = await fetch(`${GMAIL_API}${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (res.status === 401 && refreshToken) {
|
||||
const refreshed = await refreshGoogleAccessToken(userId, 'gmail', refreshToken)
|
||||
if (refreshed) {
|
||||
token = refreshed
|
||||
res = await fetch(`${GMAIL_API}${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
async function extractStructured(
|
||||
category: string,
|
||||
subject: string,
|
||||
body: string,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getChatProvider(config)
|
||||
if (!provider) return null
|
||||
|
||||
const schemas: Record<string, string> = {
|
||||
flight: '{"airline":"","flightNumber":"","departureDate":"ISO8601","departureAirport":"","arrivalAirport":"","confirmationCode":""}',
|
||||
package: '{"carrier":"","trackingNumber":"","expectedDelivery":"ISO8601","status":""}',
|
||||
subscription: '{"serviceName":"","renewalDate":"ISO8601","amount":0,"currency":"EUR"}',
|
||||
}
|
||||
|
||||
const prompt = `Extrais les données structurées de cet email (${category}). Retourne UNIQUEMENT du JSON valide selon ce schéma: ${schemas[category] || '{}'}
|
||||
Sujet: ${subject}
|
||||
Corps: ${body.slice(0, 2500)}`
|
||||
|
||||
try {
|
||||
const raw = await provider.generateText(prompt)
|
||||
const match = raw.match(/\{[\s\S]*\}/)
|
||||
if (!match) return null
|
||||
return JSON.parse(match[0])
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function buildNoteFromExtraction(category: string, subject: string, data: Record<string, unknown>) {
|
||||
if (category === 'flight') {
|
||||
const title = `Vol ${data.flightNumber || data.airline || ''}`.trim() || subject.slice(0, 80)
|
||||
const dep = data.departureDate ? new Date(String(data.departureDate)) : null
|
||||
const reminder = dep && !Number.isNaN(dep.getTime())
|
||||
? new Date(dep.getTime() - 3 * 60 * 60 * 1000)
|
||||
: null
|
||||
const html = `<p><strong>${title}</strong></p><p>${data.departureAirport || ''} → ${data.arrivalAirport || ''}</p><p>Confirmation: ${data.confirmationCode || '—'}</p>`
|
||||
return { title, content: html, reminder }
|
||||
}
|
||||
if (category === 'package') {
|
||||
const title = `Colis ${data.trackingNumber || data.carrier || ''}`.trim() || subject.slice(0, 80)
|
||||
const del = data.expectedDelivery ? new Date(String(data.expectedDelivery)) : null
|
||||
const reminder = del && !Number.isNaN(del.getTime()) ? del : null
|
||||
const html = `<p><strong>${title}</strong></p><p>Transporteur: ${data.carrier || '—'}</p><p>Suivi: ${data.trackingNumber || '—'}</p><p>Statut: ${data.status || '—'}</p>`
|
||||
return { title, content: html, reminder }
|
||||
}
|
||||
const title = `Abonnement ${data.serviceName || ''}`.trim() || subject.slice(0, 80)
|
||||
const ren = data.renewalDate ? new Date(String(data.renewalDate)) : null
|
||||
const reminder = ren && !Number.isNaN(ren.getTime())
|
||||
? new Date(ren.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
: null
|
||||
const html = `<p><strong>${title}</strong></p><p>Renouvellement: ${data.renewalDate || '—'}</p><p>Montant: ${data.amount ?? '—'} ${data.currency || ''}</p>`
|
||||
return { title, content: html, reminder }
|
||||
}
|
||||
|
||||
export class GmailScannerService {
|
||||
async scanUser(userId: string): Promise<{ processed: number; created: number }> {
|
||||
const tokens = await getGoogleTokens(userId, 'gmail')
|
||||
if (!tokens) return { processed: 0, created: 0 }
|
||||
|
||||
let processed = 0
|
||||
let created = 0
|
||||
|
||||
for (const { category, q } of SCAN_QUERIES) {
|
||||
const listRes = await gmailFetch(
|
||||
userId,
|
||||
`/messages?maxResults=5&q=${encodeURIComponent(q)}`,
|
||||
tokens.accessToken,
|
||||
tokens.refreshToken,
|
||||
)
|
||||
if (!listRes.ok) continue
|
||||
const listJson = await listRes.json()
|
||||
const ids: string[] = (listJson.messages || []).map((m: { id: string }) => m.id)
|
||||
|
||||
for (const messageId of ids) {
|
||||
const exists = await prisma.gmailScanHistory.findUnique({
|
||||
where: { userId_messageId: { userId, messageId } },
|
||||
})
|
||||
if (exists) continue
|
||||
|
||||
const msgRes = await gmailFetch(
|
||||
userId,
|
||||
`/messages/${messageId}?format=full`,
|
||||
tokens.accessToken,
|
||||
tokens.refreshToken,
|
||||
)
|
||||
if (!msgRes.ok) continue
|
||||
processed++
|
||||
|
||||
const msg = await msgRes.json()
|
||||
const headers: { name: string; value: string }[] = msg.payload?.headers || []
|
||||
const subject = headers.find(h => h.name.toLowerCase() === 'subject')?.value || '(Sans objet)'
|
||||
const body = extractBody(msg.payload)
|
||||
|
||||
const extracted = await extractStructured(category, subject, body)
|
||||
if (!extracted) {
|
||||
await prisma.gmailScanHistory.create({
|
||||
data: { userId, messageId, category, data: { subject, skipped: true } },
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const built = buildNoteFromExtraction(category, subject, extracted)
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
userId,
|
||||
title: built.title,
|
||||
content: built.content,
|
||||
type: 'richtext',
|
||||
labels: JSON.stringify(['gmail', category]),
|
||||
reminder: built.reminder,
|
||||
autoGenerated: true,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.gmailScanHistory.create({
|
||||
data: {
|
||||
userId,
|
||||
messageId,
|
||||
category,
|
||||
data: extracted as object,
|
||||
noteId: note.id,
|
||||
},
|
||||
})
|
||||
created++
|
||||
}
|
||||
}
|
||||
|
||||
return { processed, created }
|
||||
}
|
||||
|
||||
async getStatus(userId: string) {
|
||||
const tokens = await getGoogleTokens(userId, 'gmail')
|
||||
const recent = await prisma.gmailScanHistory.count({
|
||||
where: {
|
||||
userId,
|
||||
scannedAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
|
||||
noteId: { not: null },
|
||||
},
|
||||
})
|
||||
return { connected: !!tokens, recentCaptures: recent }
|
||||
}
|
||||
}
|
||||
|
||||
export const gmailScannerService = new GmailScannerService()
|
||||
66
memento-note/lib/integrations/google-integration-tokens.ts
Normal file
66
memento-note/lib/integrations/google-integration-tokens.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
|
||||
export function getGoogleOAuthCredentials() {
|
||||
return {
|
||||
clientId: process.env.GOOGLE_CALENDAR_CLIENT_ID || process.env.AUTH_GOOGLE_ID || '',
|
||||
clientSecret: process.env.GOOGLE_CALENDAR_CLIENT_SECRET || process.env.AUTH_GOOGLE_SECRET || '',
|
||||
}
|
||||
}
|
||||
|
||||
export async function readIntegrationMeta(userId: string): Promise<Record<string, unknown>> {
|
||||
const aiSettings = await prisma.userAISettings.findUnique({
|
||||
where: { userId },
|
||||
select: { integrationTokens: true },
|
||||
})
|
||||
try {
|
||||
const raw = aiSettings?.integrationTokens
|
||||
if (!raw) return {}
|
||||
return typeof raw === 'string' ? JSON.parse(raw) : (raw as Record<string, unknown>)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeIntegrationMeta(userId: string, meta: Record<string, unknown>) {
|
||||
await prisma.userAISettings.upsert({
|
||||
where: { userId },
|
||||
update: { integrationTokens: JSON.stringify(meta) },
|
||||
create: { userId, integrationTokens: JSON.stringify(meta) },
|
||||
})
|
||||
}
|
||||
|
||||
export async function getGoogleTokens(
|
||||
userId: string,
|
||||
prefix: 'calendar' | 'gmail',
|
||||
): Promise<{ accessToken: string; refreshToken: string } | null> {
|
||||
const meta = await readIntegrationMeta(userId)
|
||||
const accessToken = meta[`${prefix}AccessToken`] as string | undefined
|
||||
const refreshToken = meta[`${prefix}RefreshToken`] as string | undefined
|
||||
if (accessToken && refreshToken) return { accessToken, refreshToken }
|
||||
return null
|
||||
}
|
||||
|
||||
export async function refreshGoogleAccessToken(
|
||||
userId: string,
|
||||
prefix: 'calendar' | 'gmail',
|
||||
refreshToken: string,
|
||||
): Promise<string | null> {
|
||||
const { clientId, clientSecret } = getGoogleOAuthCredentials()
|
||||
const res = 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,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!data.access_token) return null
|
||||
|
||||
const meta = await readIntegrationMeta(userId)
|
||||
meta[`${prefix}AccessToken`] = data.access_token
|
||||
await writeIntegrationMeta(userId, meta)
|
||||
return data.access_token as string
|
||||
}
|
||||
Reference in New Issue
Block a user