feat: Tier 1 & 2 — Daily Note, Voice, Flashcard quota, Readwise, Calendar, Agent Gallery
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m7s
CI / Deploy production (on server) (push) Has been skipped

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:
Antigravity
2026-05-29 15:14:01 +00:00
parent 79fd6553b7
commit c415d93945
20 changed files with 1271 additions and 63 deletions

View File

@@ -0,0 +1,289 @@
'use client'
import { useState, useEffect } from 'react'
import { BookOpen, Loader2, Check, X, RefreshCw, Trash2, CalendarDays } from 'lucide-react'
import { toast } from 'sonner'
export default function IntegrationsPage() {
// ── Readwise ───────────────────────────────────────────────────────────
const [rwToken, setRwToken] = useState('')
const [rwConnected, setRwConnected] = useState(false)
const [rwSyncing, setRwSyncing] = useState(false)
const [rwConnecting, setRwConnecting] = useState(false)
const [rwLastSync, setRwLastSync] = useState<{ created: number; updated: number } | null>(null)
// ── Google Calendar ────────────────────────────────────────────────────
const [calConnected, setCalConnected] = useState(false)
const [calLoading, setCalLoading] = useState(true)
const [calEvents, setCalEvents] = useState<any[]>([])
const [calFetching, setCalFetching] = 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]) => {
setRwConnected(rw.connected)
setCalConnected(cal.connected)
}).finally(() => {
setLoading(false)
setCalLoading(false)
})
// Handle redirect params
const params = new URLSearchParams(window.location.search)
if (params.get('connected') === 'calendar') {
setCalConnected(true)
toast.success('Google Calendar connecté !')
window.history.replaceState({}, '', '/settings/integrations')
}
if (params.get('error')) {
toast.error(`Erreur: ${params.get('error')}`)
window.history.replaceState({}, '', '/settings/integrations')
}
}, [])
// ── Readwise handlers ──────────────────────────────────────────────────
const handleRwConnect = async () => {
if (!rwToken.trim()) return
setRwConnecting(true)
try {
const res = await fetch('/api/integrations/readwise', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: rwToken.trim() }),
})
const data = await res.json()
if (!res.ok) { toast.error(data.error || 'Erreur Readwise'); 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) }
}
const handleRwSync = async () => {
setRwSyncing(true)
try {
const res = await fetch('/api/integrations/readwise', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const data = await res.json()
if (!res.ok) { toast.error(data.error || 'Erreur de sync'); 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) }
}
const handleRwDisconnect = async () => {
await fetch('/api/integrations/readwise', { method: 'DELETE' })
setRwConnected(false)
toast.success('Readwise déconnecté')
}
// ── Calendar handlers ──────────────────────────────────────────────────
const handleCalConnect = () => {
window.location.href = '/api/integrations/calendar?connect=1'
}
const handleCalFetchEvents = async () => {
setCalFetching(true)
try {
const res = await fetch('/api/integrations/calendar?events=1')
const data = await res.json()
if (!res.ok) { toast.error(data.error || 'Erreur'); 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) }
}
const handleCreateMeetingNote = async (event: any) => {
const res = await fetch('/api/integrations/calendar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ eventId: event.id, summary: event.summary, start: event.start }),
})
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}` },
})
} else {
toast.error('Erreur lors de la création de la note')
}
}
const handleCalDisconnect = async () => {
await fetch('/api/integrations/calendar', { method: 'DELETE' })
setCalConnected(false)
setCalEvents([])
toast.success('Google Calendar déconnecté')
}
const StatusBadge = ({ connected }: { connected: boolean }) =>
connected ? (
<span className="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-950/30 border border-emerald-200 dark:border-emerald-800/40 rounded-full px-2.5 py-1">
<Check size={11} /> Connecté
</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é
</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 à Momento.</p>
</div>
{/* ── Google Calendar ────────────────────────────────────────────── */}
<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-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<CalendarDays size={20} className="text-blue-600 dark:text-blue-400" />
</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>
</div>
</div>
{calLoading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={calConnected} />}
</div>
{!calConnected ? (
<button
onClick={handleCalConnect}
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
</button>
) : (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<button
onClick={handleCalFetchEvents}
disabled={calFetching}
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
</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
</button>
</div>
{calEvents.length > 0 && (
<div className="space-y-2">
{calEvents.map((ev) => (
<div key={ev.id} className="flex items-center justify-between gap-3 px-4 py-3 bg-border/10 rounded-xl border border-border/20">
<div className="min-w-0">
<p className="text-sm font-medium text-ink truncate">{ev.summary}</p>
<p className="text-[11px] text-concrete">
{ev.start ? new Date(ev.start).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }) : ''}
</p>
</div>
<button
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
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* ── Readwise ──────────────────────────────────────────────────── */}
<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-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<BookOpen size={20} className="text-amber-600 dark:text-amber-400" />
</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>
</div>
</div>
{loading ? <Loader2 size={16} className="animate-spin text-concrete mt-2" /> : <StatusBadge connected={rwConnected} />}
</div>
{!rwConnected && (
<div className="space-y-3">
<p className="text-[12px] text-concrete">
Trouvez votre token sur{' '}
<a href="https://readwise.io/access_token" target="_blank" rel="noopener noreferrer" className="text-brand-accent underline">
readwise.io/access_token
</a>
</p>
<div className="flex gap-2">
<input
type="password"
value={rwToken}
onChange={(e) => setRwToken(e.target.value)}
placeholder="Token Readwise…"
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()}
/>
<button
onClick={handleRwConnect}
disabled={!rwToken.trim() || rwConnecting}
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
</button>
</div>
</div>
)}
{rwConnected && (
<div className="flex flex-wrap gap-2">
<button
onClick={handleRwSync}
disabled={rwSyncing}
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
</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
</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
</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>
</div>
</div>
)
}

View File

@@ -49,7 +49,7 @@ export async function POST(request: NextRequest) {
}
try {
await checkEntitlementOrThrow(session.user.id, 'reformulate')
await checkEntitlementOrThrow(session.user.id, 'ai_flashcard')
} catch (err) {
if (err instanceof QuotaExceededError) {
const isTierLocked = err.currentQuota === 0
@@ -70,7 +70,7 @@ export async function POST(request: NextRequest) {
language: note.language || undefined,
})
incrementUsageAsync(session.user.id, 'reformulate')
incrementUsageAsync(session.user.id, 'ai_flashcard')
return NextResponse.json({ cards, noteId: note.id, style })
} catch (error) {

View 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`)
}

View 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 })
}

View File

@@ -0,0 +1,258 @@
/**
* POST /api/integrations/readwise/sync
* Syncs Readwise highlights into Momento notes.
* Each book/article becomes a note with all its highlights listed.
*
* Query params:
* ?token=xxx (Readwise API token) — used for initial test; stored in UserAISettings.integrationTokens
*/
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
const READWISE_API = 'https://readwise.io/api/v2'
interface ReadwiseHighlight {
id: number
text: string
note: string
location: number
book_id: number
highlighted_at: string | null
url: string | null
color: string
}
interface ReadwiseBook {
id: number
title: string
author: string | null
category: string
source: string
num_highlights: number
last_highlight_at: string | null
cover_image_url: string | null
source_url: string | null
}
interface ReadwisePaginatedResponse<T> {
count: number
next: string | null
results: T[]
}
async function fetchAllPages<T>(url: string, token: string): Promise<T[]> {
const results: T[] = []
let nextUrl: string | null = url
while (nextUrl) {
const res = await fetch(nextUrl, {
headers: { Authorization: `Token ${token}` },
})
if (!res.ok) throw new Error(`Readwise API error: ${res.status}`)
const data: ReadwisePaginatedResponse<T> = await res.json()
results.push(...data.results)
nextUrl = data.next
}
return results
}
function buildNoteContent(book: ReadwiseBook, highlights: ReadwiseHighlight[]): string {
const sourceLink = book.source_url
? `<p><a href="${book.source_url}">${book.source_url}</a></p>`
: ''
const highlightLines = highlights
.sort((a, b) => a.location - b.location)
.map((h) => {
const note = h.note ? `<blockquote>${h.note}</blockquote>` : ''
return `<li>${h.text}${note}</li>`
})
.join('\n')
return `<h1>📚 ${book.title}</h1>
${book.author ? `<p><em>par ${book.author}</em></p>` : ''}
${sourceLink}
<p><strong>${highlights.length} surlignage${highlights.length > 1 ? 's' : ''}</strong></p>
<ul>
${highlightLines}
</ul>`
}
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
// Retrieve stored Readwise token from UserAISettings metadata
const aiSettings = await prisma.userAISettings.findUnique({
where: { userId },
select: { integrationTokens: true },
})
let token: string | undefined
try {
const meta = typeof aiSettings?.integrationTokens === 'string'
? JSON.parse(aiSettings.integrationTokens)
: (aiSettings?.integrationTokens as Record<string, unknown> | null) ?? {}
token = meta?.readwiseToken as string | undefined
} catch {
token = undefined
}
// Allow passing token directly (for test/setup)
const body = await req.json().catch(() => ({}))
if (body.token && typeof body.token === 'string') {
token = body.token
// Persist token
await prisma.userAISettings.upsert({
where: { userId },
update: {
integrationTokens: JSON.stringify({
...(typeof aiSettings?.integrationTokens === 'string' ? JSON.parse(aiSettings.integrationTokens) : {}),
readwiseToken: body.token,
}),
},
create: {
userId,
integrationTokens: { readwiseToken: body.token },
},
})
}
if (!token) {
return NextResponse.json({ error: 'Readwise token not configured' }, { status: 400 })
}
try {
// Fetch books and highlights
const [books, highlights] = await Promise.all([
fetchAllPages<ReadwiseBook>(`${READWISE_API}/books/?page_size=100`, token),
fetchAllPages<ReadwiseHighlight>(`${READWISE_API}/highlights/?page_size=500`, token),
])
// Group highlights by book
const byBook = new Map<number, ReadwiseHighlight[]>()
for (const h of highlights) {
const arr = byBook.get(h.book_id) ?? []
arr.push(h)
byBook.set(h.book_id, arr)
}
// Find or create a "Readwise" notebook
let notebook = await prisma.notebook.findFirst({
where: { userId, name: 'Readwise' },
})
if (!notebook) {
notebook = await prisma.notebook.create({
data: { userId, name: 'Readwise', icon: '📚', order: 999 },
})
}
let created = 0
let updated = 0
for (const book of books) {
const bookHighlights = byBook.get(book.id) ?? []
if (bookHighlights.length === 0) continue
const content = buildNoteContent(book, bookHighlights)
const existing = await prisma.note.findFirst({
where: { userId, notebookId: notebook.id, title: book.title, trashedAt: null },
})
if (existing) {
await prisma.note.update({
where: { id: existing.id },
data: { content, updatedAt: new Date() },
})
updated++
} else {
await prisma.note.create({
data: {
userId,
notebookId: notebook.id,
title: book.title,
content,
labels: JSON.stringify(['readwise', book.category]),
},
})
created++
}
}
return NextResponse.json({
success: true,
created,
updated,
notebookId: notebook.id,
books: books.length,
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
console.error('[readwise/sync]', message)
return NextResponse.json({ error: message }, { status: 500 })
}
}
/**
* GET /api/integrations/readwise/sync
* Returns Readwise connection status (token configured or not).
*/
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const aiSettings = await prisma.userAISettings.findUnique({
where: { userId: session.user.id },
select: { integrationTokens: true },
})
let connected = false
try {
const meta = typeof aiSettings?.integrationTokens === 'string'
? JSON.parse(aiSettings.integrationTokens)
: (aiSettings?.integrationTokens as Record<string, unknown> | null) ?? {}
connected = !!(meta?.readwiseToken)
} catch {
connected = false
}
return NextResponse.json({ connected })
}
/**
* DELETE /api/integrations/readwise/sync
* Removes the stored Readwise token.
*/
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> | null) ?? {})
delete meta.readwiseToken
await prisma.userAISettings.update({
where: { userId },
data: { integrationTokens: JSON.stringify(meta) },
})
} catch { /* no-op */ }
}
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,64 @@
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
function getTodayTitle(): string {
return new Date().toISOString().slice(0, 10) // YYYY-MM-DD
}
function getTodayContent(dateStr: string): string {
const d = new Date(dateStr)
const options: Intl.DateTimeFormatOptions = {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
}
const formatted = d.toLocaleDateString('fr-FR', options)
return JSON.stringify({
type: 'doc',
content: [
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: `📅 ${formatted}` }] },
{ type: 'paragraph', content: [{ type: 'text', text: '' }] },
],
})
}
/**
* GET /api/notes/daily
* Returns (or creates) today's daily note for the authenticated user.
*/
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const today = getTodayTitle()
const userId = session.user.id
// Try to find existing daily note for today
let note = await prisma.note.findFirst({
where: {
userId,
title: today,
type: 'daily',
trashedAt: null,
},
})
if (!note) {
note = await prisma.note.create({
data: {
userId,
title: today,
content: getTodayContent(today),
type: 'daily',
color: '#FEF9C3', // yellow-100 — distinguishes daily notes visually
labels: JSON.stringify(['daily']),
},
})
}
return NextResponse.json({ success: true, note })
}