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[]
|
tasks Task[]
|
||||||
somedayLists SomedayList[]
|
somedayLists SomedayList[]
|
||||||
calendarConnections CalendarConnection[]
|
calendarConnections CalendarConnection[]
|
||||||
|
cachedCalendarEvents CachedCalendarEvent[]
|
||||||
weeklyGoals WeeklyGoal[]
|
weeklyGoals WeeklyGoal[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -148,7 +149,7 @@ model Task {
|
|||||||
|
|
||||||
// External Integration
|
// External Integration
|
||||||
externalId String?
|
externalId String?
|
||||||
externalProvider String? // "google" | "apple"
|
externalProvider String? // "google" | "apple" | "outlook"
|
||||||
externalListId String?
|
externalListId String?
|
||||||
lastSyncedAt DateTime?
|
lastSyncedAt DateTime?
|
||||||
|
|
||||||
@ -183,9 +184,40 @@ model CalendarConnection {
|
|||||||
calendars Json? // Stores array of { id, title, isPrimary, selected }
|
calendars Json? // Stores array of { id, title, isPrimary, selected }
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
cachedEvents CachedCalendarEvent[]
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
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 {
|
model WeeklyGoal {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
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 { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getServerSession } from 'next-auth';
|
import { getServerSession } from 'next-auth';
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { refreshConnectionCache, RefreshableConnection } from '@/lib/calendar-cache';
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
// Get user's calendar connections
|
// Get user's calendar connections
|
||||||
export async function GET(request: NextRequest) {
|
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 });
|
return NextResponse.json({ success: true, connection: updated });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating calendar connection:', error);
|
console.error('Error updating calendar connection:', error);
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getServerSession } from 'next-auth';
|
import { getServerSession } from 'next-auth';
|
||||||
import { authOptions } from "@/lib/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';
|
import { createCalendarEvent, updateCalendarEvent, deleteCalendarEvent, CalendarConnection } from '@/lib/calendar-events';
|
||||||
|
import { upsertCachedEvent, deleteCachedEvent } from '@/lib/calendar-cache';
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
// Helper to find connection by calendarId
|
// Helper to find connection by calendarId
|
||||||
async function findConnectionForCalendar(userId: string, calendarId: string) {
|
async function findConnectionForCalendar(userId: string, calendarId: string) {
|
||||||
@ -63,6 +62,10 @@ export async function POST(request: NextRequest) {
|
|||||||
location
|
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 });
|
return NextResponse.json({ event });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error creating event:', error);
|
console.error('Error creating event:', error);
|
||||||
@ -100,6 +103,10 @@ export async function PATCH(request: NextRequest) {
|
|||||||
location
|
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 });
|
return NextResponse.json({ event });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error updating event:', error);
|
console.error('Error updating event:', error);
|
||||||
@ -132,6 +139,10 @@ export async function DELETE(request: NextRequest) {
|
|||||||
|
|
||||||
await deleteCalendarEvent(connection, calendarId, eventId);
|
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 });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error deleting event:', error);
|
console.error('Error deleting event:', error);
|
||||||
|
|||||||
@ -1,104 +1,78 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getServerSession } from 'next-auth';
|
import { getServerSession } from 'next-auth';
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { getCalendarEvents, CalendarConnection } from '@/lib/calendar-events';
|
import { readCachedEvents, isCacheStale, refreshConnectionCache, RefreshableConnection } from '@/lib/calendar-cache';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
console.log('[CALENDAR SYNC] Starting sync request...');
|
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
console.log('[CALENDAR SYNC] Session:', session?.user?.email);
|
|
||||||
|
|
||||||
if (!session?.user?.email) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { timeMin, timeMax, connectionId } = body;
|
const { timeMin, timeMax, connectionId, forceRefresh } = body;
|
||||||
console.log('[CALENDAR SYNC] Request params:', { timeMin, timeMax, connectionId });
|
|
||||||
|
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({
|
const user = await prisma.user.findUnique({
|
||||||
where: { email: session.user.email },
|
where: { email: session.user.email },
|
||||||
include: { calendarConnections: true }
|
include: { calendarConnections: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
console.log('[CALENDAR SYNC] User not found:', session.user.email);
|
|
||||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
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;
|
let connections = user.calendarConnections;
|
||||||
|
|
||||||
// If a specific connectionId is provided, filter to just that one
|
|
||||||
if (connectionId) {
|
if (connectionId) {
|
||||||
connections = connections.filter(c => c.id === 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) {
|
if (connections.length === 0) {
|
||||||
console.log('[CALENDAR SYNC] No calendar connections found');
|
return NextResponse.json({ success: true, events: [], fromCache: true });
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
events: [],
|
|
||||||
message: 'No calendar connections found. Please connect a calendar in Settings.'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map to CalendarConnection interface
|
// FAST PATH: read from DB cache
|
||||||
const calendarConnections: CalendarConnection[] = connections.map(conn => ({
|
const cachedEvents = await readCachedEvents(user.id, timeMinDate, timeMaxDate);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// 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,
|
id: conn.id,
|
||||||
provider: conn.provider as 'google' | 'apple' | 'outlook',
|
provider: conn.provider as 'google' | 'apple' | 'outlook',
|
||||||
accessToken: conn.accessToken,
|
accessToken: conn.accessToken,
|
||||||
refreshToken: conn.refreshToken || undefined,
|
refreshToken: conn.refreshToken ?? undefined,
|
||||||
expiresAt: conn.expiresAt || undefined,
|
expiresAt: conn.expiresAt ?? undefined,
|
||||||
calendars: conn.calendars as any
|
calendars: conn.calendars as any,
|
||||||
}));
|
};
|
||||||
|
return refreshConnectionCache(rc, 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()
|
|
||||||
);
|
);
|
||||||
|
refreshWork.catch(e => console.error('[CACHE] Background refresh error:', e));
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
events: formattedEvents,
|
events: cachedEvents,
|
||||||
count: formattedEvents.length
|
count: cachedEvents.length,
|
||||||
|
fromCache: true,
|
||||||
|
staleConnectionCount: staleConnections.length,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[CALENDAR SYNC] Sync request failed:', error);
|
console.error('[CALENDAR SYNC] Sync request failed:', error);
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import { getServerSession } from 'next-auth';
|
|||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { createGoogleClient, fetchGoogleTasks, fetchGoogleTaskLists } from '@/lib/google-tasks';
|
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();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@ -32,7 +34,7 @@ export async function POST(req: NextRequest) {
|
|||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const { provider, sourceLists } = body;
|
const { provider, sourceLists } = body;
|
||||||
|
|
||||||
if (!provider || provider !== 'google') {
|
if (!provider || !['google', 'outlook'].includes(provider)) {
|
||||||
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 });
|
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
|
// Group tasks by source list title
|
||||||
const tasksByList = new Map<string, ImportedTask[]>();
|
const tasksByList = new Map<string, ImportedTask[]>();
|
||||||
for (const task of importedTasks) {
|
for (const task of importedTasks) {
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import { getServerSession } from 'next-auth';
|
|||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { createGoogleClient, fetchGoogleTaskLists } from '@/lib/google-tasks';
|
import { createGoogleClient, fetchGoogleTaskLists } from '@/lib/google-tasks';
|
||||||
|
import { fetchMsTodoLists } from '@/lib/microsoft-todo';
|
||||||
|
import { getOutlookAccessToken } from '@/lib/outlook-token';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@ -16,7 +18,7 @@ export async function GET(req: NextRequest) {
|
|||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const provider = searchParams.get('provider');
|
const provider = searchParams.get('provider');
|
||||||
|
|
||||||
if (!provider || provider !== 'google') {
|
if (!provider || !['google', 'outlook'].includes(provider)) {
|
||||||
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,6 +30,7 @@ export async function GET(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (provider === 'google') {
|
||||||
const account = await prisma.account.findFirst({
|
const account = await prisma.account.findFirst({
|
||||||
where: { userId: user.id, provider: 'google' }
|
where: { userId: user.id, provider: 'google' }
|
||||||
});
|
});
|
||||||
@ -40,6 +43,21 @@ export async function GET(req: NextRequest) {
|
|||||||
const lists = await fetchGoogleTaskLists(client);
|
const lists = await fetchGoogleTaskLists(client);
|
||||||
|
|
||||||
return NextResponse.json({ lists: lists.map(l => ({ id: l.id, title: l.title })) });
|
return NextResponse.json({ lists: lists.map(l => ({ id: l.id, title: l.title })) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'outlook') {
|
||||||
|
const accessToken = await getOutlookAccessToken(user.id);
|
||||||
|
if (!accessToken) {
|
||||||
|
return NextResponse.json({ error: 'Outlook account not connected' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
} catch (error: unknown) {
|
||||||
console.error('Fetch lists error:', error);
|
console.error('Fetch lists error:', error);
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import { getServerSession } from 'next-auth';
|
|||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { createGoogleClient, updateGoogleTaskStatus, updateGoogleTask, deleteGoogleTask, fetchGoogleTasksForSync } from '@/lib/google-tasks';
|
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();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@ -21,40 +23,41 @@ export async function GET(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const account = await prisma.account.findFirst({
|
// Find all local tasks linked to external providers
|
||||||
where: { userId: user.id, provider: 'google' }
|
const allExternalTasks = await prisma.task.findMany({
|
||||||
});
|
|
||||||
|
|
||||||
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({
|
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
externalProvider: 'google',
|
externalProvider: { in: ['google', 'outlook'] },
|
||||||
externalId: { not: null },
|
externalId: { not: null },
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Group by externalListId
|
const googleLocalTasks = allExternalTasks.filter(t => t.externalProvider === 'google');
|
||||||
const tasksByList = new Map<string, typeof localTasks>();
|
const outlookLocalTasks = allExternalTasks.filter(t => t.externalProvider === 'outlook');
|
||||||
for (const task of localTasks) {
|
|
||||||
if (!task.externalListId) continue;
|
|
||||||
if (!tasksByList.has(task.externalListId)) {
|
|
||||||
tasksByList.set(task.externalListId, []);
|
|
||||||
}
|
|
||||||
tasksByList.get(task.externalListId)!.push(task);
|
|
||||||
}
|
|
||||||
|
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
let deleted = 0;
|
let deleted = 0;
|
||||||
|
|
||||||
for (const [listId, tasks] of tasksByList) {
|
// --- Google Tasks pull-sync ---
|
||||||
|
if (googleLocalTasks.length > 0) {
|
||||||
|
const account = await prisma.account.findFirst({
|
||||||
|
where: { userId: user.id, provider: 'google' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (account?.access_token) {
|
||||||
|
const client = createGoogleClient(account.access_token, account.refresh_token || undefined);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [listId, tasks] of googleByList) {
|
||||||
try {
|
try {
|
||||||
const remoteTasks = await fetchGoogleTasksForSync(client, listId);
|
const remoteTasks = await fetchGoogleTasksForSync(client, listId);
|
||||||
const remoteMap = new Map(remoteTasks.map(t => [t.id, t]));
|
const remoteMap = new Map(remoteTasks.map(t => [t.id, t]));
|
||||||
@ -63,7 +66,6 @@ export async function GET(req: NextRequest) {
|
|||||||
const remote = remoteMap.get(localTask.externalId!);
|
const remote = remoteMap.get(localTask.externalId!);
|
||||||
|
|
||||||
if (!remote) {
|
if (!remote) {
|
||||||
// Task was deleted in Google - soft delete locally
|
|
||||||
await prisma.task.update({
|
await prisma.task.update({
|
||||||
where: { id: localTask.id },
|
where: { id: localTask.id },
|
||||||
data: { deletedAt: new Date() }
|
data: { deletedAt: new Date() }
|
||||||
@ -72,12 +74,10 @@ export async function GET(req: NextRequest) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if remote is newer
|
|
||||||
const remoteUpdated = new Date(remote.updated);
|
const remoteUpdated = new Date(remote.updated);
|
||||||
const localUpdated = localTask.lastSyncedAt || localTask.updatedAt;
|
const localUpdated = localTask.lastSyncedAt || localTask.updatedAt;
|
||||||
if (remoteUpdated <= localUpdated) continue;
|
if (remoteUpdated <= localUpdated) continue;
|
||||||
|
|
||||||
// Apply remote changes
|
|
||||||
const updateData: any = { lastSyncedAt: new Date() };
|
const updateData: any = { lastSyncedAt: new Date() };
|
||||||
|
|
||||||
const remoteCompleted = remote.status === 'completed';
|
const remoteCompleted = remote.status === 'completed';
|
||||||
@ -93,14 +93,13 @@ export async function GET(req: NextRequest) {
|
|||||||
updateData.description = remote.notes || null;
|
updateData.description = remote.notes || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(updateData).length > 1) { // more than just lastSyncedAt
|
if (Object.keys(updateData).length > 1) {
|
||||||
await prisma.task.update({
|
await prisma.task.update({
|
||||||
where: { id: localTask.id },
|
where: { id: localTask.id },
|
||||||
data: updateData
|
data: updateData
|
||||||
});
|
});
|
||||||
updated++;
|
updated++;
|
||||||
} else {
|
} else {
|
||||||
// Still update lastSyncedAt
|
|
||||||
await prisma.task.update({
|
await prisma.task.update({
|
||||||
where: { id: localTask.id },
|
where: { id: localTask.id },
|
||||||
data: { lastSyncedAt: new Date() }
|
data: { lastSyncedAt: new Date() }
|
||||||
@ -108,7 +107,80 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (listError) {
|
} catch (listError) {
|
||||||
console.error(`Error syncing list ${listId}:`, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -167,7 +239,6 @@ export async function PATCH(req: NextRequest) {
|
|||||||
if (notes !== undefined) updates.notes = notes;
|
if (notes !== undefined) updates.notes = notes;
|
||||||
if (completed !== undefined) updates.status = completed ? 'completed' : 'needsAction';
|
if (completed !== undefined) updates.status = completed ? 'completed' : 'needsAction';
|
||||||
if (scheduledDate !== undefined) {
|
if (scheduledDate !== undefined) {
|
||||||
// Google Tasks expects RFC 3339 date (YYYY-MM-DDT00:00:00.000Z)
|
|
||||||
updates.due = scheduledDate ? new Date(scheduledDate).toISOString() : null;
|
updates.due = scheduledDate ? new Date(scheduledDate).toISOString() : null;
|
||||||
}
|
}
|
||||||
if (Object.keys(updates).length > 0) {
|
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
|
// Update lastSyncedAt
|
||||||
await prisma.task.update({
|
await prisma.task.update({
|
||||||
where: { id: taskId },
|
where: { id: taskId },
|
||||||
|
|||||||
@ -5,7 +5,7 @@ interface ImportListModalProps {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onImport: (selectedLists: { id: string, title: string }[]) => void;
|
onImport: (selectedLists: { id: string, title: string }[]) => void;
|
||||||
provider: 'google' | 'apple' | null;
|
provider: 'google' | 'apple' | 'outlook' | null;
|
||||||
lists: { id: string, title: string }[];
|
lists: { id: string, title: string }[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
@ -72,7 +72,7 @@ export const ImportListModal: React.FC<ImportListModalProps> = ({
|
|||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 600 }}>
|
<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>
|
</h2>
|
||||||
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded-full">
|
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded-full">
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
|
|||||||
@ -408,7 +408,7 @@ export default function WeeklyView() {
|
|||||||
const [importingTasksState, setImportingTasksState] = useState<boolean>(false);
|
const [importingTasksState, setImportingTasksState] = useState<boolean>(false);
|
||||||
const [importStatusMsg, setImportStatusMsg] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
const [importStatusMsg, setImportStatusMsg] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
||||||
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
|
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 [importLists, setImportLists] = useState<{ id: string, title: string }[]>([]);
|
||||||
const [isFetchingLists, setIsFetchingLists] = useState(false);
|
const [isFetchingLists, setIsFetchingLists] = useState(false);
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
@ -820,6 +820,34 @@ export default function WeeklyView() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [session]);
|
}, [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() {
|
async function fetchConnections() {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
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);
|
setImportProvider(provider);
|
||||||
setIsImportModalOpen(true);
|
setIsImportModalOpen(true);
|
||||||
setIsFetchingLists(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
|
// 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);
|
setImportingTasksState(true);
|
||||||
setImportStatusMsg(null);
|
setImportStatusMsg(null);
|
||||||
|
|
||||||
@ -2126,9 +2154,27 @@ export default function WeeklyView() {
|
|||||||
const handleSync = async () => {
|
const handleSync = async () => {
|
||||||
setSyncStatus('syncing');
|
setSyncStatus('syncing');
|
||||||
try {
|
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 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');
|
setSyncStatus('synced');
|
||||||
setTimeout(() => setSyncStatus('idle'), 3000);
|
setTimeout(() => setSyncStatus('idle'), 3000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -4261,7 +4307,7 @@ interface SettingsSidebarProps {
|
|||||||
goalDefaultSentence?: string;
|
goalDefaultSentence?: string;
|
||||||
goalFallbackType?: string;
|
goalFallbackType?: string;
|
||||||
importingTasksState: boolean;
|
importingTasksState: boolean;
|
||||||
executeImport: (provider: 'google' | 'apple') => Promise<void>;
|
executeImport: (provider: 'google' | 'apple' | 'outlook') => Promise<void>;
|
||||||
onImportLists: (lists: { id: string, title: string }[]) => Promise<void>;
|
onImportLists: (lists: { id: string, title: string }[]) => Promise<void>;
|
||||||
importStatusMsg: { type: 'success' | 'error', text: string } | null;
|
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>
|
<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' }}>
|
<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>
|
</p>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
<div style={{ display: 'flex', gap: '1rem' }}>
|
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => executeImport('google')}
|
onClick={() => executeImport('google')}
|
||||||
className="calendar-connect-btn"
|
className="calendar-connect-btn"
|
||||||
@ -5598,6 +5644,14 @@ function SettingsSidebar({
|
|||||||
>
|
>
|
||||||
<span>📅</span> {importingTasksState ? 'Importing...' : 'Import from Google Tasks'}
|
<span>📅</span> {importingTasksState ? 'Importing...' : 'Import from Google Tasks'}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
{importStatusMsg && (
|
{importStatusMsg && (
|
||||||
<div style={{
|
<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,
|
description: event.description,
|
||||||
start: event.start,
|
start: event.start,
|
||||||
end: event.end,
|
end: event.end,
|
||||||
location: event.id === 'google' ? event.location : event.location,
|
location: event.location || '',
|
||||||
source: 'outlook' as const,
|
source: 'outlook' as const,
|
||||||
calendarId,
|
calendarId,
|
||||||
calendarTitle: calendarData?.title || 'Outlook Calendar',
|
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 = [
|
const scopes = [
|
||||||
'offline_access',
|
'offline_access',
|
||||||
'user.read',
|
'user.read',
|
||||||
'Calendars.ReadWrite'
|
'Calendars.ReadWrite',
|
||||||
|
'Tasks.ReadWrite'
|
||||||
].join(' ');
|
].join(' ');
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@ -55,7 +56,7 @@ export const getTokens = async (code: string) => {
|
|||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
client_id: clientId,
|
client_id: clientId,
|
||||||
scope: 'offline_access user.read Calendars.ReadWrite',
|
scope: 'offline_access user.read Calendars.ReadWrite Tasks.ReadWrite',
|
||||||
code: code,
|
code: code,
|
||||||
redirect_uri: REDIRECT_URI,
|
redirect_uri: REDIRECT_URI,
|
||||||
grant_type: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
@ -91,7 +92,7 @@ export const refreshAccessToken = async (refreshToken: string) => {
|
|||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
client_id: clientId,
|
client_id: clientId,
|
||||||
scope: 'offline_access user.read Calendars.ReadWrite',
|
scope: 'offline_access user.read Calendars.ReadWrite Tasks.ReadWrite',
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
redirect_uri: REDIRECT_URI,
|
redirect_uri: REDIRECT_URI,
|
||||||
grant_type: 'refresh_token',
|
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