/** * 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 | 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 { 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') { const 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) ?? {}) 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 }) }