import { type Router as ExpressRouter, Router, Request, Response } from 'express'; import { nanoid } from 'nanoid'; import { db } from '../db/index.js'; import { integrationTokens } from '../db/schema.js'; import { eq, and } from 'drizzle-orm'; import { config } from '../config.js'; import { requireAuth, AuthenticatedRequest } from '../middleware/session.js'; const router: ExpressRouter = Router(); const TODOIST_OAUTH_URL = 'https://todoist.com/oauth/authorize'; const TODOIST_TOKEN_URL = 'https://todoist.com/oauth/access_token'; const TODOIST_SCOPES = 'data:read_write'; // In-memory CSRF state store const pendingStates = new Map(); /** GET /api/integrations — list connected integrations */ router.get('/', requireAuth, async (req: AuthenticatedRequest, res: Response) => { const tokens = await db .select() .from(integrationTokens) .where(eq(integrationTokens.userId, req.userId!)); const integrations = tokens.map((t) => ({ provider: t.provider, status: t.tokenStatus === 'needs_reconnect' ? 'needs_reconnect' : 'connected', connectedAt: t.connectedAt, })); res.json({ integrations }); }); /** GET /api/integrations/todoist/connect — start OAuth */ router.get('/todoist/connect', requireAuth, (req: AuthenticatedRequest, res: Response) => { const state = nanoid(); pendingStates.set(state, { userId: req.userId!, redirectTo: (req.query.redirectTo as string) ?? '/connect', }); setTimeout(() => pendingStates.delete(state), 10 * 60 * 1000); const url = new URL(TODOIST_OAUTH_URL); url.searchParams.set('client_id', config.TODOIST_CLIENT_ID); url.searchParams.set('scope', TODOIST_SCOPES); url.searchParams.set('state', state); url.searchParams.set('redirect_uri', `${config.API_BASE_URL}/api/integrations/todoist/callback`); res.redirect(url.toString()); }); /** GET /api/integrations/todoist/callback — Todoist returns here */ router.get('/todoist/callback', async (req: Request, res: Response) => { const state = req.query.state as string; const code = req.query.code as string; const pending = pendingStates.get(state); if (!pending) { res.status(400).json({ error: 'Invalid or expired state' }); return; } pendingStates.delete(state); // Exchange code for token const body = new URLSearchParams({ client_id: config.TODOIST_CLIENT_ID, client_secret: config.TODOIST_CLIENT_SECRET, code, redirect_uri: `${config.API_BASE_URL}/api/integrations/todoist/callback`, }); const tokenRes = await fetch(TODOIST_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, body: body.toString(), }); if (!tokenRes.ok) { res.status(502).json({ error: 'Failed to exchange Todoist token' }); return; } const { access_token } = (await tokenRes.json()) as { access_token: string }; const now = new Date().toISOString(); const id = nanoid(); // Delete existing token (if any) then insert fresh await db .delete(integrationTokens) .where( and( eq(integrationTokens.userId, pending.userId), eq(integrationTokens.provider, 'todoist'), ), ); await db.insert(integrationTokens).values({ id, userId: pending.userId, provider: 'todoist', accessToken: access_token, tokenStatus: 'active', connectedAt: now, }); res.redirect(`${config.WEB_BASE_URL}${pending.redirectTo}?connected=todoist`); }); /** DELETE /api/integrations/:provider — revoke token */ router.delete('/:provider', requireAuth, async (req: AuthenticatedRequest, res: Response) => { const provider = String(req.params.provider); const [token] = await db .select() .from(integrationTokens) .where( and( eq(integrationTokens.userId, req.userId!), eq(integrationTokens.provider, provider), ), ) .limit(1); if (token?.provider === 'todoist') { // Best-effort revocation await fetch('https://api.todoist.com/sync/v9/access_tokens/revoke', { method: 'POST', headers: { Authorization: `Bearer ${token.accessToken}` }, }).catch(() => {}); } await db .delete(integrationTokens) .where( and( eq(integrationTokens.userId, req.userId!), eq(integrationTokens.provider, provider), ), ); res.json({ ok: true }); }); export { router as integrationsRouter };