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:
parent
ccb34d12d5
commit
db00642c21
@ -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
|
||||
|
||||
63
src/app/api/calendar/background-sync/route.ts
Normal file
63
src/app/api/calendar/background-sync/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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
204
src/lib/calendar-cache.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
@ -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
211
src/lib/microsoft-todo.ts
Normal 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';
|
||||
};
|
||||
@ -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
41
src/lib/outlook-token.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user