diff --git a/package-lock.json b/package-lock.json index 0b9990f..c14f227 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@auth/prisma-adapter": "^2.11.1", "@prisma/client": "^5.22.0", + "@types/bcryptjs": "^2.4.6", "bcryptjs": "^3.0.3", "date-fns": "^2.30.0", "googleapis": "^170.1.0", @@ -1900,6 +1901,12 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "license": "MIT" + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", diff --git a/package.json b/package.json index 7943fef..eda71b4 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "dependencies": { "@auth/prisma-adapter": "^2.11.1", "@prisma/client": "^5.22.0", + "@types/bcryptjs": "^2.4.6", "bcryptjs": "^3.0.3", "date-fns": "^2.30.0", "googleapis": "^170.1.0", diff --git a/prisma/migrations/20260206172137_add_scheduled_date/migration.sql b/prisma/migrations/20260206172137_add_scheduled_date/migration.sql new file mode 100644 index 0000000..a3252b5 --- /dev/null +++ b/prisma/migrations/20260206172137_add_scheduled_date/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "CalendarConnection" ADD COLUMN "calendars" JSONB; + +-- AlterTable +ALTER TABLE "Task" ADD COLUMN "scheduledDate" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "timezone" TEXT NOT NULL DEFAULT 'UTC'; + +-- CreateIndex +CREATE INDEX "Task_userId_scheduledDate_idx" ON "Task"("userId", "scheduledDate"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b198496..a12e914 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -21,10 +21,19 @@ model User { passwordResetExpires DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + timezone String @default("UTC") + autoRolling Boolean @default(false) + protectEventTimes Boolean @default(false) + language String @default("en") + dateFormat String @default("MM/dd/yyyy") + timeFormat String @default("12h") + startHour Int @default(6) + endHour Int @default(22) accounts Account[] sessions Session[] tasks Task[] + somedayLists SomedayList[] calendarConnections CalendarConnection[] } @@ -70,8 +79,10 @@ model Task { description String? markdownContent String? @db.Text completed Boolean @default(false) + isRolling Boolean @default(false) order Int @default(0) - dayOfWeek Int? // 0-6 for Sunday-Saturday + dayOfWeek Int? // 0-6 for Sunday-Saturday (legacy/someday lists) + scheduledDate DateTime? // Actual date for the task somedayListId String? originalDate DateTime? startTime String? @@ -81,10 +92,27 @@ model Task { user User @relation(fields: [userId], references: [id], onDelete: Cascade) + somedayList SomedayList? @relation(fields: [somedayListId], references: [id]) + @@index([userId, dayOfWeek]) + @@index([userId, scheduledDate]) @@index([userId, somedayListId]) } +model SomedayList { + id String @id @default(cuid()) + userId String + title String + order Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + tasks Task[] + + @@index([userId]) +} + model CalendarConnection { id String @id @default(cuid()) userId String @@ -92,6 +120,7 @@ model CalendarConnection { accessToken String refreshToken String? expiresAt DateTime? + calendars Json? // Stores array of { id, title, isPrimary, selected } createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/scripts/seed-connection.ts b/scripts/seed-connection.ts new file mode 100644 index 0000000..a2253aa --- /dev/null +++ b/scripts/seed-connection.ts @@ -0,0 +1,40 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + const user = await prisma.user.findFirst(); + if (!user) { + console.log('No user found to seed connection for.'); + return; + } + + console.log(`Seeding Calendar Connection for user: ${user.email}`); + + // Clean up existing google connections + await prisma.calendarConnection.deleteMany({ + where: { userId: user.id, provider: 'google' } + }); + + // Create mock connection with FUTURE expiry + await prisma.calendarConnection.create({ + data: { + userId: user.id, + provider: 'google', + accessToken: 'mock-access-token', + refreshToken: 'mock-refresh-token', + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 Year from now + calendars: [ + { id: 'primary', title: 'Primary Calendar', isPrimary: true, selected: true }, + { id: 'work', title: 'Work Calendar', isPrimary: false, selected: true }, + { id: 'holidays', title: 'Public Holidays', isPrimary: false, selected: false } + ] + } + }); + + console.log('Seeded Mock Google Connection with 3 calendars (Valid for 1 year).'); +} + +main() + .catch(e => console.error(e)) + .finally(async () => await prisma.$disconnect()); diff --git a/scripts/verify-schema.ts b/scripts/verify-schema.ts new file mode 100644 index 0000000..a941e82 --- /dev/null +++ b/scripts/verify-schema.ts @@ -0,0 +1,37 @@ + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('Verifying Prisma User model...'); + + // 1. Check if we can select the new fields + // We'll try to find the first user + const user = await prisma.user.findFirst({ + select: { + id: true, + email: true, + startHour: true, // This should compile if client is updated + endHour: true + } + }); + + if (!user) { + console.log('No users found, but schema seems valid if this runs.'); + return; + } + + console.log('Found user:', user); + console.log('Successfully selected startHour:', user.startHour); + console.log('Successfully selected endHour:', user.endHour); +} + +main() + .catch((e) => { + console.error('Error verifying schema:', e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/src/app/api/calendar/connections/route.ts b/src/app/api/calendar/connections/route.ts index c60a64b..d10b01b 100644 --- a/src/app/api/calendar/connections/route.ts +++ b/src/app/api/calendar/connections/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; +import { authOptions } from '../../auth/[...nextauth]/route'; import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); @@ -7,7 +8,7 @@ const prisma = new PrismaClient(); // Get user's calendar connections export async function GET(request: NextRequest) { try { - const session = await getServerSession(); + const session = await getServerSession(authOptions); if (!session?.user?.email) { return NextResponse.json( @@ -33,6 +34,7 @@ export async function GET(request: NextRequest) { const connections = user.calendarConnections.map(conn => ({ id: conn.id, provider: conn.provider, + calendars: conn.calendars, // Include calendar list createdAt: conn.createdAt, expiresAt: conn.expiresAt, })); @@ -47,6 +49,52 @@ export async function GET(request: NextRequest) { } } +// Update a calendar connection (e.g. selection) +export async function PATCH(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { id, calendars } = body; + + if (!id || !calendars) { + return NextResponse.json({ error: 'ID and calendars required' }, { status: 400 }); + } + + // Find user + const user = await prisma.user.findUnique({ + where: { email: session.user.email } + }); + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + // Update connection + const updated = await prisma.calendarConnection.update({ + where: { + id, + userId: user.id + }, + data: { + calendars: calendars // Update the JSON field + } + }); + + return NextResponse.json({ success: true, connection: updated }); + } catch (error) { + console.error('Error updating calendar connection:', error); + return NextResponse.json( + { error: 'Failed to update calendar connection' }, + { status: 500 } + ); + } +} + // Delete a calendar connection export async function DELETE(request: NextRequest) { try { diff --git a/src/app/api/calendar/google/oauth/route.ts b/src/app/api/calendar/google/oauth/route.ts index 11a9e4a..19a7681 100644 --- a/src/app/api/calendar/google/oauth/route.ts +++ b/src/app/api/calendar/google/oauth/route.ts @@ -42,6 +42,33 @@ export async function GET(request: NextRequest) { // Exchange authorization code for access token const { tokens } = await oauth2Client.getToken(code); + oauth2Client.setCredentials(tokens); + + // Fetch user calendars to store in connection settings + // Dynamic import to avoid circular dep issues in some envs, or just standard import? + // Standard import is better but we are inside function scope to check diff. + // I'll assume the import is added at top level or I force it here if possible. + // I will add import at top level in separate chunk if needed? + // Replace whole file content is unsafe. I'll use multi-replace. + + // We need getUserCalendars. I'll use require or assume import added. + // Actually, I'll allow ReplaceFileContent to manage imports? No. + // I'll use multi_replace to add import AND update logic. + + // WAIT, better approach: Just implement the fetch logic here locally to avoid import issues or dependency on lib if it changes. + // But duplicate code is bad. + // I'll add the import at the top. + + // Logic: + const calendar = google.calendar({ version: 'v3', auth: oauth2Client }); + const response = await calendar.calendarList.list(); + const remoteCalendars = response.data.items?.map((item: any) => ({ + id: item.id, + title: item.summary, + isPrimary: item.primary, + backgroundColor: item.backgroundColor, // Store calendar color for event fallback + selected: true // Default newly found to true + })) || []; // Check if connection already exists const existingConnection = await prisma.calendarConnection.findFirst({ @@ -51,7 +78,21 @@ export async function GET(request: NextRequest) { } }); + let finalCalendars = remoteCalendars; + if (existingConnection) { + // Merge with existing selection + if (existingConnection.calendars && Array.isArray(existingConnection.calendars)) { + const existingList = existingConnection.calendars as any[]; + finalCalendars = remoteCalendars.map(remote => { + const match = existingList.find(e => e.id === remote.id); + return { + ...remote, + selected: match ? match.selected : true // Preserve selection + }; + }); + } + // Update existing connection await prisma.calendarConnection.update({ where: { id: existingConnection.id }, @@ -59,6 +100,7 @@ export async function GET(request: NextRequest) { accessToken: tokens.access_token || '', refreshToken: tokens.refresh_token || existingConnection.refreshToken, expiresAt: tokens.expiry_date ? new Date(tokens.expiry_date) : null, + calendars: finalCalendars, // Store calendars updatedAt: new Date() } }); @@ -71,6 +113,7 @@ export async function GET(request: NextRequest) { accessToken: tokens.access_token || '', refreshToken: tokens.refresh_token || null, expiresAt: tokens.expiry_date ? new Date(tokens.expiry_date) : null, + calendars: finalCalendars, // Store calendars } }); } diff --git a/src/app/api/calendar/sync/route.ts b/src/app/api/calendar/sync/route.ts new file mode 100644 index 0000000..680d5d7 --- /dev/null +++ b/src/app/api/calendar/sync/route.ts @@ -0,0 +1,110 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '../../auth/[...nextauth]/route'; +import { PrismaClient } from '@prisma/client'; +import { getCalendarEvents, CalendarConnection } from '@/lib/calendar-events'; + +const prisma = new PrismaClient(); + +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 }); + + // Get the user and their calendar connections + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + 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.' + }); + } + + // Map to CalendarConnection interface + const calendarConnections: CalendarConnection[] = connections.map(conn => ({ + id: conn.id, + provider: conn.provider as 'google' | 'apple', + accessToken: conn.accessToken, + refreshToken: conn.refreshToken || undefined, + expiresAt: conn.expiresAt || undefined, + calendars: conn.calendars as any + })); + + 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() + ); + + 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] Sample event:', formattedEvents[0]); + } + + return NextResponse.json({ + success: true, + events: formattedEvents, + count: formattedEvents.length + }); + } catch (error) { + console.error('[CALENDAR SYNC] Sync request failed:', error); + return NextResponse.json({ + error: 'Sync failed', + details: error instanceof Error ? error.message : 'Unknown error' + }, { status: 500 }); + } +} diff --git a/src/app/api/someday-lists/route.ts b/src/app/api/someday-lists/route.ts new file mode 100644 index 0000000..c696a00 --- /dev/null +++ b/src/app/api/someday-lists/route.ts @@ -0,0 +1,220 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const userId = (session.user as any).id; + + const lists = await prisma.somedayList.findMany({ + where: { userId }, + orderBy: { order: 'asc' }, + include: { + tasks: { + orderBy: { order: 'asc' } + } + } + }); + + return NextResponse.json({ lists }); + } catch (error) { + console.error('Error fetching someday lists:', error); + return NextResponse.json( + { error: 'Failed to fetch someday lists' }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const userId = (session.user as any).id; + const { title } = await request.json(); + + if (!title) { + return NextResponse.json( + { error: 'Title is required' }, + { status: 400 } + ); + } + + // Get max order + const maxOrderList = await prisma.somedayList.findFirst({ + where: { userId }, + orderBy: { order: 'desc' } + }); + const order = (maxOrderList?.order ?? -1) + 1; + + const list = await prisma.somedayList.create({ + data: { + userId, + title, + order + }, + include: { tasks: true } // Return with empty tasks array for frontend consistency + }); + + return NextResponse.json({ list }); + } catch (error) { + console.error('Error creating someday list:', error); + return NextResponse.json( + { error: 'Failed to create someday list' }, + { status: 500 } + ); + } +} + +export async function DELETE(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + + if (!id) { + return NextResponse.json( + { error: 'List ID is required' }, + { status: 400 } + ); + } + + // Verify ownership + const list = await prisma.somedayList.findUnique({ + where: { id } + }); + + if (!list || list.userId !== (session.user as any).id) { + return NextResponse.json( + { error: 'List not found or unauthorized' }, + { status: 404 } + ); + } + + // Delete list (tasks cascade delete is not set in schema for tasks->list, check schema) + // In schema: tasks defined as `tasks Task[]`. + // We updated schema: `user User ... onDelete: Cascade`. `tasks` are separate. + // We need to verify if deleting list deletes tasks or unlinks them. + // Schema: `somedayList SomedayList? @relation...` + // If we want cascade delete tasks in the list, we should check relations. + // Prisma default is usually not cascade for optional relations unless specified. + // Let's assume we want to keep tasks or delete them? Usually delete list = delete tasks in it. + // Let's explicitly delete tasks first or rely on schema if configured. + // Schema update I did: `tasks Task[]`. `Task` has `somedayListId`. + // I didn't add `onDelete: Cascade` to the `somedayList` relation in `Task`. + // So I should clean up tasks manually or update schema. + // For now, let's delete tasks in the list. + + await prisma.task.deleteMany({ + where: { somedayListId: id } + }); + + await prisma.somedayList.delete({ + where: { id } + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error deleting someday list:', error); + return NextResponse.json( + { error: 'Failed to delete someday list' }, + { status: 500 } + ); + } +} + +export async function PATCH(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const body = await request.json(); + + // Handle Reordering (Array of { id, order }) + if (Array.isArray(body)) { + const updates = body.map(async (item: { id: string; order: number }) => { + // Verify ownership for each or just assume if one matches? + // Better to be safe, but for performance in batch, we might trust ID if valid. + // Let's verify ownership implicitly by where clause. + return prisma.somedayList.updateMany({ + where: { + id: item.id, + userId: (session.user as any).id + }, + data: { order: item.order } + }); + }); + + await Promise.all(updates); + return NextResponse.json({ success: true }); + } + + // Handle Single Update (Title) + const { id, title } = body; + + if (!id || !title) { + return NextResponse.json( + { error: 'ID and Title are required' }, + { status: 400 } + ); + } + + // Verify ownership + const existingList = await prisma.somedayList.findUnique({ + where: { id } + }); + + if (!existingList || existingList.userId !== (session.user as any).id) { + return NextResponse.json( + { error: 'List not found or unauthorized' }, + { status: 404 } + ); + } + + const list = await prisma.somedayList.update({ + where: { id }, + data: { title } + }); + + return NextResponse.json({ list }); + } catch (error) { + console.error('Error updating someday list:', error); + return NextResponse.json( + { error: 'Failed to update someday list' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index 96ca05e..08a39f1 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -19,6 +19,41 @@ export async function GET(request: NextRequest) { const userId = (session.user as any).id; + // Rolling Logic: Find incomplete rolling tasks from the past and move them to today + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const pastRollingTasks = await prisma.task.findMany({ + where: { + userId, + completed: false, + isRolling: true, + scheduledDate: { + lt: today + } + } + }); + + if (pastRollingTasks.length > 0) { + // Current day of week (0-6) + const currentDayOfWeek = today.getDay(); + + // Bulk update past rolling tasks to today + await prisma.task.updateMany({ + where: { + id: { + in: pastRollingTasks.map(t => t.id) + } + }, + data: { + scheduledDate: today, + dayOfWeek: currentDayOfWeek, + startTime: null, // Reset time for rolled tasks as they might clash + endTime: null + } + }); + } + const tasks = await prisma.task.findMany({ where: { userId }, orderBy: [ @@ -42,7 +77,7 @@ export async function POST(request: NextRequest) { try { const session = await getServerSession(authOptions); - if (!session?.user) { + if (!session?.user?.email) { return NextResponse.json( { error: 'Unauthorized' }, { status: 401 } @@ -52,7 +87,8 @@ export async function POST(request: NextRequest) { const userId = (session.user as any).id; const body = await request.json(); - const { title, description, dayOfWeek, order, markdownContent, somedayListId, startTime } = body; + const { title, description, dayOfWeek, order, markdownContent, somedayListId, startTime, scheduledDate } = body; + let { isRolling } = body; if (!title) { return NextResponse.json( @@ -61,6 +97,15 @@ export async function POST(request: NextRequest) { ); } + // If isRolling is not specified, check user preference + if (isRolling === undefined) { + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + select: { autoRolling: true } + }); + isRolling = user?.autoRolling || false; + } + const task = await prisma.task.create({ data: { title, @@ -71,6 +116,8 @@ export async function POST(request: NextRequest) { somedayListId, userId, startTime: startTime || null, + scheduledDate: scheduledDate ? new Date(scheduledDate) : null, + isRolling: isRolling || false }, }); @@ -99,7 +146,7 @@ export async function PATCH(request: NextRequest) { const userId = (session.user as any).id; const body = await request.json(); - const { id, title, description, completed, dayOfWeek, order, markdownContent } = body; + const { id, title, description, completed, dayOfWeek, order, markdownContent, scheduledDate, startTime } = body; if (!id) { return NextResponse.json( @@ -129,6 +176,9 @@ export async function PATCH(request: NextRequest) { ...(dayOfWeek !== undefined && { dayOfWeek: parseInt(dayOfWeek) }), ...(order !== undefined && { order: parseInt(order) }), ...(markdownContent !== undefined && { markdownContent }), + ...(scheduledDate !== undefined && { scheduledDate: scheduledDate ? new Date(scheduledDate) : null }), + ...(startTime !== undefined && { startTime }), + ...(body.isRolling !== undefined && { isRolling: body.isRolling }) }, }); diff --git a/src/app/api/user/export/route.ts b/src/app/api/user/export/route.ts new file mode 100644 index 0000000..8d593b6 --- /dev/null +++ b/src/app/api/user/export/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '../../auth/[...nextauth]/route'; +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +export async function GET(request: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + try { + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + include: { + tasks: true, + calendarConnections: true, + accounts: true + } + }); + + if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 }); + + // Sanitize + const exportData = { + profile: { + name: user.name, + email: user.email, + joined: user.createdAt, + timezone: user.timezone + }, + tasks: user.tasks, + connections: user.calendarConnections.map(c => ({ + provider: c.provider, + connectedAt: c.createdAt, + calendars: c.calendars + })) + }; + + // Return as file download + return new NextResponse(JSON.stringify(exportData, null, 2), { + headers: { + 'Content-Type': 'application/json', + 'Content-Disposition': `attachment; filename="data-export-${new Date().toISOString().split('T')[0]}.json"` + } + }); + } catch (error) { + console.error('Export failed:', error); + return NextResponse.json({ error: 'Export failed' }, { status: 500 }); + } +} diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts new file mode 100644 index 0000000..6cb79b9 --- /dev/null +++ b/src/app/api/user/profile/route.ts @@ -0,0 +1,107 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '../../auth/[...nextauth]/route'; +import { PrismaClient } from '@prisma/client'; +import bcrypt from 'bcryptjs'; + +const prisma = new PrismaClient(); + +// Get user profile +export async function GET(request: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + select: { + name: true, + email: true, + timezone: true, + autoRolling: true, + protectEventTimes: true, + language: true, + dateFormat: true, + timeFormat: true, + startHour: true, + endHour: true, + createdAt: true + } + }); + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + return NextResponse.json({ user }); +} + +// Update user profile +export async function PATCH(request: NextRequest) { + const session = await getServerSession(authOptions); + + if (!session || !session.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const body = await request.json(); + const { name, timezone, password, autoRolling, protectEventTimes, language, dateFormat, timeFormat, startHour, endHour } = body; + + const updateData: any = { + ...(name !== undefined && { name }), + ...(timezone !== undefined && { timezone }), + ...(autoRolling !== undefined && { autoRolling }), + ...(protectEventTimes !== undefined && { protectEventTimes }), + ...(language !== undefined && { language }), + ...(dateFormat !== undefined && { dateFormat }), + ...(timeFormat !== undefined && { timeFormat }), + ...(startHour !== undefined && { startHour }), + ...(endHour !== undefined && { endHour }), + }; + if (password) { + updateData.passwordHash = await bcrypt.hash(password, 10); + } + + const user = await prisma.user.update({ + where: { email: session.user.email }, + data: updateData, + select: { + id: true, + name: true, + email: true, + timezone: true, + autoRolling: true, + protectEventTimes: true, + language: true, + dateFormat: true, + timeFormat: true, + startHour: true, + endHour: true, + } + }); + + return NextResponse.json({ success: true, user }); + } catch (e) { + console.error('Error updating profile:', e); + return NextResponse.json( + { error: 'Failed to update profile', details: (e as Error).message }, + { status: 500 } + ); + } +} + +// Delete account +export async function DELETE(request: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + try { + await prisma.user.delete({ + where: { email: session.user.email } + }); + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error deleting account:', error); + return NextResponse.json({ error: 'Failed to delete account' }, { status: 500 }); + } +} diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index d60420e..44f56fb 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -29,21 +29,21 @@ export default function LoginPage() { }; return ( -
+
A simple, designy to-do app.
{/* Error Message */} {error && ( -