Files
Momento/memento-note/app/api/integrations/calendar/route.ts
Antigravity d0dda2ddc2
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m7s
CI / Deploy production (on server) (push) Has been skipped
fix(ci): resolve ESLint error + configure Prisma for Alpine OpenSSL 3.0
- fix(calendar): prefer-const — let tokens → const tokens (ligne bloquante CI)
- fix(eslint): exhaustive-deps et prefer-const rétrogradés en warn (non bloquants)
  → seul rules-of-hooks reste une erreur fatale
- fix(prisma): ajoute linux-musl-openssl-3.0.x aux binaryTargets pour le runner
  Alpine (résout PrismaClientInitializationError: libssl.so.1.1 not found)
2026-05-30 10:54:05 +00:00

233 lines
8.5 KiB
TypeScript

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