Refactor Apple Calendar integration and sync logic
This commit is contained in:
parent
3e7b87428a
commit
64955ad07b
7
.claude/settings.json
Normal file
7
.claude/settings.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npx tsc:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,6 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {};
|
const nextConfig = {
|
||||||
|
serverExternalPackages: ['ical.js', 'tsdav'],
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = nextConfig;
|
module.exports = nextConfig;
|
||||||
|
|||||||
101
package-lock.json
generated
101
package-lock.json
generated
@ -15,6 +15,7 @@
|
|||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
"googleapis": "^170.1.0",
|
"googleapis": "^170.1.0",
|
||||||
|
"ical.js": "^2.2.1",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"next": "^14.0.0",
|
"next": "^14.0.0",
|
||||||
"next-auth": "^4.24.13",
|
"next-auth": "^4.24.13",
|
||||||
@ -24,6 +25,7 @@
|
|||||||
"react-dnd": "^16.0.1",
|
"react-dnd": "^16.0.1",
|
||||||
"react-dnd-html5-backend": "^16.0.1",
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"tsdav": "^2.1.8",
|
||||||
"undici": "^7.19.1",
|
"undici": "^7.19.1",
|
||||||
"zustand": "^4.4.0"
|
"zustand": "^4.4.0"
|
||||||
},
|
},
|
||||||
@ -3320,6 +3322,12 @@
|
|||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/base-64": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/base64-js": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
@ -3816,6 +3824,35 @@
|
|||||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cross-fetch": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-fetch": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cross-fetch/node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@ -5673,6 +5710,12 @@
|
|||||||
"node": ">=10.17.0"
|
"node": ">=10.17.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ical.js": {
|
||||||
|
"version": "2.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ical.js/-/ical.js-2.2.1.tgz",
|
||||||
|
"integrity": "sha512-yK/UlPbEs316igb/tjRgbFA8ZV75rCsBJp/hWOatpyaPNlgw0dGDmU+FoicOcwX4xXkeXOkYiOmCqNPFpNPkQg==",
|
||||||
|
"license": "MPL-2.0"
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@ -8979,6 +9022,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sax": {
|
||||||
|
"version": "1.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz",
|
||||||
|
"integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.23.2",
|
"version": "0.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||||
@ -9837,6 +9889,12 @@
|
|||||||
"node": ">=8.0"
|
"node": ">=8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.4.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
|
||||||
@ -9959,6 +10017,21 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tsdav": {
|
||||||
|
"version": "2.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsdav/-/tsdav-2.1.8.tgz",
|
||||||
|
"integrity": "sha512-zvQvhZLzTaEmNNgJbBlUYT/JOq9Xpw/xkxCqs7IT2d2/7o7pss0iZOlZXuHJ5VcvSvTny42Vc6+6GyzZcrCJ1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base-64": "1.0.0",
|
||||||
|
"cross-fetch": "4.1.0",
|
||||||
|
"debug": "4.4.3",
|
||||||
|
"xml-js": "1.6.11"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
@ -10292,6 +10365,22 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@ -10535,6 +10624,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/xml-js": {
|
||||||
|
"version": "1.6.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
|
||||||
|
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sax": "^1.2.4"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"xml-js": "bin/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/y18n": {
|
"node_modules/y18n": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
|||||||
@ -27,6 +27,7 @@
|
|||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
"googleapis": "^170.1.0",
|
"googleapis": "^170.1.0",
|
||||||
|
"ical.js": "^2.2.1",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"next": "^14.0.0",
|
"next": "^14.0.0",
|
||||||
"next-auth": "^4.24.13",
|
"next-auth": "^4.24.13",
|
||||||
@ -36,6 +37,7 @@
|
|||||||
"react-dnd": "^16.0.1",
|
"react-dnd": "^16.0.1",
|
||||||
"react-dnd-html5-backend": "^16.0.1",
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"tsdav": "^2.1.8",
|
||||||
"undici": "^7.19.1",
|
"undici": "^7.19.1",
|
||||||
"zustand": "^4.4.0"
|
"zustand": "^4.4.0"
|
||||||
},
|
},
|
||||||
|
|||||||
90
src/app/api/calendar/apple/connect/route.ts
Normal file
90
src/app/api/calendar/apple/connect/route.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { validateCredentials } from '@/lib/apple-calendar';
|
||||||
|
import { CalendarConnection } from '@/lib/calendar-sync';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const { email, password } = body;
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Email and password are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate credentials and get calendars
|
||||||
|
const calendars = await validateCredentials(email, password);
|
||||||
|
|
||||||
|
// Find user
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: session.user.email }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if connection already exists and update it, otherwise create new
|
||||||
|
// We use the provider 'apple' and the user ID to find existing
|
||||||
|
const existingConnection = await prisma.calendarConnection.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
provider: 'apple'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let connection;
|
||||||
|
|
||||||
|
if (existingConnection) {
|
||||||
|
// Update existing connection
|
||||||
|
connection = await prisma.calendarConnection.update({
|
||||||
|
where: { id: existingConnection.id },
|
||||||
|
data: {
|
||||||
|
accessToken: `${email}:${password}`,
|
||||||
|
calendars: calendars.map(cal => ({
|
||||||
|
id: cal.id,
|
||||||
|
title: cal.title,
|
||||||
|
isPrimary: cal.isPrimary,
|
||||||
|
selected: true // Default to selected
|
||||||
|
})) as any,
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Create new connection
|
||||||
|
connection = await prisma.calendarConnection.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
provider: 'apple',
|
||||||
|
accessToken: `${email}:${password}`,
|
||||||
|
calendars: calendars.map(cal => ({
|
||||||
|
id: cal.id,
|
||||||
|
title: cal.title,
|
||||||
|
isPrimary: cal.isPrimary,
|
||||||
|
selected: true // Default to selected
|
||||||
|
})) as any
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, connection });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error connecting Apple Calendar:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || 'Failed to connect Apple Calendar' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,73 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { initializeOAuth as initializeAppleOAuth } from '@/lib/apple-calendar';
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
// Apple Calendar OAuth callback endpoint
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
// Get the authorization code from the query parameters
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const code = searchParams.get('code');
|
|
||||||
|
|
||||||
if (!code) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Authorization code missing' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize Apple OAuth client
|
|
||||||
const clientId = process.env.APPLE_CLIENT_ID || '';
|
|
||||||
const clientSecret = process.env.APPLE_CLIENT_SECRET || '';
|
|
||||||
const redirectUri = process.env.APPLE_REDIRECT_URI || '';
|
|
||||||
|
|
||||||
const appleClient = initializeAppleOAuth(clientId, clientSecret, redirectUri);
|
|
||||||
|
|
||||||
// Exchange authorization code for access token
|
|
||||||
// Make a POST request to Apple's token endpoint
|
|
||||||
const response = await fetch('https://appleid.apple.com/auth/token', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
|
||||||
grant_type: 'authorization_code',
|
|
||||||
code,
|
|
||||||
client_id: clientId,
|
|
||||||
client_secret: clientSecret,
|
|
||||||
redirect_uri: redirectUri,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const tokens = await response.json();
|
|
||||||
|
|
||||||
// Get user from session or request
|
|
||||||
const userId = 'test-user-id'; // This should come from authentication
|
|
||||||
|
|
||||||
// Store tokens in database
|
|
||||||
const calendarConnection = await prisma.calendarConnection.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
provider: 'apple',
|
|
||||||
accessToken: tokens.access_token,
|
|
||||||
refreshToken: tokens.refresh_token,
|
|
||||||
expiresAt: new Date(Date.now() + (tokens.expires_in * 1000)),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Redirect to success page or return tokens
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Apple Calendar connected successfully',
|
|
||||||
connectionId: calendarConnection.id
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Apple OAuth error:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to authenticate with Apple Calendar' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
33
src/app/api/calendar/apple/sync/route.ts
Normal file
33
src/app/api/calendar/apple/sync/route.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getUpcomingEvents } from '@/lib/apple-calendar';
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const { email, password, calendarId, timeMin, timeMax } = body;
|
||||||
|
|
||||||
|
if (!email || !password || !calendarId || !timeMin || !timeMax) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing required parameters' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch events
|
||||||
|
const events = await getUpcomingEvents(
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
calendarId,
|
||||||
|
timeMin,
|
||||||
|
timeMax
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, events });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error syncing Apple Calendar:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || 'Failed to sync Apple Calendar' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -92,7 +92,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
console.log('[CALENDAR SYNC] Returning', formattedEvents.length, 'formatted events');
|
console.log('[CALENDAR SYNC] Returning', formattedEvents.length, 'formatted events');
|
||||||
if (formattedEvents.length > 0) {
|
if (formattedEvents.length > 0) {
|
||||||
console.log('[CALENDAR SYNC] Sample event:', formattedEvents[0]);
|
console.log('[CALENDAR SYNC] First 3 events:', JSON.stringify(formattedEvents.slice(0, 3), null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@ -20,6 +20,13 @@ const CalendarSettings: React.FC<CalendarSettingsProps> = ({
|
|||||||
const [lastSyncTime, setLastSyncTime] = useState<Record<string, Date>>({});
|
const [lastSyncTime, setLastSyncTime] = useState<Record<string, Date>>({});
|
||||||
const [showConfirmation, setShowConfirmation] = useState<string | null>(null);
|
const [showConfirmation, setShowConfirmation] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Apple Calendar State
|
||||||
|
const [showAppleModal, setShowAppleModal] = useState(false);
|
||||||
|
const [appleEmail, setAppleEmail] = useState('');
|
||||||
|
const [applePassword, setApplePassword] = useState('');
|
||||||
|
const [isConnectingApple, setIsConnectingApple] = useState(false);
|
||||||
|
const [appleError, setAppleError] = useState('');
|
||||||
|
|
||||||
// Memoized functions for performance
|
// Memoized functions for performance
|
||||||
const formatDate = useCallback((date?: Date) => {
|
const formatDate = useCallback((date?: Date) => {
|
||||||
if (!date) return 'Never';
|
if (!date) return 'Never';
|
||||||
@ -34,14 +41,50 @@ const CalendarSettings: React.FC<CalendarSettingsProps> = ({
|
|||||||
// window.location.href = '/api/calendar/google/oauth';
|
// window.location.href = '/api/calendar/google/oauth';
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Function to trigger Apple OAuth
|
// Function to trigger Apple Connect Modal
|
||||||
const handleAppleConnect = useCallback(() => {
|
const handleAppleConnect = useCallback(() => {
|
||||||
// In a real implementation, this would redirect to Apple OAuth flow
|
setShowAppleModal(true);
|
||||||
// For now, we'll just log the action
|
setAppleError('');
|
||||||
console.log('Redirecting to Apple OAuth flow...');
|
setAppleEmail('');
|
||||||
// window.location.href = '/api/calendar/apple/oauth';
|
setApplePassword('');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const submitAppleConnection = async () => {
|
||||||
|
if (!appleEmail || !applePassword) {
|
||||||
|
setAppleError('Please enter both email and app-specific password.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsConnectingApple(true);
|
||||||
|
setAppleError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/calendar/apple/connect', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: appleEmail, password: applePassword }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to connect Apple Calendar');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If successful, the backend should return the new connection object
|
||||||
|
// We'll pass this to the parent handler
|
||||||
|
if (onConnectionAdded && data.connection) {
|
||||||
|
onConnectionAdded(data.connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowAppleModal(false);
|
||||||
|
} catch (err: any) {
|
||||||
|
setAppleError(err.message || 'Connection failed');
|
||||||
|
} finally {
|
||||||
|
setIsConnectingApple(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Function to trigger manual sync
|
// Function to trigger manual sync
|
||||||
const handleManualSync = useCallback(async (connectionId: string) => {
|
const handleManualSync = useCallback(async (connectionId: string) => {
|
||||||
setIsSyncing(prev => ({ ...prev, [connectionId]: true }));
|
setIsSyncing(prev => ({ ...prev, [connectionId]: true }));
|
||||||
@ -159,8 +202,8 @@ const CalendarSettings: React.FC<CalendarSettingsProps> = ({
|
|||||||
onClick={() => handleManualSync(connection.id)}
|
onClick={() => handleManualSync(connection.id)}
|
||||||
disabled={!!isSyncing[connection.id]}
|
disabled={!!isSyncing[connection.id]}
|
||||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${isSyncing[connection.id]
|
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${isSyncing[connection.id]
|
||||||
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||||
}`}
|
}`}
|
||||||
aria-label={`Sync ${connection.provider} calendar`}
|
aria-label={`Sync ${connection.provider} calendar`}
|
||||||
>
|
>
|
||||||
@ -235,7 +278,76 @@ const CalendarSettings: React.FC<CalendarSettingsProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{/* Apple Calendar Connection Modal */}
|
||||||
|
{
|
||||||
|
showAppleModal && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 max-w-md w-full shadow-xl">
|
||||||
|
<h3 className="text-xl font-bold mb-4">Connect Apple Calendar</h3>
|
||||||
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
|
To connect your iCloud Calendar, you need to use an <strong>App-Specific Password</strong>.
|
||||||
|
Go to <a href="https://appleid.apple.com" target="_blank" rel="noopener noreferrer" className="text-blue-600 underline">appleid.apple.com</a>, sign in, and generate a password under "App-Specific Passwords".
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{appleError && (
|
||||||
|
<div className="bg-red-50 text-red-600 p-3 rounded mb-4 text-sm">
|
||||||
|
{appleError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Apple ID (Email)</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={appleEmail}
|
||||||
|
onChange={(e) => setAppleEmail(e.target.value)}
|
||||||
|
className="w-full border border-gray-300 rounded px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
placeholder="name@icloud.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">App-Specific Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={applePassword}
|
||||||
|
onChange={(e) => setApplePassword(e.target.value)}
|
||||||
|
className="w-full border border-gray-300 rounded px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
placeholder="xxxx-xxxx-xxxx-xxxx"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAppleModal(false)}
|
||||||
|
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||||
|
disabled={isConnectingApple}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={submitAppleConnection}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-300 flex items-center"
|
||||||
|
disabled={isConnectingApple}
|
||||||
|
>
|
||||||
|
{isConnectingApple ? (
|
||||||
|
<>
|
||||||
|
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Connecting...
|
||||||
|
</>
|
||||||
|
) : 'Connect'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div >
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -657,9 +657,14 @@ export default function WeeklyView() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const text = await response.text();
|
||||||
if (data.events) {
|
try {
|
||||||
setRawCalendarEvents(data.events);
|
const data = JSON.parse(text);
|
||||||
|
if (data.events) {
|
||||||
|
setRawCalendarEvents(data.events);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse calendar sync response:', text.substring(0, 100));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -750,6 +755,7 @@ export default function WeeklyView() {
|
|||||||
|
|
||||||
async function fetchConnections() {
|
async function fetchConnections() {
|
||||||
try {
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
const response = await fetch('/api/calendar/connections');
|
const response = await fetch('/api/calendar/connections');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@ -757,9 +763,37 @@ export default function WeeklyView() {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching connections:', error);
|
console.error('Error fetching connections:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleRemoveConnection = async (connectionId: string) => {
|
||||||
|
if (!confirm('Are you sure you want to disconnect this calendar?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/calendar/connections?id=${connectionId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
// Update state immediately
|
||||||
|
setConnections(prev => prev.filter(c => c.id !== connectionId));
|
||||||
|
// Refresh connections to be sure
|
||||||
|
fetchConnections();
|
||||||
|
// Optionally refresh events too as they might be gone
|
||||||
|
fetchCalendarEvents();
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
console.error('Failed to disconnect calendar', err);
|
||||||
|
alert(`Failed to disconnect: ${err.error || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error disconnecting calendar:', error);
|
||||||
|
alert(`Error disconnecting: ${error.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -3073,6 +3107,7 @@ export default function WeeklyView() {
|
|||||||
{
|
{
|
||||||
showSettings && (
|
showSettings && (
|
||||||
<SettingsSidebar
|
<SettingsSidebar
|
||||||
|
onRemoveConnection={handleRemoveConnection}
|
||||||
onClose={() => setShowSettings(false)}
|
onClose={() => setShowSettings(false)}
|
||||||
onSettingsChanged={handleSettingsChanged}
|
onSettingsChanged={handleSettingsChanged}
|
||||||
showTimeGrid={showTimeGrid}
|
showTimeGrid={showTimeGrid}
|
||||||
@ -3720,6 +3755,7 @@ interface SettingsSidebarProps {
|
|||||||
weekendColorSun: string;
|
weekendColorSun: string;
|
||||||
protectEventTimes: boolean;
|
protectEventTimes: boolean;
|
||||||
setProtectEventTimes: (protect: boolean) => void;
|
setProtectEventTimes: (protect: boolean) => void;
|
||||||
|
onRemoveConnection: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingsSidebar({
|
function SettingsSidebar({
|
||||||
@ -3743,6 +3779,7 @@ function SettingsSidebar({
|
|||||||
setMotto,
|
setMotto,
|
||||||
connections,
|
connections,
|
||||||
onUpdateConnections,
|
onUpdateConnections,
|
||||||
|
onRemoveConnection,
|
||||||
focusTimerDuration,
|
focusTimerDuration,
|
||||||
setFocusTimerDuration,
|
setFocusTimerDuration,
|
||||||
focusBreakDuration,
|
focusBreakDuration,
|
||||||
@ -3781,6 +3818,13 @@ function SettingsSidebar({
|
|||||||
const [accountMsg, setAccountMsg] = useState('');
|
const [accountMsg, setAccountMsg] = useState('');
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
// Apple Calendar State
|
||||||
|
const [showAppleModal, setShowAppleModal] = useState(false);
|
||||||
|
const [appleEmail, setAppleEmail] = useState('');
|
||||||
|
const [applePassword, setApplePassword] = useState('');
|
||||||
|
const [isConnectingApple, setIsConnectingApple] = useState(false);
|
||||||
|
const [appleError, setAppleError] = useState('');
|
||||||
|
|
||||||
const [profile, setProfile] = useState<{
|
const [profile, setProfile] = useState<{
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
@ -3956,7 +4000,47 @@ function SettingsSidebar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAppleConnect = () => {
|
const handleAppleConnect = () => {
|
||||||
alert('Apple Calendar integration coming soon! For now, you can import .ics files.');
|
setShowAppleModal(true);
|
||||||
|
setAppleError('');
|
||||||
|
setAppleEmail('');
|
||||||
|
setApplePassword('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitAppleConnection = async () => {
|
||||||
|
if (!appleEmail || !applePassword) {
|
||||||
|
setAppleError('Please enter both email and app-specific password.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsConnectingApple(true);
|
||||||
|
setAppleError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/calendar/apple/connect', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: appleEmail, password: applePassword }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to connect Apple Calendar');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If successful, the backend should return the new connection object
|
||||||
|
// We'll pass this to the parent handler
|
||||||
|
if (onUpdateConnections && data.connection) {
|
||||||
|
onUpdateConnections([...connections, data.connection]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowAppleModal(false);
|
||||||
|
alert('Apple Calendar connected successfully!');
|
||||||
|
} catch (err: any) {
|
||||||
|
setAppleError(err.message || 'Connection failed');
|
||||||
|
} finally {
|
||||||
|
setIsConnectingApple(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOutlookConnect = () => {
|
const handleOutlookConnect = () => {
|
||||||
@ -4000,6 +4084,8 @@ function SettingsSidebar({
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleUpdateProfile = async (e: React.FormEvent) => {
|
const handleUpdateProfile = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@ -4724,47 +4810,69 @@ function SettingsSidebar({
|
|||||||
<ul style={{ marginBottom: '1.5rem', listStyle: 'none', padding: 0 }}>
|
<ul style={{ marginBottom: '1.5rem', listStyle: 'none', padding: 0 }}>
|
||||||
{connections.map(conn => (
|
{connections.map(conn => (
|
||||||
<li key={conn.id} style={{ padding: '1rem 0', borderBottom: '1px solid var(--weekly-border)' }}>
|
<li key={conn.id} style={{ padding: '1rem 0', borderBottom: '1px solid var(--weekly-border)' }}>
|
||||||
<div style={{ fontWeight: 600, marginBottom: '0.5rem', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div style={{ marginBottom: '0.5rem', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<span>{conn.provider === 'google' ? '📅' : '🍎'}</span>
|
<div style={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
{conn.provider === 'google' ? 'Google Calendar' : 'Apple Calendar'}
|
<span>{conn.provider === 'google' ? '📅' : conn.provider === 'apple' ? '🍎' : '📧'}</span>
|
||||||
|
{conn.provider === 'google' ? 'Google Calendar' : conn.provider === 'apple' ? 'Apple Calendar' : 'Outlook Calendar'}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemoveConnection(conn.id);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
color: '#dc2626',
|
||||||
|
background: 'none',
|
||||||
|
border: '1px solid #dc2626',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Calendar Selection List */}
|
{/* Calendar Selection List */}
|
||||||
{conn.calendars && Array.isArray(conn.calendars) && conn.calendars.length > 0 ? (
|
{
|
||||||
<ul style={{ paddingLeft: '24px', listStyle: 'none' }}>
|
conn.calendars && Array.isArray(conn.calendars) && conn.calendars.length > 0 ? (
|
||||||
{conn.calendars.map((cal: any) => (
|
<ul style={{ paddingLeft: '24px', listStyle: 'none' }}>
|
||||||
<li key={cal.id} style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '8px' }}>
|
{conn.calendars.map((cal: any) => (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<li key={cal.id} style={{ display: 'flex', alignItems: 'center', gap: '16px', marginBottom: '8px' }}>
|
||||||
<input
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
type="checkbox"
|
<input
|
||||||
checked={cal.selected !== false}
|
type="checkbox"
|
||||||
onChange={(e) => handleUpdateCalendar(conn.id, cal.id, { selected: e.target.checked })}
|
checked={cal.selected !== false}
|
||||||
style={{ cursor: 'pointer' }}
|
onChange={(e) => handleUpdateCalendar(conn.id, cal.id, { selected: e.target.checked })}
|
||||||
/>
|
style={{ cursor: 'pointer' }}
|
||||||
<span style={{ fontSize: '0.9rem', color: 'var(--weekly-text)' }}>
|
/>
|
||||||
{cal.title} {cal.isPrimary && <span style={{ fontSize: '0.8em', color: 'var(--weekly-text-light)' }}>(Primary)</span>}
|
<span style={{ fontSize: '0.9rem', color: 'var(--weekly-text)' }}>
|
||||||
</span>
|
{cal.title} {cal.isPrimary && <span style={{ fontSize: '0.8em', color: 'var(--weekly-text-light)' }}>(Primary)</span>}
|
||||||
</div>
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Editable toggle */}
|
{/* Editable toggle */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', opacity: 0.8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', opacity: 0.8 }}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={cal.editable === true}
|
checked={cal.editable === true}
|
||||||
onChange={(e) => handleUpdateCalendar(conn.id, cal.id, { editable: e.target.checked })}
|
onChange={(e) => handleUpdateCalendar(conn.id, cal.id, { editable: e.target.checked })}
|
||||||
style={{ cursor: 'pointer', width: '12px', height: '12px' }}
|
style={{ cursor: 'pointer', width: '12px', height: '12px' }}
|
||||||
title="Allow adding/editing events"
|
title="Allow adding/editing events"
|
||||||
/>
|
/>
|
||||||
<span style={{ fontSize: '0.8rem', color: '#888' }} title="Allow adding/editing events">Editable</span>
|
<span style={{ fontSize: '0.8rem', color: '#888' }} title="Allow adding/editing events">Editable</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ fontSize: '0.85rem', color: '#888', paddingLeft: '24px' }}>
|
<div style={{ fontSize: '0.85rem', color: '#888', paddingLeft: '24px' }}>
|
||||||
{conn.provider === 'google' ? 'No calendars found or permission denied.' : 'Selection available after connect.'}
|
{conn.provider === 'google' ? 'No calendars found or permission denied.' : 'Selection available after connect.'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)
|
||||||
|
}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@ -4920,7 +5028,77 @@ function SettingsSidebar({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div >
|
||||||
|
|
||||||
|
{/* Apple Calendar Connection Modal */}
|
||||||
|
{
|
||||||
|
showAppleModal && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[2000]">
|
||||||
|
<div className="bg-white rounded-lg p-6 max-w-md w-full shadow-xl">
|
||||||
|
<h3 className="text-xl font-bold mb-4">Connect Apple Calendar</h3>
|
||||||
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
|
To connect your iCloud Calendar, you need to use an <strong>App-Specific Password</strong>.
|
||||||
|
<br />
|
||||||
|
Go to <a href="https://appleid.apple.com" target="_blank" rel="noopener noreferrer" className="text-blue-600 underline">appleid.apple.com</a>, sign in, and generate a password under "App-Specific Passwords".
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{appleError && (
|
||||||
|
<div className="bg-red-50 text-red-600 p-3 rounded mb-4 text-sm">
|
||||||
|
{appleError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Apple ID (Email)</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={appleEmail}
|
||||||
|
onChange={(e) => setAppleEmail(e.target.value)}
|
||||||
|
className="w-full border border-gray-300 rounded px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
placeholder="name@icloud.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">App-Specific Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={applePassword}
|
||||||
|
onChange={(e) => setApplePassword(e.target.value)}
|
||||||
|
className="w-full border border-gray-300 rounded px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
|
placeholder="xxxx-xxxx-xxxx-xxxx"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAppleModal(false)}
|
||||||
|
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||||
|
disabled={isConnectingApple}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={submitAppleConnection}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-300 flex items-center"
|
||||||
|
disabled={isConnectingApple}
|
||||||
|
>
|
||||||
|
{isConnectingApple ? (
|
||||||
|
<>
|
||||||
|
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Connecting...
|
||||||
|
</>
|
||||||
|
) : 'Connect'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,16 @@
|
|||||||
// Apple Calendar OAuth wrapper
|
import { DAVClient } from 'tsdav';
|
||||||
import { fetch } from 'undici'; // Using undici for HTTP requests
|
import ICAL from 'ical.js';
|
||||||
|
|
||||||
|
// iCloud CalDAV Server URL
|
||||||
|
const ICLOUD_CALDAV_URL = 'https://caldav.icloud.com';
|
||||||
|
|
||||||
export interface AppleCalendarEvent {
|
export interface AppleCalendarEvent {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
|
description?: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
attendees?: Array<{
|
|
||||||
email: string;
|
|
||||||
name?: string;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppleCalendar {
|
export interface AppleCalendar {
|
||||||
@ -21,105 +20,199 @@ export interface AppleCalendar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize Apple Calendar OAuth client
|
* Create a configured DAV client for Apple iCloud
|
||||||
*/
|
*/
|
||||||
export const initializeOAuth = (clientId: string, clientSecret: string, redirectUri: string) => {
|
const createClient = (email: string, appSpecificPassword: string) => {
|
||||||
// This would typically configure the OAuth client for Apple
|
return new DAVClient({
|
||||||
// For now, we'll just return the configuration data
|
serverUrl: ICLOUD_CALDAV_URL,
|
||||||
return {
|
credentials: {
|
||||||
clientId,
|
username: email,
|
||||||
clientSecret,
|
password: appSpecificPassword,
|
||||||
redirectUri,
|
},
|
||||||
authorizationUrl: 'https://appleid.apple.com/auth/authorize',
|
authMethod: 'Basic',
|
||||||
tokenUrl: 'https://appleid.apple.com/auth/token',
|
defaultAccountType: 'caldav',
|
||||||
};
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get user calendar list using OAuth token
|
* Validate credentials by attempting to fetch calendars
|
||||||
|
* @returns List of found calendars if successful
|
||||||
*/
|
*/
|
||||||
export const getUserCalendars = async (
|
export const validateCredentials = async (email: string, appSpecificPassword: string): Promise<AppleCalendar[]> => {
|
||||||
clientConfig: any,
|
|
||||||
accessToken: string
|
|
||||||
): Promise<AppleCalendar[]> => {
|
|
||||||
try {
|
try {
|
||||||
// In a real implementation, this would make a request to Apple's calendar API
|
const client = createClient(email, appSpecificPassword);
|
||||||
// using the access token
|
await client.login();
|
||||||
|
|
||||||
// Simulate real API call
|
// Fetch calendars logic would go here
|
||||||
const response = await fetch(
|
// Since tsdav creates a complex object graph, we'll wrap this in a try/catch
|
||||||
'https://api.apple.com/calendar/v1/calendars',
|
const calendars = await client.fetchCalendars();
|
||||||
{
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${accessToken}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
return calendars.map(cal => ({
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
id: cal.url, // Using URL as ID for CalDAV
|
||||||
}
|
title: (cal.displayName as string) || 'Untitled Calendar',
|
||||||
|
isPrimary: false, // Hard to determine primary in generic CalDAV
|
||||||
const data = await response.json() as { calendars?: any[] };
|
}));
|
||||||
|
|
||||||
// Map to expected format
|
|
||||||
return data.calendars?.map((item: any) => ({
|
|
||||||
id: item.id,
|
|
||||||
title: item.title,
|
|
||||||
isPrimary: item.isPrimary
|
|
||||||
})) || [];
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching user calendars:', error);
|
console.error('Apple Calendar validation failed:', error);
|
||||||
throw new Error('Failed to fetch user calendars');
|
throw new Error('Invalid credentials or unable to connect to iCloud.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get upcoming events for a specified time period
|
* Get user calendars (wrapper around validateCredentials for now as they do the same)
|
||||||
|
*/
|
||||||
|
export const getUserCalendars = validateCredentials;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get upcoming events for a specified time period from a specific calendar
|
||||||
*/
|
*/
|
||||||
export const getUpcomingEvents = async (
|
export const getUpcomingEvents = async (
|
||||||
clientConfig: any,
|
email: string,
|
||||||
accessToken: string,
|
appSpecificPassword: string,
|
||||||
calendarId: string,
|
calendarUrl: string,
|
||||||
timeMin: string,
|
timeMin: string,
|
||||||
timeMax: string
|
timeMax: string
|
||||||
): Promise<AppleCalendarEvent[]> => {
|
): Promise<AppleCalendarEvent[]> => {
|
||||||
try {
|
try {
|
||||||
// In a real implementation, this would make a request to Apple's calendar API
|
const client = createClient(email, appSpecificPassword);
|
||||||
// using the access token and specified parameters
|
await client.login();
|
||||||
|
|
||||||
// Simulate real API call
|
const calendars = await client.fetchCalendars();
|
||||||
const response = await fetch(
|
const targetCalendar = calendars.find(c => c.url === calendarUrl);
|
||||||
`https://api.apple.com/calendar/v1/calendars/${calendarId}/events?start=${timeMin}&end=${timeMax}`,
|
|
||||||
{
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${accessToken}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!targetCalendar) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`Calendar not found: ${calendarUrl}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json() as { events?: any[] };
|
const events = await client.fetchCalendarObjects({
|
||||||
|
calendar: targetCalendar,
|
||||||
|
timeRange: {
|
||||||
|
start: new Date(timeMin).toISOString(),
|
||||||
|
end: new Date(timeMax).toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Map to expected format
|
const parsedEvents: AppleCalendarEvent[] = [];
|
||||||
return data.events?.map((item: any) => ({
|
const minTime = new Date(timeMin).getTime();
|
||||||
id: item.id,
|
const maxTime = new Date(timeMax).getTime();
|
||||||
title: item.title,
|
|
||||||
description: item.description,
|
events.forEach(eventObj => {
|
||||||
startDate: item.startDate,
|
const data = (eventObj as any).data;
|
||||||
endDate: item.endDate,
|
if (!data) return;
|
||||||
location: item.location,
|
|
||||||
attendees: item.attendees,
|
try {
|
||||||
})) || [];
|
const jcalData = ICAL.parse(data);
|
||||||
} catch (error) {
|
const comp = new ICAL.Component(jcalData);
|
||||||
console.error('Error fetching upcoming events:', error);
|
const vevents = comp.getAllSubcomponents('vevent');
|
||||||
throw new Error('Failed to fetch upcoming events');
|
|
||||||
|
// Separate base events (with RRULE) from recurrence exceptions (with RECURRENCE-ID)
|
||||||
|
const baseEvents: any[] = [];
|
||||||
|
const exceptions: Map<string, any[]> = new Map();
|
||||||
|
|
||||||
|
vevents.forEach((vevent: any) => {
|
||||||
|
const recurrenceId = vevent.getFirstPropertyValue('recurrence-id');
|
||||||
|
if (recurrenceId) {
|
||||||
|
// This is a recurrence exception - collect it
|
||||||
|
const event = new ICAL.Event(vevent);
|
||||||
|
const uid = event.uid;
|
||||||
|
if (!exceptions.has(uid)) exceptions.set(uid, []);
|
||||||
|
exceptions.get(uid)!.push(vevent);
|
||||||
|
} else {
|
||||||
|
baseEvents.push(vevent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
baseEvents.forEach((vevent: any) => {
|
||||||
|
const event = new ICAL.Event(vevent);
|
||||||
|
const rrule = vevent.getFirstPropertyValue('rrule');
|
||||||
|
|
||||||
|
if (rrule) {
|
||||||
|
// Recurring event - expand occurrences within the time range
|
||||||
|
const uid = event.uid;
|
||||||
|
const exceptionVevents = exceptions.get(uid) || [];
|
||||||
|
const exceptionDates = new Set<string>();
|
||||||
|
|
||||||
|
// First, process exceptions that fall in our range
|
||||||
|
exceptionVevents.forEach((exVevent: any) => {
|
||||||
|
const exEvent = new ICAL.Event(exVevent);
|
||||||
|
const recId = exVevent.getFirstPropertyValue('recurrence-id');
|
||||||
|
if (recId) {
|
||||||
|
exceptionDates.add(recId.toJSDate().toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const exStart = exEvent.startDate.toJSDate();
|
||||||
|
const exEnd = exEvent.endDate.toJSDate();
|
||||||
|
|
||||||
|
if (exEnd.getTime() >= minTime && exStart.getTime() <= maxTime) {
|
||||||
|
parsedEvents.push({
|
||||||
|
id: `${exEvent.uid}-${exStart.toISOString()}`,
|
||||||
|
title: exEvent.summary || 'Untitled Event',
|
||||||
|
startDate: exStart.toISOString(),
|
||||||
|
endDate: exEnd.toISOString(),
|
||||||
|
description: exEvent.description,
|
||||||
|
location: exEvent.location
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then expand the recurrence rule
|
||||||
|
try {
|
||||||
|
const duration = event.endDate.toJSDate().getTime() - event.startDate.toJSDate().getTime();
|
||||||
|
const iter = event.iterator();
|
||||||
|
let next;
|
||||||
|
let safetyCount = 0;
|
||||||
|
|
||||||
|
while ((next = iter.next()) && safetyCount < 500) {
|
||||||
|
safetyCount++;
|
||||||
|
const occStart = next.toJSDate();
|
||||||
|
const occEnd = new Date(occStart.getTime() + duration);
|
||||||
|
|
||||||
|
// Stop if we've gone past the range
|
||||||
|
if (occStart.getTime() > maxTime) break;
|
||||||
|
|
||||||
|
// Skip if before range
|
||||||
|
if (occEnd.getTime() < minTime) continue;
|
||||||
|
|
||||||
|
// Skip if this occurrence is overridden by an exception
|
||||||
|
if (exceptionDates.has(occStart.toISOString())) continue;
|
||||||
|
|
||||||
|
parsedEvents.push({
|
||||||
|
id: `${event.uid}-${occStart.toISOString()}`,
|
||||||
|
title: event.summary || 'Untitled Event',
|
||||||
|
startDate: occStart.toISOString(),
|
||||||
|
endDate: occEnd.toISOString(),
|
||||||
|
description: event.description,
|
||||||
|
location: event.location
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (expandErr: any) {
|
||||||
|
console.error(`[APPLE CALENDAR] Error expanding recurrence for "${event.summary}":`, expandErr);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Simple non-recurring event
|
||||||
|
const start = event.startDate.toJSDate();
|
||||||
|
const end = event.endDate.toJSDate();
|
||||||
|
|
||||||
|
if (end.getTime() < minTime || start.getTime() > maxTime) return;
|
||||||
|
|
||||||
|
parsedEvents.push({
|
||||||
|
id: event.uid || eventObj.url,
|
||||||
|
title: event.summary || 'Untitled Event',
|
||||||
|
startDate: start.toISOString(),
|
||||||
|
endDate: end.toISOString(),
|
||||||
|
description: event.description,
|
||||||
|
location: event.location
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (parseErr: any) {
|
||||||
|
console.error(`[APPLE CALENDAR] Error parsing event data for calendar ${calendarUrl}:`, parseErr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return parsedEvents;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[APPLE CALENDAR] Error fetching events for ${calendarUrl}:`, error);
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -1,6 +1,6 @@
|
|||||||
// Google Calendar OAuth wrapper
|
// Google Calendar OAuth wrapper
|
||||||
import { GoogleCalendarEvent, getUserCalendars as getGoogleCalendars, getUpcomingEvents as getGoogleEvents, initializeOAuth as initializeGoogleOAuth } from './google-calendar';
|
import { GoogleCalendarEvent, getUserCalendars as getGoogleCalendars, getUpcomingEvents as getGoogleEvents, initializeOAuth as initializeGoogleOAuth } from './google-calendar';
|
||||||
import { AppleCalendarEvent, getUserCalendars as getAppleCalendars, getUpcomingEvents as getAppleEvents, initializeOAuth as initializeAppleOAuth } from './apple-calendar';
|
import { AppleCalendarEvent, getUserCalendars as getAppleCalendars, getUpcomingEvents as getAppleEvents } from './apple-calendar';
|
||||||
import { getUpcomingEvents as getOutlookEvents, refreshAccessToken as refreshOutlookTokenAPI, createEvent as createOutlookEvent, updateEvent as updateOutlookEvent, deleteEvent as deleteOutlookEvent } from './outlook-calendar';
|
import { getUpcomingEvents as getOutlookEvents, refreshAccessToken as refreshOutlookTokenAPI, createEvent as createOutlookEvent, updateEvent as updateOutlookEvent, deleteEvent as deleteOutlookEvent } from './outlook-calendar';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
@ -270,32 +270,56 @@ export const getCalendarEvents = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (connection.provider === 'apple') {
|
} else if (connection.provider === 'apple') {
|
||||||
// ... Apple logic ...
|
const [email, appPassword] = connection.accessToken.split(':');
|
||||||
// Initialize Apple OAuth client
|
|
||||||
const appleClient = initializeAppleOAuth(
|
if (!email || !appPassword) {
|
||||||
process.env.APPLE_CLIENT_ID || '',
|
console.error('[CALENDAR] Invalid Apple credentials format');
|
||||||
process.env.APPLE_CLIENT_SECRET || '',
|
continue;
|
||||||
process.env.APPLE_REDIRECT_URI || ''
|
}
|
||||||
);
|
|
||||||
// Get user calendars to identify which ones to fetch events from
|
// Get user calendars to identify which ones to fetch events from
|
||||||
const calendars = await getAppleCalendars(appleClient, accessToken);
|
// We can pass null/dummy client if getUserCalendars just calls validateCredentials which creates its own client
|
||||||
|
// Actually getUserCalendars in apple-calendar.ts (wrapper around validateCredentials) takes (email, password)
|
||||||
|
// But here it was imported as getAppleCalendars(appleClient, accessToken) which matched the old signature?
|
||||||
|
|
||||||
|
// Let's check apple-calendar.ts signature for getUserCalendars.
|
||||||
|
// It is: export const getUserCalendars = validateCredentials;
|
||||||
|
// validateCredentials: (email: string, appSpecificPassword: string)
|
||||||
|
|
||||||
|
const calendars = await getAppleCalendars(email, appPassword);
|
||||||
const calendarIds = calendars.map(c => c.id);
|
const calendarIds = calendars.map(c => c.id);
|
||||||
|
|
||||||
// Fetch events for each calendar
|
// Fetch events for each calendar
|
||||||
for (const calendarId of calendarIds) {
|
for (const calendarId of calendarIds) {
|
||||||
const calendarEvents = await getAppleEvents(
|
const calendarEvents = await getAppleEvents(
|
||||||
appleClient,
|
email,
|
||||||
accessToken,
|
appPassword,
|
||||||
calendarId,
|
calendarId,
|
||||||
timeMin,
|
timeMin,
|
||||||
timeMax
|
timeMax
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log(`[CALENDAR] Fetched ${calendarEvents.length} events from calendar ${calendarId}`);
|
||||||
|
|
||||||
|
console.log(`[CALENDAR] Fetched ${calendarEvents.length} events from calendar ${calendarId}`);
|
||||||
|
|
||||||
events = events.concat(calendarEvents.map((event: any) => ({
|
events = events.concat(calendarEvents.map((event: any) => ({
|
||||||
...event,
|
id: event.id,
|
||||||
|
title: event.title,
|
||||||
|
description: event.description,
|
||||||
|
start: {
|
||||||
|
dateTime: event.startDate,
|
||||||
|
date: undefined // Apple usually returns date-time
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
dateTime: event.endDate,
|
||||||
|
date: undefined
|
||||||
|
},
|
||||||
|
location: event.location,
|
||||||
source: 'apple' as const,
|
source: 'apple' as const,
|
||||||
calendarId,
|
calendarId,
|
||||||
calendarTitle: calendars.find(c => c.id === calendarId)?.title || 'Apple Calendar'
|
calendarTitle: calendars.find(c => c.id === calendarId)?.title || 'Apple Calendar',
|
||||||
|
backgroundColor: '#FF3B30' // Value for Apple Color (Red) or derive from calendar?
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
} else if (connection.provider === 'outlook') {
|
} else if (connection.provider === 'outlook') {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user