feat: Tier 1 & 2 — Daily Note, Voice, Flashcard quota, Readwise, Calendar, Agent Gallery
Tier 1: - BASIC tier: chat (10/mo) + reformulate (10/mo) désormais accessibles - Nouveaux quotas: ai_flashcard + voice_transcribe dans tous les tiers - /api/notes/daily : note du jour auto-créée (find or create) - Bouton Note du Jour dans la sidebar (CalendarDays) - Voice-to-Text dans l'éditeur (Web Speech API, bouton Mic toolbar) - Flashcard generation → quota ai_flashcard (au lieu de reformulate) Tier 2: - Intégration Readwise: GET/POST/DELETE /api/integrations/readwise - Intégration Google Calendar: OAuth flow + today's events + meeting notes - /api/integrations/calendar + /callback - Page /settings/integrations avec cards Calendar + Readwise - SettingsNav: onglet Intégrations - AgentTemplates: catégories + 4 nouveaux templates (Digest/Recap/AutoTagger/Synthesis) Schema: - UserAISettings.integrationTokens Json? (migration 20260529160000) - prisma generate + migrate deploy appliqués Fix: - SpeechRecognition types (triple-slash @types/dom-speech-recognition) - Notebook.create: suppression champ 'description' inexistant Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
69
memento-note/app/api/integrations/calendar/callback/route.ts
Normal file
69
memento-note/app/api/integrations/calendar/callback/route.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* GET /api/integrations/calendar/callback
|
||||
* Google OAuth callback — exchanges code for tokens and persists them.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
|
||||
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'
|
||||
|
||||
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=calendar_auth_failed`,
|
||||
)
|
||||
}
|
||||
|
||||
const clientId = process.env.GOOGLE_CALENDAR_CLIENT_ID || process.env.AUTH_GOOGLE_ID || ''
|
||||
const clientSecret = process.env.GOOGLE_CALENDAR_CLIENT_SECRET || process.env.AUTH_GOOGLE_SECRET || ''
|
||||
const redirectUri = `${url.origin}/api/integrations/calendar/callback`
|
||||
|
||||
const tokenRes = await fetch(GOOGLE_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
}),
|
||||
})
|
||||
|
||||
const tokenData = await tokenRes.json()
|
||||
|
||||
if (!tokenData.access_token) {
|
||||
return NextResponse.redirect(
|
||||
`${url.origin}/settings/integrations?error=calendar_token_failed`,
|
||||
)
|
||||
}
|
||||
|
||||
// Persist tokens in UserAISettings.integrationTokens
|
||||
const aiSettings = await prisma.userAISettings.findUnique({
|
||||
where: { userId },
|
||||
select: { integrationTokens: true },
|
||||
})
|
||||
const meta =
|
||||
typeof aiSettings?.integrationTokens === 'string'
|
||||
? JSON.parse(aiSettings.integrationTokens)
|
||||
: (aiSettings?.integrationTokens as Record<string, unknown> | null) ?? {}
|
||||
|
||||
meta.calendarAccessToken = tokenData.access_token
|
||||
if (tokenData.refresh_token) {
|
||||
meta.calendarRefreshToken = tokenData.refresh_token
|
||||
}
|
||||
|
||||
await prisma.userAISettings.upsert({
|
||||
where: { userId },
|
||||
update: { integrationTokens: JSON.stringify(meta) },
|
||||
create: { userId, integrationTokens: JSON.stringify(meta) },
|
||||
})
|
||||
|
||||
return NextResponse.redirect(`${url.origin}/settings/integrations?connected=calendar`)
|
||||
}
|
||||
232
memento-note/app/api/integrations/calendar/route.ts
Normal file
232
memento-note/app/api/integrations/calendar/route.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Google Calendar Integration
|
||||
*
|
||||
* OAuth flow (simplified — uses the existing Google account from NextAuth):
|
||||
* 1. GET /api/integrations/calendar → redirect to Google OAuth consent (calendar scope)
|
||||
* 2. GET /api/integrations/calendar/callback → exchange code, store refresh token
|
||||
*
|
||||
* Usage after auth:
|
||||
* GET /api/integrations/calendar?events=1 → list today's events
|
||||
* POST /api/integrations/calendar → create a meeting note from an event id
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import prisma from '@/lib/prisma'
|
||||
|
||||
const CALENDAR_SCOPE = 'https://www.googleapis.com/auth/calendar.readonly'
|
||||
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
|
||||
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'
|
||||
const GOOGLE_CALENDAR_API = 'https://www.googleapis.com/calendar/v3'
|
||||
|
||||
function getClientId() {
|
||||
return process.env.GOOGLE_CALENDAR_CLIENT_ID || process.env.AUTH_GOOGLE_ID || ''
|
||||
}
|
||||
function getClientSecret() {
|
||||
return process.env.GOOGLE_CALENDAR_CLIENT_SECRET || process.env.AUTH_GOOGLE_SECRET || ''
|
||||
}
|
||||
function getRedirectUri(baseUrl: string) {
|
||||
return `${baseUrl}/api/integrations/calendar/callback`
|
||||
}
|
||||
|
||||
async function getStoredTokens(userId: string): Promise<{ accessToken: string; refreshToken: string } | null> {
|
||||
const aiSettings = await prisma.userAISettings.findUnique({
|
||||
where: { userId },
|
||||
select: { integrationTokens: true },
|
||||
})
|
||||
try {
|
||||
const meta =
|
||||
typeof aiSettings?.integrationTokens === 'string'
|
||||
? JSON.parse(aiSettings.integrationTokens)
|
||||
: (aiSettings?.integrationTokens as Record<string, unknown> | null) ?? {}
|
||||
if (meta?.calendarAccessToken && meta?.calendarRefreshToken) {
|
||||
return {
|
||||
accessToken: meta.calendarAccessToken as string,
|
||||
refreshToken: meta.calendarRefreshToken as string,
|
||||
}
|
||||
}
|
||||
} catch { /* no-op */ }
|
||||
return null
|
||||
}
|
||||
|
||||
async function refreshAccessToken(userId: string, refreshToken: string): Promise<string | null> {
|
||||
const res = await fetch(GOOGLE_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: getClientId(),
|
||||
client_secret: getClientSecret(),
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!data.access_token) return null
|
||||
|
||||
// Persist refreshed access token
|
||||
const aiSettings = await prisma.userAISettings.findUnique({ where: { userId }, select: { integrationTokens: true } })
|
||||
const meta = typeof aiSettings?.integrationTokens === 'string' ? JSON.parse(aiSettings.integrationTokens) : {}
|
||||
meta.calendarAccessToken = data.access_token
|
||||
await prisma.userAISettings.upsert({
|
||||
where: { userId },
|
||||
update: { integrationTokens: JSON.stringify(meta) },
|
||||
create: { userId, integrationTokens: JSON.stringify(meta) },
|
||||
})
|
||||
|
||||
return data.access_token
|
||||
}
|
||||
|
||||
/** GET /api/integrations/calendar — status check or redirect to OAuth */
|
||||
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 connect = url.searchParams.get('connect')
|
||||
const events = url.searchParams.get('events')
|
||||
const userId = session.user.id
|
||||
|
||||
// Check connection status
|
||||
if (!connect && !events) {
|
||||
const tokens = await getStoredTokens(userId)
|
||||
return NextResponse.json({ connected: !!tokens })
|
||||
}
|
||||
|
||||
// Start OAuth flow
|
||||
if (connect === '1') {
|
||||
const clientId = getClientId()
|
||||
if (!clientId) {
|
||||
return NextResponse.json({ error: 'Google OAuth not configured' }, { status: 503 })
|
||||
}
|
||||
const baseUrl = url.origin
|
||||
const authUrl = new URL(GOOGLE_AUTH_URL)
|
||||
authUrl.searchParams.set('client_id', clientId)
|
||||
authUrl.searchParams.set('redirect_uri', getRedirectUri(baseUrl))
|
||||
authUrl.searchParams.set('response_type', 'code')
|
||||
authUrl.searchParams.set('scope', CALENDAR_SCOPE)
|
||||
authUrl.searchParams.set('access_type', 'offline')
|
||||
authUrl.searchParams.set('prompt', 'consent')
|
||||
authUrl.searchParams.set('state', userId)
|
||||
return NextResponse.redirect(authUrl.toString())
|
||||
}
|
||||
|
||||
// Fetch today's events
|
||||
if (events === '1') {
|
||||
let tokens = await getStoredTokens(userId)
|
||||
if (!tokens) {
|
||||
return NextResponse.json({ error: 'Calendar not connected' }, { status: 400 })
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString()
|
||||
const endOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59).toISOString()
|
||||
|
||||
const fetchEvents = async (accessToken: string) =>
|
||||
fetch(
|
||||
`${GOOGLE_CALENDAR_API}/calendars/primary/events?` +
|
||||
new URLSearchParams({
|
||||
timeMin: startOfDay,
|
||||
timeMax: endOfDay,
|
||||
singleEvents: 'true',
|
||||
orderBy: 'startTime',
|
||||
}),
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
||||
)
|
||||
|
||||
let res = await fetchEvents(tokens.accessToken)
|
||||
|
||||
// Token might be expired — refresh and retry
|
||||
if (res.status === 401 && tokens.refreshToken) {
|
||||
const newToken = await refreshAccessToken(userId, tokens.refreshToken)
|
||||
if (!newToken) {
|
||||
return NextResponse.json({ error: 'Token refresh failed — please reconnect Calendar' }, { status: 401 })
|
||||
}
|
||||
res = await fetchEvents(newToken)
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
const items = (data.items ?? []).map((e: any) => ({
|
||||
id: e.id,
|
||||
summary: e.summary || '(Sans titre)',
|
||||
start: e.start?.dateTime || e.start?.date,
|
||||
end: e.end?.dateTime || e.end?.date,
|
||||
location: e.location,
|
||||
description: e.description,
|
||||
htmlLink: e.htmlLink,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ events: items })
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
|
||||
}
|
||||
|
||||
/** POST /api/integrations/calendar — create a meeting note from a calendar event */
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const body = await req.json().catch(() => ({}))
|
||||
const eventId = body.eventId as string | undefined
|
||||
const summary = body.summary as string | undefined
|
||||
const start = body.start as string | undefined
|
||||
|
||||
if (!summary) {
|
||||
return NextResponse.json({ error: 'summary required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const dateStr = start ? new Date(start).toLocaleDateString('fr-FR') : new Date().toLocaleDateString('fr-FR')
|
||||
const timeStr = start ? new Date(start).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }) : ''
|
||||
|
||||
const content = JSON.stringify({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: `🗓 ${summary}` }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: `📅 ${dateStr}${timeStr ? ` à ${timeStr}` : ''}` }] },
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '📋 Ordre du jour' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '' }] },
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '📝 Notes' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '' }] },
|
||||
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '✅ Actions à faire' }] },
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: '' }] },
|
||||
],
|
||||
})
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
userId,
|
||||
title: `${summary} — ${dateStr}`,
|
||||
content,
|
||||
type: 'meeting',
|
||||
labels: JSON.stringify(['réunion', 'calendar']),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, note })
|
||||
}
|
||||
|
||||
/** DELETE /api/integrations/calendar — disconnect Calendar */
|
||||
export async function DELETE() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const aiSettings = await prisma.userAISettings.findUnique({ where: { userId }, select: { integrationTokens: true } })
|
||||
if (aiSettings) {
|
||||
try {
|
||||
const meta = ((aiSettings.integrationTokens as Record<string, unknown>) ?? {})
|
||||
delete meta.calendarAccessToken
|
||||
delete meta.calendarRefreshToken
|
||||
await prisma.userAISettings.update({ where: { userId }, data: { integrationTokens: JSON.stringify(meta) } })
|
||||
} catch { /* no-op */ }
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
Reference in New Issue
Block a user