Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
338 changes: 318 additions & 20 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@tanstack/react-virtual": "^3.13.21",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"googleapis": "^171.4.0",
"html-to-image": "^1.11.13",
"lucide-react": "^0.563.0",
"mapbox-gl": "^3.18.1",
Expand Down
35 changes: 35 additions & 0 deletions src/app/api/gmail/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
import { handleGoogleCallback } from '@/lib/gmail';

export async function GET(req: NextRequest) {
try {
const url = new URL(req.url);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state'); // userId passed as state
const error = url.searchParams.get('error');

if (error) {
// User denied access or something went wrong
const appUrl = process.env.NEXT_PUBLIC_APP_URL || '';
return NextResponse.redirect(`${appUrl}/import?error=access_denied`);
}

if (!code || !state) {
return NextResponse.json(
{ error: 'Missing code or state parameter' },
{ status: 400 }
);
}

// Exchange code for tokens and store them
await handleGoogleCallback(code, state);

// Redirect back to the import page
const appUrl = process.env.NEXT_PUBLIC_APP_URL || '';
return NextResponse.redirect(`${appUrl}/import?connected=true`);
} catch (err) {
console.error('Gmail callback error:', err);
const appUrl = process.env.NEXT_PUBLIC_APP_URL || '';
return NextResponse.redirect(`${appUrl}/import?error=callback_failed`);
}
}
30 changes: 30 additions & 0 deletions src/app/api/gmail/connect/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthenticatedUser } from '@/lib/gmail/server-auth';
import { getGoogleAuthUrl, getGmailConnection } from '@/lib/gmail';

export async function GET(req: NextRequest) {
try {
const { user, error } = await getAuthenticatedUser(req);
if (error) return error;

// Check if already connected
const existing = await getGmailConnection(user!.id);
if (existing) {
return NextResponse.json({
connected: true,
email: existing.google_account_email,
connectedAt: existing.connected_at,
});
}

// Generate OAuth URL
const authUrl = getGoogleAuthUrl(user!.id);
return NextResponse.json({ authUrl });
} catch (err) {
console.error('Gmail connect error:', err);
return NextResponse.json(
{ error: 'Failed to initiate Gmail connection' },
{ status: 500 }
);
}
}
45 changes: 45 additions & 0 deletions src/app/api/gmail/disconnect/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@supabase/supabase-js';
import { getAuthenticatedUser } from '@/lib/gmail/server-auth';
import { disconnectGmail } from '@/lib/gmail';

export async function POST(req: NextRequest) {
try {
const { user, error } = await getAuthenticatedUser(req);
if (error) return error;

const userId = user!.id;
const body = await req.json().catch(() => ({}));
const deleteData = body.deleteData === true;

// Disconnect Gmail (revokes token, marks connection as disconnected)
await disconnectGmail(userId);

// Optionally delete all imported data
if (deleteData) {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);

// Sources are CASCADE deleted when events are deleted
await supabase
.from('imported_events')
.delete()
.eq('user_id', userId)
.eq('source', 'gmail_luma');
}

return NextResponse.json({
success: true,
dataDeleted: deleteData,
});
} catch (err) {
console.error('Gmail disconnect error:', err);
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json(
{ error: `Disconnect failed: ${message}` },
{ status: 500 }
);
}
}
103 changes: 103 additions & 0 deletions src/app/api/gmail/import/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@supabase/supabase-js';
import { getAuthenticatedUser } from '@/lib/gmail/server-auth';
import type { ImportEventPayload } from '@/lib/gmail/types';

function getSupabase() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
}

export async function POST(req: NextRequest) {
try {
const { user, error } = await getAuthenticatedUser(req);
if (error) return error;

const userId = user!.id;
const body = await req.json();
const events: ImportEventPayload[] = body.events;

if (!Array.isArray(events) || events.length === 0) {
return NextResponse.json(
{ error: 'No events provided' },
{ status: 400 }
);
}

const supabase = getSupabase();
let imported = 0;
let skipped = 0;

for (const event of events) {
// Upsert the event (update if external_event_key already exists)
const { data: upserted, error: eventError } = await supabase
.from('imported_events')
.upsert(
{
user_id: userId,
source: 'gmail_luma',
external_event_key: event.externalEventKey,
event_name: event.eventName,
event_start_at: event.eventStartAt,
event_end_at: event.eventEndAt,
location_raw: event.locationRaw,
location_normalized: event.locationRaw, // Same as raw for MVP
event_url: event.eventUrl,
status: event.status,
parse_confidence: event.parseConfidence,
first_seen_at: event.firstSeenAt,
last_seen_at: event.lastSeenAt,
updated_at: new Date().toISOString(),
},
{
onConflict: 'user_id,external_event_key',
}
)
.select('id')
.single();

if (eventError || !upserted) {
console.error('Failed to upsert event:', eventError);
skipped++;
continue;
}

// Insert source references
for (const source of event.sources) {
await supabase.from('imported_event_sources').upsert(
{
imported_event_id: upserted.id,
gmail_message_id: source.gmailMessageId,
gmail_thread_id: source.gmailThreadId,
message_type: source.hadIcs ? 'calendar_invite' : 'unknown',
sender: source.sender,
subject: source.subject,
received_at: source.receivedAt,
had_ics: source.hadIcs,
},
{
onConflict: 'id', // No real conflict expected, just insert
ignoreDuplicates: true,
}
);
}

imported++;
}

return NextResponse.json({
success: true,
imported,
skipped,
});
} catch (err) {
console.error('Gmail import error:', err);
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json(
{ error: `Import failed: ${message}` },
{ status: 500 }
);
}
}
58 changes: 58 additions & 0 deletions src/app/api/gmail/sync/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@supabase/supabase-js';
import { getAuthenticatedUser } from '@/lib/gmail/server-auth';
import {
getValidAccessToken,
fetchLumaMessages,
extractLumaEvent,
deduplicateEvents,
} from '@/lib/gmail';

export async function POST(req: NextRequest) {
try {
const { user, error } = await getAuthenticatedUser(req);
if (error) return error;

const userId = user!.id;

// Get a valid access token
const accessToken = await getValidAccessToken(userId);

// Fetch Luma messages from Gmail
const messages = await fetchLumaMessages(accessToken);

// Extract candidate events from each message
const candidates = messages
.map((msg) => extractLumaEvent(msg))
.filter((c): c is NonNullable<typeof c> => c !== null);

// Deduplicate
const events = deduplicateEvents(candidates);

// Update last_sync_at
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);

await supabase
.from('gmail_connections')
.update({ last_sync_at: new Date().toISOString() })
.eq('user_id', userId)
.eq('status', 'active');

return NextResponse.json({
events,
totalMessages: messages.length,
totalCandidates: candidates.length,
totalEvents: events.length,
});
} catch (err) {
console.error('Gmail sync error:', err);
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json(
{ error: `Sync failed: ${message}` },
{ status: 500 }
);
}
}
Loading