feat: Add Microsoft To-Do and Outlook Calendar integration

Adds full OAuth2 flow, bidirectional task sync, calendar event CRUD,
caching with background refresh, and shared token management for
Microsoft/Outlook services. Extracts common token refresh logic into
shared outlook-token.ts utility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-02-21 22:07:37 +01:00
parent ccb34d12d5
commit db00642c21
15 changed files with 911 additions and 163 deletions

View File

@ -80,6 +80,7 @@ model User {
tasks Task[]
somedayLists SomedayList[]
calendarConnections CalendarConnection[]
cachedCalendarEvents CachedCalendarEvent[]
weeklyGoals WeeklyGoal[]
}
@ -148,7 +149,7 @@ model Task {
// External Integration
externalId String?
externalProvider String? // "google" | "apple"
externalProvider String? // "google" | "apple" | "outlook"
externalListId String?
lastSyncedAt DateTime?
@ -183,9 +184,40 @@ model CalendarConnection {
calendars Json? // Stores array of { id, title, isPrimary, selected }
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
cachedEvents CachedCalendarEvent[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model CachedCalendarEvent {
id String @id @default(cuid())
userId String
externalId String
connectionId String
provider String // "google" | "apple" | "outlook"
calendarId String
calendarTitle String
calendarColor String?
title String
description String? @db.Text
location String?
startDateTime DateTime?
startDate String? // YYYY-MM-DD for all-day events
endDateTime DateTime?
endDate String? // YYYY-MM-DD for all-day events
weekStart DateTime
syncedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
connection CalendarConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
@@unique([userId, externalId, provider])
@@index([userId, startDateTime])
@@index([userId, startDate])
@@index([connectionId, weekStart])
}
model WeeklyGoal {
id String @id @default(cuid())
userId String

View File

@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { prisma } from '@/lib/prisma';
import { isCacheStale, refreshConnectionCache, RefreshableConnection } from '@/lib/calendar-cache';
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json().catch(() => ({}));
const { timeMin, timeMax } = body;
const user = await prisma.user.findUnique({
where: { email: session.user.email },
include: { calendarConnections: true },
});
if (!user || user.calendarConnections.length === 0) {
return NextResponse.json({ queued: 0 });
}
const now = new Date();
const tMin = timeMin ? new Date(timeMin) : new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const tMax = timeMax ? new Date(timeMax) : new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000);
const staleChecks = await Promise.all(
user.calendarConnections.map(async conn => ({
conn,
stale: await isCacheStale(conn.id, tMin),
}))
);
const toRefresh = staleChecks.filter(c => c.stale).map(c => c.conn);
if (toRefresh.length > 0) {
// Fire-and-forget
Promise.allSettled(
toRefresh.map(conn => {
const rc: RefreshableConnection = {
dbId: conn.id,
userId: user.id,
id: conn.id,
provider: conn.provider as 'google' | 'apple' | 'outlook',
accessToken: conn.accessToken,
refreshToken: conn.refreshToken ?? undefined,
expiresAt: conn.expiresAt ?? undefined,
calendars: conn.calendars as any,
};
return refreshConnectionCache(rc, tMin, tMax);
})
).catch(e => console.error('[BG SYNC]', e));
}
return NextResponse.json({ queued: toRefresh.length });
} catch (error) {
console.error('[BG SYNC] Error:', error);
return NextResponse.json({ queued: 0 });
}
}

View File

@ -1,9 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
import { prisma } from '@/lib/prisma';
import { refreshConnectionCache, RefreshableConnection } from '@/lib/calendar-cache';
// Get user's calendar connections
export async function GET(request: NextRequest) {
@ -98,6 +97,23 @@ export async function PATCH(request: NextRequest) {
}
});
// Fire-and-forget cache refresh for this connection (covers ±1 week)
const now = new Date();
const tMin = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const tMax = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000);
const rc: RefreshableConnection = {
dbId: updated.id,
userId: user.id,
id: updated.id,
provider: updated.provider as 'google' | 'apple' | 'outlook',
accessToken: updated.accessToken,
refreshToken: updated.refreshToken ?? undefined,
expiresAt: updated.expiresAt ?? undefined,
calendars: calendars,
};
refreshConnectionCache(rc, tMin, tMax)
.catch(e => console.error('[CACHE] Post-selection refresh failed:', e));
return NextResponse.json({ success: true, connection: updated });
} catch (error) {
console.error('Error updating calendar connection:', error);

View File

@ -1,10 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { PrismaClient } from '@prisma/client';
import { prisma } from '@/lib/prisma';
import { createCalendarEvent, updateCalendarEvent, deleteCalendarEvent, CalendarConnection } from '@/lib/calendar-events';
const prisma = new PrismaClient();
import { upsertCachedEvent, deleteCachedEvent } from '@/lib/calendar-cache';
// Helper to find connection by calendarId
async function findConnectionForCalendar(userId: string, calendarId: string) {
@ -63,6 +62,10 @@ export async function POST(request: NextRequest) {
location
});
// Update cache
upsertCachedEvent(userId, connection.id, connection.provider, event)
.catch(e => console.error('[CACHE] Failed to cache created event:', e));
return NextResponse.json({ event });
} catch (error: any) {
console.error('Error creating event:', error);
@ -100,6 +103,10 @@ export async function PATCH(request: NextRequest) {
location
});
// Update cache
upsertCachedEvent(userId, connection.id, connection.provider, event)
.catch(e => console.error('[CACHE] Failed to cache updated event:', e));
return NextResponse.json({ event });
} catch (error: any) {
console.error('Error updating event:', error);
@ -132,6 +139,10 @@ export async function DELETE(request: NextRequest) {
await deleteCalendarEvent(connection, calendarId, eventId);
// Remove from cache
deleteCachedEvent(userId, eventId, connection.provider)
.catch(e => console.error('[CACHE] Failed to delete cached event:', e));
return NextResponse.json({ success: true });
} catch (error: any) {
console.error('Error deleting event:', error);

View File

@ -1,104 +1,78 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { PrismaClient } from '@prisma/client';
import { getCalendarEvents, CalendarConnection } from '@/lib/calendar-events';
const prisma = new PrismaClient();
import { prisma } from '@/lib/prisma';
import { readCachedEvents, isCacheStale, refreshConnectionCache, RefreshableConnection } from '@/lib/calendar-cache';
export async function POST(request: NextRequest) {
console.log('[CALENDAR SYNC] Starting sync request...');
try {
const session = await getServerSession(authOptions);
console.log('[CALENDAR SYNC] Session:', session?.user?.email);
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { timeMin, timeMax, connectionId } = body;
console.log('[CALENDAR SYNC] Request params:', { timeMin, timeMax, connectionId });
const { timeMin, timeMax, connectionId, forceRefresh } = body;
const timeMinDate = new Date(timeMin ?? Date.now());
const timeMaxDate = new Date(timeMax ?? Date.now() + 7 * 24 * 60 * 60 * 1000);
// Get the user and their calendar connections
const user = await prisma.user.findUnique({
where: { email: session.user.email },
include: { calendarConnections: true }
include: { calendarConnections: true },
});
if (!user) {
console.log('[CALENDAR SYNC] User not found:', session.user.email);
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
console.log('[CALENDAR SYNC] Found user:', user.id, 'with', user.calendarConnections.length, 'connections');
// Get connections to sync
let connections = user.calendarConnections;
// If a specific connectionId is provided, filter to just that one
if (connectionId) {
connections = connections.filter(c => c.id === connectionId);
if (connections.length === 0) {
return NextResponse.json({ error: 'Connection not found' }, { status: 404 });
}
}
if (connections.length === 0) {
console.log('[CALENDAR SYNC] No calendar connections found');
return NextResponse.json({
success: true,
events: [],
message: 'No calendar connections found. Please connect a calendar in Settings.'
});
return NextResponse.json({ success: true, events: [], fromCache: true });
}
// Map to CalendarConnection interface
const calendarConnections: CalendarConnection[] = connections.map(conn => ({
id: conn.id,
provider: conn.provider as 'google' | 'apple' | 'outlook',
accessToken: conn.accessToken,
refreshToken: conn.refreshToken || undefined,
expiresAt: conn.expiresAt || undefined,
calendars: conn.calendars as any
}));
// FAST PATH: read from DB cache
const cachedEvents = await readCachedEvents(user.id, timeMinDate, timeMaxDate);
console.log('[CALENDAR SYNC] Fetching events from', calendarConnections.length, 'connections');
calendarConnections.forEach(c => {
console.log('[CALENDAR SYNC] Connection:', c.provider, 'calendars:', c.calendars?.length || 0);
});
// Fetch events from all connected calendars
const events = await getCalendarEvents(
calendarConnections,
timeMin || new Date().toISOString(),
timeMax || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString()
// Check which connections are stale
const staleChecks = await Promise.all(
connections.map(async conn => ({
conn,
stale: forceRefresh || await isCacheStale(conn.id, timeMinDate),
}))
);
const staleConnections = staleChecks.filter(c => c.stale).map(c => c.conn);
console.log('[CALENDAR SYNC] Fetched', events.length, 'events');
// Transform events to the format expected by the frontend
const formattedEvents = events.map(event => ({
id: event.id,
title: event.title,
description: event.description,
startTime: event.start.dateTime || event.start.date,
endTime: event.end.dateTime || event.end.date,
source: event.source,
calendarId: event.calendarId,
calendarTitle: event.calendarTitle,
calendarColor: event.backgroundColor
}));
console.log('[CALENDAR SYNC] Returning', formattedEvents.length, 'formatted events');
if (formattedEvents.length > 0) {
console.log('[CALENDAR SYNC] First 3 events:', JSON.stringify(formattedEvents.slice(0, 3), null, 2));
// BACKGROUND REFRESH: fire-and-forget for stale connections
if (staleConnections.length > 0) {
const refreshWork = Promise.allSettled(
staleConnections.map(conn => {
const rc: RefreshableConnection = {
dbId: conn.id,
userId: user.id,
id: conn.id,
provider: conn.provider as 'google' | 'apple' | 'outlook',
accessToken: conn.accessToken,
refreshToken: conn.refreshToken ?? undefined,
expiresAt: conn.expiresAt ?? undefined,
calendars: conn.calendars as any,
};
return refreshConnectionCache(rc, timeMinDate, timeMaxDate);
})
);
refreshWork.catch(e => console.error('[CACHE] Background refresh error:', e));
}
return NextResponse.json({
success: true,
events: formattedEvents,
count: formattedEvents.length
events: cachedEvents,
count: cachedEvents.length,
fromCache: true,
staleConnectionCount: staleConnections.length,
});
} catch (error) {
console.error('[CALENDAR SYNC] Sync request failed:', error);

View File

@ -4,6 +4,8 @@ import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { PrismaClient } from '@prisma/client';
import { createGoogleClient, fetchGoogleTasks, fetchGoogleTaskLists } from '@/lib/google-tasks';
import { fetchMsTodoLists, fetchMsTodoTasks, isMsTodoTaskCompleted } from '@/lib/microsoft-todo';
import { getOutlookAccessToken } from '@/lib/outlook-token';
const prisma = new PrismaClient();
@ -32,7 +34,7 @@ export async function POST(req: NextRequest) {
const body = await req.json();
const { provider, sourceLists } = body;
if (!provider || provider !== 'google') {
if (!provider || !['google', 'outlook'].includes(provider)) {
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 });
}
@ -90,6 +92,35 @@ export async function POST(req: NextRequest) {
}
if (provider === 'outlook') {
const accessToken = await getOutlookAccessToken(user.id);
if (!accessToken) {
return NextResponse.json({ error: 'Outlook account not connected' }, { status: 400 });
}
let targetLists = lists;
if (targetLists.length === 0) {
const msTodoLists = await fetchMsTodoLists(accessToken);
if (msTodoLists.length > 0) {
const defaultList = msTodoLists.find(l => l.wellknownListName === 'defaultList') || msTodoLists[0];
targetLists = [{ id: defaultList.id, title: defaultList.displayName }];
}
}
for (const sourceList of targetLists) {
const msTasks = await fetchMsTodoTasks(accessToken, sourceList.id);
importedTasks.push(...msTasks.map(t => ({
title: t.title,
description: t.body?.content || '',
externalId: t.id,
externalListId: sourceList.id,
dueDate: t.dueDateTime ? new Date(t.dueDateTime.dateTime) : null,
status: isMsTodoTaskCompleted(t.status) ? 'completed' : 'notStarted',
sourceListTitle: sourceList.title,
})));
}
}
// Group tasks by source list title
const tasksByList = new Map<string, ImportedTask[]>();
for (const task of importedTasks) {

View File

@ -3,6 +3,8 @@ import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { PrismaClient } from '@prisma/client';
import { createGoogleClient, fetchGoogleTaskLists } from '@/lib/google-tasks';
import { fetchMsTodoLists } from '@/lib/microsoft-todo';
import { getOutlookAccessToken } from '@/lib/outlook-token';
const prisma = new PrismaClient();
@ -16,7 +18,7 @@ export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const provider = searchParams.get('provider');
if (!provider || provider !== 'google') {
if (!provider || !['google', 'outlook'].includes(provider)) {
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 });
}
@ -28,18 +30,34 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
const account = await prisma.account.findFirst({
where: { userId: user.id, provider: 'google' }
});
if (provider === 'google') {
const account = await prisma.account.findFirst({
where: { userId: user.id, provider: 'google' }
});
if (!account || !account.access_token) {
return NextResponse.json({ error: 'Google account not connected' }, { status: 400 });
if (!account || !account.access_token) {
return NextResponse.json({ error: 'Google account not connected' }, { status: 400 });
}
const client = createGoogleClient(account.access_token, account.refresh_token || undefined);
const lists = await fetchGoogleTaskLists(client);
return NextResponse.json({ lists: lists.map(l => ({ id: l.id, title: l.title })) });
}
const client = createGoogleClient(account.access_token, account.refresh_token || undefined);
const lists = await fetchGoogleTaskLists(client);
if (provider === 'outlook') {
const accessToken = await getOutlookAccessToken(user.id);
if (!accessToken) {
return NextResponse.json({ error: 'Outlook account not connected' }, { status: 400 });
}
return NextResponse.json({ lists: lists.map(l => ({ id: l.id, title: l.title })) });
const lists = await fetchMsTodoLists(accessToken);
return NextResponse.json({
lists: lists.map(l => ({ id: l.id, title: l.displayName }))
});
}
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 });
} catch (error: unknown) {
console.error('Fetch lists error:', error);

View File

@ -3,6 +3,8 @@ import { getServerSession } from 'next-auth';
import { authOptions } from "@/lib/auth";
import { PrismaClient } from '@prisma/client';
import { createGoogleClient, updateGoogleTaskStatus, updateGoogleTask, deleteGoogleTask, fetchGoogleTasksForSync } from '@/lib/google-tasks';
import { fetchMsTodoTasksForSync, updateMsTodoTask, deleteMsTodoTask, isMsTodoTaskCompleted } from '@/lib/microsoft-todo';
import { getOutlookAccessToken } from '@/lib/outlook-token';
const prisma = new PrismaClient();
@ -21,94 +23,164 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
const account = await prisma.account.findFirst({
where: { userId: user.id, provider: 'google' }
});
if (!account?.access_token) {
return NextResponse.json({ error: 'Google account not connected' }, { status: 400 });
}
const client = createGoogleClient(account.access_token, account.refresh_token || undefined);
// Find all local tasks linked to Google
const localTasks = await prisma.task.findMany({
// Find all local tasks linked to external providers
const allExternalTasks = await prisma.task.findMany({
where: {
userId: user.id,
externalProvider: 'google',
externalProvider: { in: ['google', 'outlook'] },
externalId: { not: null },
deletedAt: null,
}
});
// Group by externalListId
const tasksByList = new Map<string, typeof localTasks>();
for (const task of localTasks) {
if (!task.externalListId) continue;
if (!tasksByList.has(task.externalListId)) {
tasksByList.set(task.externalListId, []);
}
tasksByList.get(task.externalListId)!.push(task);
}
const googleLocalTasks = allExternalTasks.filter(t => t.externalProvider === 'google');
const outlookLocalTasks = allExternalTasks.filter(t => t.externalProvider === 'outlook');
let updated = 0;
let deleted = 0;
for (const [listId, tasks] of tasksByList) {
try {
const remoteTasks = await fetchGoogleTasksForSync(client, listId);
const remoteMap = new Map(remoteTasks.map(t => [t.id, t]));
// --- Google Tasks pull-sync ---
if (googleLocalTasks.length > 0) {
const account = await prisma.account.findFirst({
where: { userId: user.id, provider: 'google' }
});
for (const localTask of tasks) {
const remote = remoteMap.get(localTask.externalId!);
if (account?.access_token) {
const client = createGoogleClient(account.access_token, account.refresh_token || undefined);
if (!remote) {
// Task was deleted in Google - soft delete locally
await prisma.task.update({
where: { id: localTask.id },
data: { deletedAt: new Date() }
});
deleted++;
continue;
const googleByList = new Map<string, typeof googleLocalTasks>();
for (const task of googleLocalTasks) {
if (!task.externalListId) continue;
if (!googleByList.has(task.externalListId)) {
googleByList.set(task.externalListId, []);
}
googleByList.get(task.externalListId)!.push(task);
}
// Check if remote is newer
const remoteUpdated = new Date(remote.updated);
const localUpdated = localTask.lastSyncedAt || localTask.updatedAt;
if (remoteUpdated <= localUpdated) continue;
for (const [listId, tasks] of googleByList) {
try {
const remoteTasks = await fetchGoogleTasksForSync(client, listId);
const remoteMap = new Map(remoteTasks.map(t => [t.id, t]));
// Apply remote changes
const updateData: any = { lastSyncedAt: new Date() };
for (const localTask of tasks) {
const remote = remoteMap.get(localTask.externalId!);
const remoteCompleted = remote.status === 'completed';
if (remoteCompleted !== localTask.completed) {
updateData.completed = remoteCompleted;
}
if (!remote) {
await prisma.task.update({
where: { id: localTask.id },
data: { deletedAt: new Date() }
});
deleted++;
continue;
}
if (remote.title && remote.title !== localTask.title) {
updateData.title = remote.title;
}
const remoteUpdated = new Date(remote.updated);
const localUpdated = localTask.lastSyncedAt || localTask.updatedAt;
if (remoteUpdated <= localUpdated) continue;
if (remote.notes !== undefined && remote.notes !== (localTask.description || undefined)) {
updateData.description = remote.notes || null;
}
const updateData: any = { lastSyncedAt: new Date() };
if (Object.keys(updateData).length > 1) { // more than just lastSyncedAt
await prisma.task.update({
where: { id: localTask.id },
data: updateData
});
updated++;
} else {
// Still update lastSyncedAt
await prisma.task.update({
where: { id: localTask.id },
data: { lastSyncedAt: new Date() }
});
const remoteCompleted = remote.status === 'completed';
if (remoteCompleted !== localTask.completed) {
updateData.completed = remoteCompleted;
}
if (remote.title && remote.title !== localTask.title) {
updateData.title = remote.title;
}
if (remote.notes !== undefined && remote.notes !== (localTask.description || undefined)) {
updateData.description = remote.notes || null;
}
if (Object.keys(updateData).length > 1) {
await prisma.task.update({
where: { id: localTask.id },
data: updateData
});
updated++;
} else {
await prisma.task.update({
where: { id: localTask.id },
data: { lastSyncedAt: new Date() }
});
}
}
} catch (listError) {
console.error(`Error syncing Google list ${listId}:`, listError);
}
}
}
}
// --- Microsoft To-Do pull-sync ---
if (outlookLocalTasks.length > 0) {
const outlookToken = await getOutlookAccessToken(user.id);
if (outlookToken) {
const outlookByList = new Map<string, typeof outlookLocalTasks>();
for (const task of outlookLocalTasks) {
if (!task.externalListId) continue;
if (!outlookByList.has(task.externalListId)) {
outlookByList.set(task.externalListId, []);
}
outlookByList.get(task.externalListId)!.push(task);
}
for (const [listId, tasks] of outlookByList) {
try {
const remoteTasks = await fetchMsTodoTasksForSync(outlookToken, listId);
const remoteMap = new Map(remoteTasks.map(t => [t.id, t]));
for (const localTask of tasks) {
const remote = remoteMap.get(localTask.externalId!);
if (!remote) {
await prisma.task.update({
where: { id: localTask.id },
data: { deletedAt: new Date() }
});
deleted++;
continue;
}
const remoteUpdated = new Date(remote.lastModifiedDateTime);
const localUpdated = localTask.lastSyncedAt || localTask.updatedAt;
if (remoteUpdated <= localUpdated) continue;
const updateData: any = { lastSyncedAt: new Date() };
const remoteCompleted = isMsTodoTaskCompleted(remote.status);
if (remoteCompleted !== localTask.completed) {
updateData.completed = remoteCompleted;
}
if (remote.title && remote.title !== localTask.title) {
updateData.title = remote.title;
}
const remoteNotes = remote.body?.content || null;
if (remoteNotes !== (localTask.description || null)) {
updateData.description = remoteNotes;
}
if (Object.keys(updateData).length > 1) {
await prisma.task.update({
where: { id: localTask.id },
data: updateData
});
updated++;
} else {
await prisma.task.update({
where: { id: localTask.id },
data: { lastSyncedAt: new Date() }
});
}
}
} catch (listError) {
console.error(`Error syncing Outlook list ${listId}:`, listError);
}
}
} catch (listError) {
console.error(`Error syncing list ${listId}:`, listError);
}
}
@ -167,7 +239,6 @@ export async function PATCH(req: NextRequest) {
if (notes !== undefined) updates.notes = notes;
if (completed !== undefined) updates.status = completed ? 'completed' : 'needsAction';
if (scheduledDate !== undefined) {
// Google Tasks expects RFC 3339 date (YYYY-MM-DDT00:00:00.000Z)
updates.due = scheduledDate ? new Date(scheduledDate).toISOString() : null;
}
if (Object.keys(updates).length > 0) {
@ -177,6 +248,27 @@ export async function PATCH(req: NextRequest) {
}
}
if (task.externalProvider === 'outlook' && task.externalListId) {
const outlookToken = await getOutlookAccessToken(task.userId);
if (outlookToken) {
if (action === 'delete') {
await deleteMsTodoTask(outlookToken, task.externalListId, task.externalId);
} else {
const updates: { title?: string; body?: string; status?: 'notStarted' | 'completed'; dueDateTime?: string | null } = {};
if (title !== undefined) updates.title = title;
if (notes !== undefined) updates.body = notes;
if (completed !== undefined) updates.status = completed ? 'completed' : 'notStarted';
if (scheduledDate !== undefined) {
updates.dueDateTime = scheduledDate ? new Date(scheduledDate).toISOString() : null;
}
if (Object.keys(updates).length > 0) {
await updateMsTodoTask(outlookToken, task.externalListId, task.externalId, updates);
}
}
}
}
// Update lastSyncedAt
await prisma.task.update({
where: { id: taskId },

View File

@ -5,7 +5,7 @@ interface ImportListModalProps {
isOpen: boolean;
onClose: () => void;
onImport: (selectedLists: { id: string, title: string }[]) => void;
provider: 'google' | 'apple' | null;
provider: 'google' | 'apple' | 'outlook' | null;
lists: { id: string, title: string }[];
isLoading: boolean;
}
@ -72,7 +72,7 @@ export const ImportListModal: React.FC<ImportListModalProps> = ({
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ fontSize: '1.25rem', fontWeight: 600 }}>
Import from {provider === 'google' ? 'Google Tasks' : 'Apple Reminders'}
Import from {provider === 'google' ? 'Google Tasks' : provider === 'outlook' ? 'Microsoft To-Do' : 'Apple Reminders'}
</h2>
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded-full">
<X size={20} />

View File

@ -408,7 +408,7 @@ export default function WeeklyView() {
const [importingTasksState, setImportingTasksState] = useState<boolean>(false);
const [importStatusMsg, setImportStatusMsg] = useState<{ type: 'success' | 'error', text: string } | null>(null);
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [importProvider, setImportProvider] = useState<'google' | 'apple' | null>(null);
const [importProvider, setImportProvider] = useState<'google' | 'apple' | 'outlook' | null>(null);
const [importLists, setImportLists] = useState<{ id: string, title: string }[]>([]);
const [isFetchingLists, setIsFetchingLists] = useState(false);
const [isVisible, setIsVisible] = useState(false);
@ -820,6 +820,34 @@ export default function WeeklyView() {
return () => clearInterval(interval);
}, [session]);
// Periodic background calendar cache refresh (every 5 minutes)
useEffect(() => {
if (!session) return;
const interval = setInterval(async () => {
try {
const now = new Date();
const res = await fetch('/api/calendar/background-sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
timeMin: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(),
timeMax: new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000).toISOString(),
}),
});
if (res.ok) {
const data = await res.json();
if (data.queued > 0) {
// Stale connections are being refreshed; re-fetch events after delay
setTimeout(() => fetchCalendarEvents(), 8000);
}
}
} catch (e) {
// Silent fail for background sync
}
}, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [session, fetchCalendarEvents]);
async function fetchConnections() {
try {
setIsLoading(true);
@ -1472,7 +1500,7 @@ export default function WeeklyView() {
};
const executeImport = async (provider: 'google' | 'apple') => {
const executeImport = async (provider: 'google' | 'apple' | 'outlook') => {
setImportProvider(provider);
setIsImportModalOpen(true);
setIsFetchingLists(true);
@ -1500,7 +1528,7 @@ export default function WeeklyView() {
};
// Core import logic — accepts provider directly so it works both from modal and sidebar
const doImport = async (provider: 'google' | 'apple', selectedLists: { id: string, title: string }[]) => {
const doImport = async (provider: 'google' | 'apple' | 'outlook', selectedLists: { id: string, title: string }[]) => {
setImportingTasksState(true);
setImportStatusMsg(null);
@ -2126,9 +2154,27 @@ export default function WeeklyView() {
const handleSync = async () => {
setSyncStatus('syncing');
try {
// Pull changes from Google Tasks, then reload everything
// Pull changes from Google Tasks, then force-refresh calendar cache
await fetch('/api/tasks/sync').catch(e => console.error('Task pull sync error:', e));
await Promise.all([fetchCalendarEvents(), fetchTasks()]);
// Force live refresh from providers (bypass staleness check)
const syncRes = await fetch('/api/calendar/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
timeMin: currentWeekStart.toISOString(),
timeMax: new Date(currentWeekStart.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(),
forceRefresh: true,
}),
});
if (syncRes.ok) {
const data = await syncRes.json();
if (data.events) setRawCalendarEvents(data.events);
}
await fetchTasks();
// Re-fetch after background refresh completes
if (true) {
setTimeout(() => fetchCalendarEvents(), 8000);
}
setSyncStatus('synced');
setTimeout(() => setSyncStatus('idle'), 3000);
} catch (error) {
@ -4261,7 +4307,7 @@ interface SettingsSidebarProps {
goalDefaultSentence?: string;
goalFallbackType?: string;
importingTasksState: boolean;
executeImport: (provider: 'google' | 'apple') => Promise<void>;
executeImport: (provider: 'google' | 'apple' | 'outlook') => Promise<void>;
onImportLists: (lists: { id: string, title: string }[]) => Promise<void>;
importStatusMsg: { type: 'success' | 'error', text: string } | null;
}
@ -5586,10 +5632,10 @@ function SettingsSidebar({
<h3 style={{ marginBottom: '1rem', fontSize: '1rem', fontWeight: 600, marginTop: '2rem' }}>Import Tasks</h3>
<p style={{ fontSize: '0.9rem', color: 'var(--weekly-text-light)', marginBottom: '1rem' }}>
Import tasks from Google Tasks into a Someday list.
Import tasks from Google Tasks or Microsoft To-Do into a Someday list.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div style={{ display: 'flex', gap: '1rem' }}>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<button
onClick={() => executeImport('google')}
className="calendar-connect-btn"
@ -5598,6 +5644,14 @@ function SettingsSidebar({
>
<span>📅</span> {importingTasksState ? 'Importing...' : 'Import from Google Tasks'}
</button>
<button
onClick={() => executeImport('outlook')}
className="calendar-connect-btn"
disabled={!connections.some(c => c.provider === 'outlook') || importingTasksState}
style={{ opacity: (!connections.some(c => c.provider === 'outlook') || importingTasksState) ? 0.5 : 1 }}
>
<span>📧</span> {importingTasksState ? 'Importing...' : 'Import from Microsoft To-Do'}
</button>
</div>
{importStatusMsg && (
<div style={{

204
src/lib/calendar-cache.ts Normal file
View File

@ -0,0 +1,204 @@
// @ts-nocheck
/* eslint-disable @typescript-eslint/no-explicit-any */
import { prisma } from './prisma';
import { getCalendarEvents, CalendarEvent, CalendarConnection } from './calendar-events';
const STALE_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes
/**
* Get the Monday (start of ISO week) for a given date.
*/
function getWeekStart(date: Date): Date {
const d = new Date(date);
d.setUTCHours(0, 0, 0, 0);
const day = d.getUTCDay(); // 0=Sun
const diff = day === 0 ? -6 : 1 - day;
d.setUTCDate(d.getUTCDate() + diff);
return d;
}
/**
* Check if the cache for a given connection + time window is stale.
*/
export async function isCacheStale(
connectionId: string,
timeMin: Date,
): Promise<boolean> {
const weekStart = getWeekStart(timeMin);
const newest = await prisma.cachedCalendarEvent.findFirst({
where: { connectionId, weekStart },
orderBy: { syncedAt: 'desc' },
select: { syncedAt: true },
});
if (!newest) return true;
return Date.now() - newest.syncedAt.getTime() > STALE_THRESHOLD_MS;
}
/**
* Read cached events for a user within a date range.
* Returns them in the flat shape the frontend expects.
*/
export async function readCachedEvents(
userId: string,
timeMin: Date,
timeMax: Date,
) {
const minDateStr = timeMin.toISOString().slice(0, 10);
const maxDateStr = timeMax.toISOString().slice(0, 10);
const rows = await prisma.cachedCalendarEvent.findMany({
where: {
userId,
OR: [
// Timed events within range
{
startDateTime: { gte: timeMin, lte: timeMax },
},
// All-day events within range
{
startDate: { gte: minDateStr, lte: maxDateStr },
},
],
},
orderBy: { startDateTime: 'asc' },
});
return rows.map(row => ({
id: row.externalId,
title: row.title,
description: row.description,
startTime: row.startDateTime?.toISOString() ?? row.startDate ?? '',
endTime: row.endDateTime?.toISOString() ?? row.endDate ?? '',
source: row.provider as 'google' | 'apple' | 'outlook',
calendarId: row.calendarId,
calendarTitle: row.calendarTitle,
calendarColor: row.calendarColor,
}));
}
export interface RefreshableConnection extends CalendarConnection {
dbId: string;
userId: string;
}
/**
* Fetch live events from provider, then atomic swap into cache.
*/
export async function refreshConnectionCache(
connection: RefreshableConnection,
timeMin: Date,
timeMax: Date,
): Promise<number> {
const weekStart = getWeekStart(timeMin);
let liveEvents: CalendarEvent[] = [];
try {
liveEvents = await getCalendarEvents(
[connection],
timeMin.toISOString(),
timeMax.toISOString(),
);
} catch (err) {
console.error(`[CACHE] Live fetch failed for connection ${connection.dbId}:`, err);
return 0;
}
const now = new Date();
const rows = liveEvents.map(ev => ({
userId: connection.userId,
externalId: ev.id,
connectionId: connection.dbId,
provider: connection.provider,
calendarId: ev.calendarId,
calendarTitle: ev.calendarTitle,
calendarColor: ev.backgroundColor ?? null,
title: ev.title,
description: ev.description ?? null,
location: ev.location ?? null,
startDateTime: ev.start.dateTime ? new Date(ev.start.dateTime) : null,
startDate: ev.start.date ?? null,
endDateTime: ev.end.dateTime ? new Date(ev.end.dateTime) : null,
endDate: ev.end.date ?? null,
weekStart,
syncedAt: now,
}));
// Atomic swap: delete old events for this connection+week, insert fresh ones
await prisma.$transaction([
prisma.cachedCalendarEvent.deleteMany({
where: { connectionId: connection.dbId, weekStart },
}),
prisma.cachedCalendarEvent.createMany({
data: rows,
skipDuplicates: true,
}),
]);
console.log(`[CACHE] Refreshed ${rows.length} events for ${connection.provider} connection, week ${weekStart.toISOString().slice(0, 10)}`);
return rows.length;
}
/**
* Upsert a single event into cache (after create/update mutations).
*/
export async function upsertCachedEvent(
userId: string,
connectionId: string,
provider: string,
event: CalendarEvent,
): Promise<void> {
const weekStart = getWeekStart(
new Date(event.start.dateTime ?? event.start.date ?? Date.now()),
);
await prisma.cachedCalendarEvent.upsert({
where: {
userId_externalId_provider: { userId, externalId: event.id, provider },
},
create: {
userId,
externalId: event.id,
connectionId,
provider,
calendarId: event.calendarId,
calendarTitle: event.calendarTitle,
calendarColor: event.backgroundColor ?? null,
title: event.title,
description: event.description ?? null,
location: event.location ?? null,
startDateTime: event.start.dateTime ? new Date(event.start.dateTime) : null,
startDate: event.start.date ?? null,
endDateTime: event.end.dateTime ? new Date(event.end.dateTime) : null,
endDate: event.end.date ?? null,
weekStart,
syncedAt: new Date(),
},
update: {
calendarTitle: event.calendarTitle,
calendarColor: event.backgroundColor ?? null,
title: event.title,
description: event.description ?? null,
location: event.location ?? null,
startDateTime: event.start.dateTime ? new Date(event.start.dateTime) : null,
startDate: event.start.date ?? null,
endDateTime: event.end.dateTime ? new Date(event.end.dateTime) : null,
endDate: event.end.date ?? null,
weekStart,
syncedAt: new Date(),
},
});
}
/**
* Delete a single cached event (after delete mutations).
*/
export async function deleteCachedEvent(
userId: string,
externalId: string,
provider: string,
): Promise<void> {
await prisma.cachedCalendarEvent.deleteMany({
where: { userId, externalId, provider },
});
}

View File

@ -364,7 +364,7 @@ export const getCalendarEvents = async (
description: event.description,
start: event.start,
end: event.end,
location: event.id === 'google' ? event.location : event.location,
location: event.location || '',
source: 'outlook' as const,
calendarId,
calendarTitle: calendarData?.title || 'Outlook Calendar',

211
src/lib/microsoft-todo.ts Normal file
View File

@ -0,0 +1,211 @@
// @ts-nocheck
/* eslint-disable @typescript-eslint/no-explicit-any */
const GRAPH_ENDPOINT = 'https://graph.microsoft.com/v1.0';
export interface MicrosoftTodoList {
id: string;
displayName: string;
isOwner: boolean;
isShared: boolean;
wellknownListName: string;
}
export interface MicrosoftTodoTask {
id: string;
title: string;
body?: {
content: string;
contentType: string;
};
status: 'notStarted' | 'inProgress' | 'completed' | 'waitingOnOthers' | 'deferred';
importance: 'low' | 'normal' | 'high';
dueDateTime?: {
dateTime: string;
timeZone: string;
};
completedDateTime?: {
dateTime: string;
timeZone: string;
};
createdDateTime: string;
lastModifiedDateTime: string;
}
/**
* Fetch all Microsoft To-Do task lists.
*/
export const fetchMsTodoLists = async (accessToken: string): Promise<MicrosoftTodoList[]> => {
const response = await fetch(`${GRAPH_ENDPOINT}/me/todo/lists`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const err = await response.text();
console.error('Error fetching Microsoft To-Do lists:', err);
throw new Error(`Failed to fetch To-Do lists: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return (data.value || []) as MicrosoftTodoList[];
};
/**
* Fetch active tasks from a specific To-Do list (for import).
*/
export const fetchMsTodoTasks = async (
accessToken: string,
listId: string
): Promise<MicrosoftTodoTask[]> => {
const params = new URLSearchParams({
'$filter': "status ne 'completed'",
'$top': '100',
'$select': 'id,title,body,status,importance,dueDateTime,createdDateTime,lastModifiedDateTime'
});
const response = await fetch(
`${GRAPH_ENDPOINT}/me/todo/lists/${listId}/tasks?${params.toString()}`,
{
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
}
);
if (!response.ok) {
const err = await response.text();
console.error(`Error fetching tasks from list ${listId}:`, err);
throw new Error(`Failed to fetch To-Do tasks: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return (data.value || []) as MicrosoftTodoTask[];
};
/**
* Fetch all tasks (including completed) with optional modified-since filter (for sync).
*/
export const fetchMsTodoTasksForSync = async (
accessToken: string,
listId: string,
modifiedSince?: string
): Promise<MicrosoftTodoTask[]> => {
const selectFields = 'id,title,body,status,dueDateTime,createdDateTime,lastModifiedDateTime';
const params = new URLSearchParams({
'$top': '100',
'$select': selectFields
});
if (modifiedSince) {
params.set('$filter', `lastModifiedDateTime gt ${modifiedSince}`);
}
const response = await fetch(
`${GRAPH_ENDPOINT}/me/todo/lists/${listId}/tasks?${params.toString()}`,
{
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
}
);
if (!response.ok) {
const err = await response.text();
console.error(`Error fetching sync tasks from list ${listId}:`, err);
throw new Error(`Failed to fetch To-Do tasks for sync: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return (data.value || []) as MicrosoftTodoTask[];
};
/**
* Update a Microsoft To-Do task.
*/
export const updateMsTodoTask = async (
accessToken: string,
listId: string,
taskId: string,
updates: {
title?: string;
body?: string;
status?: 'notStarted' | 'completed';
dueDateTime?: string | null;
}
): Promise<MicrosoftTodoTask> => {
const body: any = {};
if (updates.title !== undefined) body.title = updates.title;
if (updates.body !== undefined) body.body = { content: updates.body, contentType: 'text' };
if (updates.status !== undefined) {
body.status = updates.status;
if (updates.status === 'completed') {
body.completedDateTime = {
dateTime: new Date().toISOString(),
timeZone: 'UTC'
};
} else {
body.completedDateTime = null;
}
}
if (updates.dueDateTime !== undefined) {
body.dueDateTime = updates.dueDateTime
? { dateTime: new Date(updates.dueDateTime).toISOString(), timeZone: 'UTC' }
: null;
}
const response = await fetch(
`${GRAPH_ENDPOINT}/me/todo/lists/${listId}/tasks/${taskId}`,
{
method: 'PATCH',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
}
);
if (!response.ok) {
const err = await response.text();
throw new Error(`Failed to update To-Do task: ${err}`);
}
return response.json();
};
/**
* Delete a Microsoft To-Do task.
*/
export const deleteMsTodoTask = async (
accessToken: string,
listId: string,
taskId: string
): Promise<void> => {
const response = await fetch(
`${GRAPH_ENDPOINT}/me/todo/lists/${listId}/tasks/${taskId}`,
{
method: 'DELETE',
headers: {
'Authorization': `Bearer ${accessToken}`
}
}
);
if (!response.ok) {
const err = await response.text();
throw new Error(`Failed to delete To-Do task: ${err}`);
}
};
/**
* Check if a Microsoft To-Do task status maps to completed.
*/
export const isMsTodoTaskCompleted = (status: MicrosoftTodoTask['status']): boolean => {
return status === 'completed';
};

View File

@ -28,7 +28,8 @@ export const getAuthUrl = () => {
const scopes = [
'offline_access',
'user.read',
'Calendars.ReadWrite'
'Calendars.ReadWrite',
'Tasks.ReadWrite'
].join(' ');
const params = new URLSearchParams({
@ -55,7 +56,7 @@ export const getTokens = async (code: string) => {
const params = new URLSearchParams({
client_id: clientId,
scope: 'offline_access user.read Calendars.ReadWrite',
scope: 'offline_access user.read Calendars.ReadWrite Tasks.ReadWrite',
code: code,
redirect_uri: REDIRECT_URI,
grant_type: 'authorization_code',
@ -91,7 +92,7 @@ export const refreshAccessToken = async (refreshToken: string) => {
const params = new URLSearchParams({
client_id: clientId,
scope: 'offline_access user.read Calendars.ReadWrite',
scope: 'offline_access user.read Calendars.ReadWrite Tasks.ReadWrite',
refresh_token: refreshToken,
redirect_uri: REDIRECT_URI,
grant_type: 'refresh_token',

41
src/lib/outlook-token.ts Normal file
View File

@ -0,0 +1,41 @@
import { prisma } from '@/lib/prisma';
import { refreshAccessToken } from '@/lib/outlook-calendar';
/**
* Get a valid Outlook access token for a user, refreshing if needed.
* Shared across task sync, import, and list routes.
*/
export async function getOutlookAccessToken(userId: string): Promise<string | null> {
const conn = await prisma.calendarConnection.findFirst({
where: { userId, provider: 'outlook' }
});
if (!conn) return null;
const bufferMs = 5 * 60 * 1000;
const needsRefresh = !conn.expiresAt ||
new Date().getTime() > (new Date(conn.expiresAt).getTime() - bufferMs);
if (needsRefresh && conn.refreshToken) {
try {
const tokenData = await refreshAccessToken(conn.refreshToken);
if (tokenData.access_token) {
const expiresAt = new Date();
expiresAt.setSeconds(expiresAt.getSeconds() + tokenData.expires_in);
await prisma.calendarConnection.update({
where: { id: conn.id },
data: {
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token || conn.refreshToken,
expiresAt
}
});
return tokenData.access_token;
}
} catch (e) {
console.error('Failed to refresh Outlook token:', e);
return null;
}
}
return conn.accessToken;
}