feat: add Notion provider integration + Apple Reminders deprecation note
- Notion OAuth integration: start/callback routes, calendar-events dispatch, CRUD operations (create/update/delete via Notion API) - New notion-calendar.ts provider library with database discovery - Onboarding wizard: expanded to 6 steps (header/tasks/display design), improved preview fidelity, universal dummy content, Notion in connect step - Apple Calendar: label changed to "events only", added warning that Reminders are unsupported since iOS 13 (no CalDAV/API from Apple) - Fixed 12h time format on now-line, wizard settings apply on completion v1.57.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4ac73d3e03
commit
ee4b540558
14
package-lock.json
generated
14
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.36.1",
|
"version": "1.56.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "my-weekly-todo-list",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.36.1",
|
"version": "1.56.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
@ -15,6 +15,7 @@
|
|||||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||||
"@fortawesome/react-fontawesome": "^3.2.0",
|
"@fortawesome/react-fontawesome": "^3.2.0",
|
||||||
"@next-auth/prisma-adapter": "^1.0.7",
|
"@next-auth/prisma-adapter": "^1.0.7",
|
||||||
|
"@notionhq/client": "^5.14.0",
|
||||||
"@prisma/client": "5.22.0",
|
"@prisma/client": "5.22.0",
|
||||||
"@tiptap/extension-link": "^3.20.0",
|
"@tiptap/extension-link": "^3.20.0",
|
||||||
"@tiptap/extension-placeholder": "^3.20.0",
|
"@tiptap/extension-placeholder": "^3.20.0",
|
||||||
@ -2269,6 +2270,15 @@
|
|||||||
"node": ">=12.4.0"
|
"node": ">=12.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@notionhq/client": {
|
||||||
|
"version": "5.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@notionhq/client/-/client-5.14.0.tgz",
|
||||||
|
"integrity": "sha512-9bbH7/9M6D9YlHMYCZ1aAFxRCWiKRBpP/XOnAHFtBCFDf00PPhpWRSsGE1FfmjYCNW2BFRj19WshJFH5IFfNvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@panva/hkdf": {
|
"node_modules/@panva/hkdf": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.56.0",
|
"version": "1.57.0",
|
||||||
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -27,6 +27,7 @@
|
|||||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||||
"@fortawesome/react-fontawesome": "^3.2.0",
|
"@fortawesome/react-fontawesome": "^3.2.0",
|
||||||
"@next-auth/prisma-adapter": "^1.0.7",
|
"@next-auth/prisma-adapter": "^1.0.7",
|
||||||
|
"@notionhq/client": "^5.14.0",
|
||||||
"@prisma/client": "5.22.0",
|
"@prisma/client": "5.22.0",
|
||||||
"@tiptap/extension-link": "^3.20.0",
|
"@tiptap/extension-link": "^3.20.0",
|
||||||
"@tiptap/extension-placeholder": "^3.20.0",
|
"@tiptap/extension-placeholder": "^3.20.0",
|
||||||
|
|||||||
124
src/app/api/calendar/notion/callback/route.ts
Normal file
124
src/app/api/calendar/notion/callback/route.ts
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { getUserDatabases } from '@/lib/notion-calendar';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const code = searchParams.get('code');
|
||||||
|
const state = searchParams.get('state');
|
||||||
|
|
||||||
|
const appBaseUrl = process.env.NEXTAUTH_URL || request.url;
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return NextResponse.redirect(new URL('/tasks?error=oauth_code_missing', appBaseUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = (session?.user as any)?.id || state;
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.redirect(new URL('/auth/login?error=session_expired', appBaseUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.redirect(new URL('/auth/login?error=user_not_found', appBaseUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange code for tokens
|
||||||
|
const clientId = process.env.NOTION_CLIENT_ID || '';
|
||||||
|
const clientSecret = process.env.NOTION_CLIENT_SECRET || '';
|
||||||
|
const redirectUri = process.env.NOTION_REDIRECT_URI
|
||||||
|
|| `${process.env.NEXTAUTH_URL}/api/calendar/notion/callback`;
|
||||||
|
|
||||||
|
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
|
||||||
|
|
||||||
|
const tokenResponse = await fetch('https://api.notion.com/v1/oauth/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${credentials}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tokenResponse.ok) {
|
||||||
|
const errorText = await tokenResponse.text();
|
||||||
|
console.error('Notion token exchange failed:', errorText);
|
||||||
|
return NextResponse.redirect(new URL('/tasks?error=oauth_failed', appBaseUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await tokenResponse.json();
|
||||||
|
const accessToken = tokens.access_token;
|
||||||
|
const refreshToken = tokens.refresh_token || null;
|
||||||
|
|
||||||
|
// Fetch databases shared with the integration
|
||||||
|
const databases = await getUserDatabases(accessToken);
|
||||||
|
|
||||||
|
// Map to calendar-style format for consistency
|
||||||
|
const calendars = databases.map(db => ({
|
||||||
|
id: db.id,
|
||||||
|
title: db.title,
|
||||||
|
selected: true,
|
||||||
|
dateProperty: db.dateProperty,
|
||||||
|
backgroundColor: '#000000',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Check for existing connection
|
||||||
|
const existingConnection = await prisma.calendarConnection.findFirst({
|
||||||
|
where: { userId: user.id, provider: 'notion' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingConnection) {
|
||||||
|
// Merge selection state
|
||||||
|
let finalCalendars = calendars;
|
||||||
|
if (existingConnection.calendars && Array.isArray(existingConnection.calendars)) {
|
||||||
|
const existingList = existingConnection.calendars as any[];
|
||||||
|
finalCalendars = calendars.map(remote => {
|
||||||
|
const match = existingList.find((e: any) => e.id === remote.id);
|
||||||
|
return {
|
||||||
|
...remote,
|
||||||
|
selected: match ? match.selected : true,
|
||||||
|
dateProperty: remote.dateProperty || match?.dateProperty,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.calendarConnection.update({
|
||||||
|
where: { id: existingConnection.id },
|
||||||
|
data: {
|
||||||
|
accessToken,
|
||||||
|
refreshToken: refreshToken || existingConnection.refreshToken,
|
||||||
|
calendars: finalCalendars,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.calendarConnection.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
provider: 'notion',
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
calendars,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.redirect(new URL('/tasks?calendar=connected&openSettings=calendars', appBaseUrl));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Notion OAuth error:', error);
|
||||||
|
const appBaseUrl = process.env.NEXTAUTH_URL || request.url;
|
||||||
|
return NextResponse.redirect(new URL('/tasks?error=oauth_failed', appBaseUrl));
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/app/api/calendar/notion/start/route.ts
Normal file
43
src/app/api/calendar/notion/start/route.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getServerSession } from 'next-auth';
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
const userId = (session?.user as any)?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
const baseUrl = process.env.NEXTAUTH_URL || request.url;
|
||||||
|
return NextResponse.redirect(new URL('/auth/login', baseUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = process.env.NOTION_CLIENT_ID;
|
||||||
|
if (!clientId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Notion OAuth not configured. Please set NOTION_CLIENT_ID and NOTION_CLIENT_SECRET.' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUri = process.env.NOTION_REDIRECT_URI
|
||||||
|
|| `${process.env.NEXTAUTH_URL}/api/calendar/notion/callback`;
|
||||||
|
|
||||||
|
const authUrl = new URL('https://api.notion.com/v1/oauth/authorize');
|
||||||
|
authUrl.searchParams.set('client_id', clientId);
|
||||||
|
authUrl.searchParams.set('response_type', 'code');
|
||||||
|
authUrl.searchParams.set('owner', 'user');
|
||||||
|
authUrl.searchParams.set('redirect_uri', redirectUri);
|
||||||
|
authUrl.searchParams.set('state', userId);
|
||||||
|
|
||||||
|
return NextResponse.redirect(authUrl.toString());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initiating Notion OAuth:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to initiate Notion connection' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4908,9 +4908,26 @@ h3 {
|
|||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.onboarding-card {
|
.onboarding-card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100% !important;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
max-height: 100vh;
|
max-height: 100vh;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.onboarding-card {
|
||||||
|
max-width: 95% !important;
|
||||||
|
}
|
||||||
|
.onboarding-wide-step {
|
||||||
|
flex-direction: column !important;
|
||||||
|
}
|
||||||
|
.onboarding-wide-step .onboarding-settings-panel {
|
||||||
|
width: 100% !important;
|
||||||
|
max-height: none !important;
|
||||||
|
overflow-y: visible !important;
|
||||||
|
}
|
||||||
|
.onboarding-wide-step .onboarding-preview-panel {
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -13,7 +13,7 @@ import CalendarEventModal from "./CalendarEventModal";
|
|||||||
import TaskRecurrenceModal from "./RecurrenceModal";
|
import TaskRecurrenceModal from "./RecurrenceModal";
|
||||||
import { GridTaskBlock } from "./GridTaskBlock";
|
import { GridTaskBlock } from "./GridTaskBlock";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faApple, faGoogle, faMicrosoft } from "@fortawesome/free-brands-svg-icons";
|
import { faApple, faGoogle, faMicrosoft, faNotion } from "@fortawesome/free-brands-svg-icons";
|
||||||
import {
|
import {
|
||||||
faServer, faFolder, faBriefcase, faBullseye, faRocket, faStar,
|
faServer, faFolder, faBriefcase, faBullseye, faRocket, faStar,
|
||||||
faLightbulb, faFire, faPalette, faMusic, faMobileScreen, faLaptop,
|
faLightbulb, faFire, faPalette, faMusic, faMobileScreen, faLaptop,
|
||||||
@ -161,7 +161,7 @@ interface CalendarEvent {
|
|||||||
title: string;
|
title: string;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
endTime: string;
|
endTime: string;
|
||||||
source: "google" | "apple" | "outlook" | "synology";
|
source: "google" | "apple" | "outlook" | "synology" | "notion";
|
||||||
calendarId?: string;
|
calendarId?: string;
|
||||||
calendarTitle?: string;
|
calendarTitle?: string;
|
||||||
calendarColor?: string;
|
calendarColor?: string;
|
||||||
@ -290,7 +290,9 @@ const translations: Record<string, any> = {
|
|||||||
connectMore: "Connect More",
|
connectMore: "Connect More",
|
||||||
connectGoogle: "Connect Google Calendar",
|
connectGoogle: "Connect Google Calendar",
|
||||||
connectApple: "Connect Apple Calendar",
|
connectApple: "Connect Apple Calendar",
|
||||||
|
appleRemindersNote: "Apple Reminders are not supported. Since iOS 13 / macOS Catalina, Apple no longer provides a CalDAV or public API for Reminders. Only calendar events can be synced.",
|
||||||
connectSynology: "Connect Synology",
|
connectSynology: "Connect Synology",
|
||||||
|
connectNotion: "Connect Notion",
|
||||||
noCalendars: "No calendars connected yet.",
|
noCalendars: "No calendars connected yet.",
|
||||||
dataPrivacy: "Data & Privacy",
|
dataPrivacy: "Data & Privacy",
|
||||||
downloadData: "Download My Data",
|
downloadData: "Download My Data",
|
||||||
@ -381,6 +383,7 @@ const translations: Record<string, any> = {
|
|||||||
styling: "Styling",
|
styling: "Styling",
|
||||||
motivation: "Motivation",
|
motivation: "Motivation",
|
||||||
about: "About",
|
about: "About",
|
||||||
|
setupAssistant: "Run Setup Assistant",
|
||||||
weekStartLabel: "Start week on",
|
weekStartLabel: "Start week on",
|
||||||
startViewLabel: "Start view on",
|
startViewLabel: "Start view on",
|
||||||
monday: "Monday",
|
monday: "Monday",
|
||||||
@ -504,6 +507,8 @@ const translations: Record<string, any> = {
|
|||||||
connectMore: "Mehr verbinden",
|
connectMore: "Mehr verbinden",
|
||||||
connectGoogle: "Google Kalender verbinden",
|
connectGoogle: "Google Kalender verbinden",
|
||||||
connectApple: "Apple Kalender verbinden",
|
connectApple: "Apple Kalender verbinden",
|
||||||
|
appleRemindersNote: "Apple Erinnerungen werden nicht unterstützt. Seit iOS 13 / macOS Catalina bietet Apple keine CalDAV- oder öffentliche API mehr für Erinnerungen an. Nur Kalender-Ereignisse können synchronisiert werden.",
|
||||||
|
connectNotion: "Notion verbinden",
|
||||||
noCalendars: "Keine Kalender verbunden.",
|
noCalendars: "Keine Kalender verbunden.",
|
||||||
dataPrivacy: "Daten & Datenschutz",
|
dataPrivacy: "Daten & Datenschutz",
|
||||||
downloadData: "Meine Daten herunterladen",
|
downloadData: "Meine Daten herunterladen",
|
||||||
@ -594,6 +599,7 @@ const translations: Record<string, any> = {
|
|||||||
styling: "Design",
|
styling: "Design",
|
||||||
motivation: "Motivation",
|
motivation: "Motivation",
|
||||||
about: "Über",
|
about: "Über",
|
||||||
|
setupAssistant: "Einrichtungsassistent starten",
|
||||||
weekStartLabel: "Woche beginnt am",
|
weekStartLabel: "Woche beginnt am",
|
||||||
startViewLabel: "Ansicht beginnt mit",
|
startViewLabel: "Ansicht beginnt mit",
|
||||||
monday: "Montag",
|
monday: "Montag",
|
||||||
@ -715,7 +721,9 @@ const translations: Record<string, any> = {
|
|||||||
connectMore: "En connecter d'autres",
|
connectMore: "En connecter d'autres",
|
||||||
connectGoogle: "Connecter Google Agenda",
|
connectGoogle: "Connecter Google Agenda",
|
||||||
connectApple: "Connecter le calendrier Apple",
|
connectApple: "Connecter le calendrier Apple",
|
||||||
|
appleRemindersNote: "Les rappels Apple ne sont pas pris en charge. Depuis iOS 13 / macOS Catalina, Apple ne fournit plus de CalDAV ni d'API publique pour les rappels. Seuls les événements de calendrier peuvent être synchronisés.",
|
||||||
connectSynology: "Connecter Synology",
|
connectSynology: "Connecter Synology",
|
||||||
|
connectNotion: "Connecter Notion",
|
||||||
noCalendars: "Aucun calendrier connecté.",
|
noCalendars: "Aucun calendrier connecté.",
|
||||||
dataPrivacy: "Données et confidentialité",
|
dataPrivacy: "Données et confidentialité",
|
||||||
downloadData: "Télécharger mes données",
|
downloadData: "Télécharger mes données",
|
||||||
@ -806,6 +814,7 @@ const translations: Record<string, any> = {
|
|||||||
styling: "Style",
|
styling: "Style",
|
||||||
motivation: "Motivation",
|
motivation: "Motivation",
|
||||||
about: "À propos",
|
about: "À propos",
|
||||||
|
setupAssistant: "Lancer l'assistant de configuration",
|
||||||
weekStartLabel: "La semaine commence le",
|
weekStartLabel: "La semaine commence le",
|
||||||
startViewLabel: "Vue commence par",
|
startViewLabel: "Vue commence par",
|
||||||
monday: "Lundi",
|
monday: "Lundi",
|
||||||
@ -927,7 +936,9 @@ const translations: Record<string, any> = {
|
|||||||
connectMore: "Conectar más",
|
connectMore: "Conectar más",
|
||||||
connectGoogle: "Conectar Google Calendar",
|
connectGoogle: "Conectar Google Calendar",
|
||||||
connectApple: "Conectar calendario de Apple",
|
connectApple: "Conectar calendario de Apple",
|
||||||
|
appleRemindersNote: "Los recordatorios de Apple no son compatibles. Desde iOS 13 / macOS Catalina, Apple ya no ofrece CalDAV ni una API pública para recordatorios. Solo se pueden sincronizar eventos del calendario.",
|
||||||
connectSynology: "Conectar Synology",
|
connectSynology: "Conectar Synology",
|
||||||
|
connectNotion: "Conectar Notion",
|
||||||
noCalendars: "No hay calendarios conectados.",
|
noCalendars: "No hay calendarios conectados.",
|
||||||
dataPrivacy: "Datos y privacidad",
|
dataPrivacy: "Datos y privacidad",
|
||||||
downloadData: "Descargar mis datos",
|
downloadData: "Descargar mis datos",
|
||||||
@ -1018,6 +1029,7 @@ const translations: Record<string, any> = {
|
|||||||
styling: "Estilo",
|
styling: "Estilo",
|
||||||
motivation: "Motivación",
|
motivation: "Motivación",
|
||||||
about: "Acerca de",
|
about: "Acerca de",
|
||||||
|
setupAssistant: "Iniciar asistente de configuración",
|
||||||
weekStartLabel: "La semana empieza el",
|
weekStartLabel: "La semana empieza el",
|
||||||
startViewLabel: "Vista empieza con",
|
startViewLabel: "Vista empieza con",
|
||||||
monday: "Lunes",
|
monday: "Lunes",
|
||||||
@ -1139,7 +1151,9 @@ const translations: Record<string, any> = {
|
|||||||
connectMore: "Collega altri",
|
connectMore: "Collega altri",
|
||||||
connectGoogle: "Collega Google Calendar",
|
connectGoogle: "Collega Google Calendar",
|
||||||
connectApple: "Collega il calendario Apple",
|
connectApple: "Collega il calendario Apple",
|
||||||
|
appleRemindersNote: "I promemoria Apple non sono supportati. Da iOS 13 / macOS Catalina, Apple non fornisce più CalDAV o un'API pubblica per i promemoria. Solo gli eventi del calendario possono essere sincronizzati.",
|
||||||
connectSynology: "Collega Synology",
|
connectSynology: "Collega Synology",
|
||||||
|
connectNotion: "Collega Notion",
|
||||||
noCalendars: "Nessun calendario collegato.",
|
noCalendars: "Nessun calendario collegato.",
|
||||||
dataPrivacy: "Dati e privacy",
|
dataPrivacy: "Dati e privacy",
|
||||||
downloadData: "Scarica i miei dati",
|
downloadData: "Scarica i miei dati",
|
||||||
@ -1230,6 +1244,7 @@ const translations: Record<string, any> = {
|
|||||||
styling: "Stile",
|
styling: "Stile",
|
||||||
motivation: "Motivazione",
|
motivation: "Motivazione",
|
||||||
about: "Info",
|
about: "Info",
|
||||||
|
setupAssistant: "Assistente di configurazione",
|
||||||
weekStartLabel: "La settimana inizia il",
|
weekStartLabel: "La settimana inizia il",
|
||||||
startViewLabel: "Vista inizia con",
|
startViewLabel: "Vista inizia con",
|
||||||
monday: "Lunedì",
|
monday: "Lunedì",
|
||||||
@ -7167,7 +7182,7 @@ export default function WeeklyView() {
|
|||||||
getSlotHeight(effectiveCellDuration) / effectiveCellDuration;
|
getSlotHeight(effectiveCellDuration) / effectiveCellDuration;
|
||||||
const topPosition =
|
const topPosition =
|
||||||
minutesSinceStart * pixelsPerMinute;
|
minutesSinceStart * pixelsPerMinute;
|
||||||
const timeString = `${String(nowHour).padStart(2, "0")}:${String(nowMinute).padStart(2, "0")}`;
|
const timeString = formatHour(nowHour, nowMinute, "full", timeFormat);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="now-line"
|
className="now-line"
|
||||||
@ -8066,7 +8081,7 @@ export default function WeeklyView() {
|
|||||||
list.externalProvider === "apple" ? "https://www.icloud.com/reminders/" : null;
|
list.externalProvider === "apple" ? "https://www.icloud.com/reminders/" : null;
|
||||||
const iconContent = (
|
const iconContent = (
|
||||||
<span
|
<span
|
||||||
title={`Synced with ${list.externalProvider === "outlook" ? "Microsoft" : list.externalProvider === "google" ? "Google" : list.externalProvider === "apple" ? "Apple" : list.externalProvider}`}
|
title={`Synced with ${list.externalProvider === "outlook" ? "Microsoft" : list.externalProvider === "google" ? "Google" : list.externalProvider === "apple" ? "Apple" : list.externalProvider === "notion" ? "Notion" : list.externalProvider}`}
|
||||||
style={{ display: "inline-flex", alignItems: "center", marginLeft: "8px", opacity: 0.8, flexShrink: 0, cursor: providerUrl ? "pointer" : "default" }}
|
style={{ display: "inline-flex", alignItems: "center", marginLeft: "8px", opacity: 0.8, flexShrink: 0, cursor: providerUrl ? "pointer" : "default" }}
|
||||||
>
|
>
|
||||||
{list.externalProvider === "outlook" ? (
|
{list.externalProvider === "outlook" ? (
|
||||||
@ -8077,6 +8092,8 @@ export default function WeeklyView() {
|
|||||||
<FontAwesomeIcon icon={faApple} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#555] dark:hover:text-[#ccc] transition-colors" />
|
<FontAwesomeIcon icon={faApple} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#555] dark:hover:text-[#ccc] transition-colors" />
|
||||||
) : list.externalProvider === "synology" ? (
|
) : list.externalProvider === "synology" ? (
|
||||||
<FontAwesomeIcon icon={faServer} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#007AFF] dark:hover:text-[#3A9CFF] transition-colors" />
|
<FontAwesomeIcon icon={faServer} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#007AFF] dark:hover:text-[#3A9CFF] transition-colors" />
|
||||||
|
) : list.externalProvider === "notion" ? (
|
||||||
|
<FontAwesomeIcon icon={faNotion} className="w-4 h-4 text-zinc-600 dark:text-zinc-400 hover:text-[#000] dark:hover:text-[#fff] transition-colors" />
|
||||||
) : (
|
) : (
|
||||||
<RefreshCcw size={14} className="text-zinc-400" />
|
<RefreshCcw size={14} className="text-zinc-400" />
|
||||||
)}
|
)}
|
||||||
@ -8757,7 +8774,12 @@ export default function WeeklyView() {
|
|||||||
<SettingsSidebar
|
<SettingsSidebar
|
||||||
initialTab={activeTab}
|
initialTab={activeTab}
|
||||||
onRemoveConnection={handleRemoveConnection}
|
onRemoveConnection={handleRemoveConnection}
|
||||||
onClose={() => setShowSettings(false)}
|
onClose={() => {
|
||||||
|
setShowSettings(false);
|
||||||
|
if (profile.hasCompletedOnboarding === false && localStorage.getItem("onboarding_step")) {
|
||||||
|
setShowOnboarding(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onSettingsChanged={handleSettingsChanged}
|
onSettingsChanged={handleSettingsChanged}
|
||||||
showTimeGrid={showTimeGrid}
|
showTimeGrid={showTimeGrid}
|
||||||
setShowTimeGrid={setShowTimeGrid}
|
setShowTimeGrid={setShowTimeGrid}
|
||||||
@ -8875,6 +8897,7 @@ export default function WeeklyView() {
|
|||||||
saveViewSetting: saveViewSetting as any,
|
saveViewSetting: saveViewSetting as any,
|
||||||
getEffective: getEffective as any,
|
getEffective: getEffective as any,
|
||||||
}}
|
}}
|
||||||
|
onRunSetupAssistant={() => setShowOnboarding(true)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -8897,11 +8920,16 @@ export default function WeeklyView() {
|
|||||||
darkMode={darkMode}
|
darkMode={darkMode}
|
||||||
language={language}
|
language={language}
|
||||||
connections={connections}
|
connections={connections}
|
||||||
onComplete={() => { saveSetting("hasCompletedOnboarding", true); setShowOnboarding(false); }}
|
onComplete={async () => { await saveSetting("hasCompletedOnboarding", true); setShowOnboarding(false); await fetchProfile(); }}
|
||||||
onSkip={() => { saveSetting("hasCompletedOnboarding", true); setShowOnboarding(false); }}
|
onSkip={async () => { await saveSetting("hasCompletedOnboarding", true); setShowOnboarding(false); await fetchProfile(); }}
|
||||||
saveSetting={saveSetting}
|
saveSetting={saveSetting}
|
||||||
onLanguageChange={(lang: string) => { setLanguage(lang); }}
|
onLanguageChange={(lang: string) => { setLanguage(lang); }}
|
||||||
onDarkModeToggle={() => setDarkMode(!darkMode)}
|
onDarkModeToggle={() => setDarkMode(!darkMode)}
|
||||||
|
onConnectProvider={(provider: string) => {
|
||||||
|
setShowOnboarding(false);
|
||||||
|
setActiveTab("calendar");
|
||||||
|
setShowSettings(true);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -9744,6 +9772,7 @@ function TaskItem({
|
|||||||
outlook: { icon: faMicrosoft, color: "#0078D4", label: "Microsoft" },
|
outlook: { icon: faMicrosoft, color: "#0078D4", label: "Microsoft" },
|
||||||
apple: { icon: faApple, color: "#555", label: "Apple" },
|
apple: { icon: faApple, color: "#555", label: "Apple" },
|
||||||
synology: { icon: faServer, color: "#007AFF", label: "Synology" },
|
synology: { icon: faServer, color: "#007AFF", label: "Synology" },
|
||||||
|
notion: { icon: faNotion, color: "#000000", label: "Notion" },
|
||||||
};
|
};
|
||||||
const info = iconMap[provider];
|
const info = iconMap[provider];
|
||||||
if (!info) return null;
|
if (!info) return null;
|
||||||
@ -10646,6 +10675,7 @@ interface SettingsSidebarProps {
|
|||||||
saveViewSetting: (key: string, value: any, perView: boolean) => void;
|
saveViewSetting: (key: string, value: any, perView: boolean) => void;
|
||||||
getEffective: (key: string, globalVal: any) => any;
|
getEffective: (key: string, globalVal: any) => any;
|
||||||
};
|
};
|
||||||
|
onRunSetupAssistant?: () => void;
|
||||||
}
|
}
|
||||||
// Notes Sidebar Component
|
// Notes Sidebar Component
|
||||||
interface NotesSidebarProps {
|
interface NotesSidebarProps {
|
||||||
@ -10871,6 +10901,7 @@ function SettingsSidebar({
|
|||||||
isMobile: isMobileSidebar,
|
isMobile: isMobileSidebar,
|
||||||
mobileActions,
|
mobileActions,
|
||||||
perView,
|
perView,
|
||||||
|
onRunSetupAssistant,
|
||||||
}: SettingsSidebarProps) {
|
}: SettingsSidebarProps) {
|
||||||
const [activeTab, setActiveTab] = useState<
|
const [activeTab, setActiveTab] = useState<
|
||||||
"calendar" | "general" | "account" | "styling" | "motivation" | "about" | "localisation"
|
"calendar" | "general" | "account" | "styling" | "motivation" | "about" | "localisation"
|
||||||
@ -11084,6 +11115,10 @@ function SettingsSidebar({
|
|||||||
window.location.href = "/api/calendar/outlook/start";
|
window.location.href = "/api/calendar/outlook/start";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNotionConnect = () => {
|
||||||
|
window.location.href = "/api/calendar/notion/start";
|
||||||
|
};
|
||||||
|
|
||||||
const handleUpdateCalendar = async (
|
const handleUpdateCalendar = async (
|
||||||
connectionId: string,
|
connectionId: string,
|
||||||
calendarId: string,
|
calendarId: string,
|
||||||
@ -12191,6 +12226,8 @@ function SettingsSidebar({
|
|||||||
? <FontAwesomeIcon icon={faApple} />
|
? <FontAwesomeIcon icon={faApple} />
|
||||||
: conn.provider === "synology"
|
: conn.provider === "synology"
|
||||||
? <FontAwesomeIcon icon={faServer} />
|
? <FontAwesomeIcon icon={faServer} />
|
||||||
|
: conn.provider === "notion"
|
||||||
|
? <FontAwesomeIcon icon={faNotion} />
|
||||||
: <FontAwesomeIcon icon={faMicrosoft} />}
|
: <FontAwesomeIcon icon={faMicrosoft} />}
|
||||||
</span>
|
</span>
|
||||||
{conn.provider === "google"
|
{conn.provider === "google"
|
||||||
@ -12199,6 +12236,8 @@ function SettingsSidebar({
|
|||||||
? "Apple Calendar"
|
? "Apple Calendar"
|
||||||
: conn.provider === "synology"
|
: conn.provider === "synology"
|
||||||
? "Synology Calendar"
|
? "Synology Calendar"
|
||||||
|
: conn.provider === "notion"
|
||||||
|
? "Notion"
|
||||||
: "Outlook Calendar"}
|
: "Outlook Calendar"}
|
||||||
</div>
|
</div>
|
||||||
{confirmDisconnectId === conn.id ? (
|
{confirmDisconnectId === conn.id ? (
|
||||||
@ -12418,6 +12457,8 @@ function SettingsSidebar({
|
|||||||
? t.noCalendarsApple
|
? t.noCalendarsApple
|
||||||
: conn.provider === "synology"
|
: conn.provider === "synology"
|
||||||
? t.noCalendarsSynology
|
? t.noCalendarsSynology
|
||||||
|
: conn.provider === "notion"
|
||||||
|
? t.selectionAfterConnect
|
||||||
: t.selectionAfterConnect}
|
: t.selectionAfterConnect}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -12461,6 +12502,12 @@ function SettingsSidebar({
|
|||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faServer} className="mr-2" /> {t.connectSynology || "Connect Synology"}
|
<FontAwesomeIcon icon={faServer} className="mr-2" /> {t.connectSynology || "Connect Synology"}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleNotionConnect}
|
||||||
|
className="calendar-connect-btn"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faNotion} className="mr-2" /> {t.connectNotion || "Connect Notion"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3
|
<h3
|
||||||
@ -14332,6 +14379,24 @@ function SettingsSidebar({
|
|||||||
Version {process.env.NEXT_PUBLIC_APP_VERSION || "1.8.0"}
|
Version {process.env.NEXT_PUBLIC_APP_VERSION || "1.8.0"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => { onRunSetupAssistant?.(); }}
|
||||||
|
style={{
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center", gap: "8px",
|
||||||
|
width: "100%", padding: "12px 20px", borderRadius: "10px",
|
||||||
|
border: "1px solid var(--weekly-border, #e5e7eb)",
|
||||||
|
background: "var(--weekly-bg, #fff)",
|
||||||
|
color: "var(--weekly-text, #333)",
|
||||||
|
fontSize: "0.9rem", fontWeight: 500, cursor: "pointer",
|
||||||
|
transition: "all 0.15s",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.background = "var(--weekly-hover, #f3f4f6)"; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.background = "var(--weekly-bg, #fff)"; }}
|
||||||
|
>
|
||||||
|
<Play size={16} />
|
||||||
|
{t.setupAssistant}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Account Tab */
|
/* Account Tab */
|
||||||
@ -15035,6 +15100,13 @@ function SettingsSidebar({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded p-3 mb-4 text-sm text-amber-800" style={{ display: "flex", gap: "8px", alignItems: "flex-start" }}>
|
||||||
|
<span style={{ fontSize: "1rem", flexShrink: 0 }}>⚠️</span>
|
||||||
|
<p style={{ margin: 0, fontSize: "0.8rem", lineHeight: 1.4 }}>
|
||||||
|
{t.appleRemindersNote}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{appleCalError && (
|
{appleCalError && (
|
||||||
<div className="bg-red-50 text-red-600 p-3 rounded mb-4 text-sm">
|
<div className="bg-red-50 text-red-600 p-3 rounded mb-4 text-sm">
|
||||||
{appleCalError}
|
{appleCalError}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { GoogleCalendarEvent, getUserCalendars as getGoogleCalendars, getUpcomin
|
|||||||
import { AppleCalendarEvent, getUserCalendars as getAppleCalendars, getUpcomingEvents as getAppleEvents } 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 { getUserCalendars as getSynologyCalendars, getUpcomingEvents as getSynologyEvents } from './synology-calendar';
|
import { getUserCalendars as getSynologyCalendars, getUpcomingEvents as getSynologyEvents } from './synology-calendar';
|
||||||
|
import { getUpcomingEvents as getNotionEvents, refreshAccessToken as refreshNotionToken } from './notion-calendar';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
@ -42,7 +43,7 @@ export interface CalendarEvent {
|
|||||||
recurrence?: string;
|
recurrence?: string;
|
||||||
recurringEventId?: string;
|
recurringEventId?: string;
|
||||||
isRecurring?: boolean;
|
isRecurring?: boolean;
|
||||||
source: 'google' | 'apple' | 'outlook' | 'synology';
|
source: 'google' | 'apple' | 'outlook' | 'synology' | 'notion';
|
||||||
calendarId: string;
|
calendarId: string;
|
||||||
calendarTitle: string;
|
calendarTitle: string;
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
@ -581,6 +582,78 @@ export const getCalendarEvents = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (connection.provider === 'notion') {
|
||||||
|
console.log('[CALENDAR] Processing Notion connection:', connection.id);
|
||||||
|
|
||||||
|
// Refresh token if available
|
||||||
|
if (connection.refreshToken) {
|
||||||
|
try {
|
||||||
|
const refreshed = await refreshNotionToken(
|
||||||
|
connection.refreshToken,
|
||||||
|
process.env.NOTION_CLIENT_ID || '',
|
||||||
|
process.env.NOTION_CLIENT_SECRET || '',
|
||||||
|
);
|
||||||
|
accessToken = refreshed.accessToken;
|
||||||
|
await prisma.calendarConnection.update({
|
||||||
|
where: { id: connection.id },
|
||||||
|
data: {
|
||||||
|
accessToken: refreshed.accessToken,
|
||||||
|
refreshToken: refreshed.refreshToken,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (refreshErr) {
|
||||||
|
console.error('[CALENDAR] Notion token refresh failed, using existing token:', refreshErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get selected databases
|
||||||
|
const databases = (connection.calendars as any[] || []).filter((c: any) => c.selected !== false);
|
||||||
|
if (databases.length === 0) {
|
||||||
|
console.log('[CALENDAR] Notion: no databases selected, skipping');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const db of databases) {
|
||||||
|
if (!db.dateProperty) {
|
||||||
|
console.log(`[CALENDAR] Notion: database "${db.title}" has no date property, skipping`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const notionEvents = await getNotionEvents(
|
||||||
|
accessToken,
|
||||||
|
db.id,
|
||||||
|
db.dateProperty,
|
||||||
|
timeMin,
|
||||||
|
timeMax,
|
||||||
|
db.title,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[CALENDAR] Notion: fetched ${notionEvents.length} events from "${db.title}"`);
|
||||||
|
|
||||||
|
events = events.concat(notionEvents.map((ne) => ({
|
||||||
|
id: ne.id,
|
||||||
|
title: ne.title,
|
||||||
|
description: ne.description,
|
||||||
|
start: {
|
||||||
|
dateTime: ne.allDay ? undefined : ne.start,
|
||||||
|
date: ne.allDay ? ne.start : undefined,
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
dateTime: ne.allDay ? undefined : (ne.end || ne.start),
|
||||||
|
date: ne.allDay ? (ne.end || ne.start) : undefined,
|
||||||
|
},
|
||||||
|
location: undefined,
|
||||||
|
url: ne.url,
|
||||||
|
source: 'notion' as const,
|
||||||
|
calendarId: db.id,
|
||||||
|
calendarTitle: db.title,
|
||||||
|
backgroundColor: '#000000',
|
||||||
|
})));
|
||||||
|
} catch (dbError) {
|
||||||
|
console.error(`[CALENDAR] Notion: error fetching from "${db.title}":`, dbError);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[CALENDAR] Total events for connection:', events.length);
|
console.log('[CALENDAR] Total events for connection:', events.length);
|
||||||
@ -875,6 +948,37 @@ export const createCalendarEvent = async (
|
|||||||
calendarId,
|
calendarId,
|
||||||
calendarTitle: '',
|
calendarTitle: '',
|
||||||
} as CalendarEvent;
|
} as CalendarEvent;
|
||||||
|
} else if (connection.provider === 'notion') {
|
||||||
|
if (!event.title) throw new Error('Event title is required');
|
||||||
|
if (!event.start) throw new Error('Event start time is required');
|
||||||
|
|
||||||
|
const databases = (connection.calendars as any[] || []);
|
||||||
|
const db = databases.find((d: any) => d.id === calendarId);
|
||||||
|
const dateProperty = db?.dateProperty;
|
||||||
|
if (!dateProperty) throw new Error('No date property found for this Notion database');
|
||||||
|
|
||||||
|
const startDate = event.start.dateTime || event.start.date || '';
|
||||||
|
const endDate = event.end?.dateTime || event.end?.date || undefined;
|
||||||
|
|
||||||
|
const { createEvent: createNotionEvent } = await import('./notion-calendar');
|
||||||
|
const pageId = await createNotionEvent(
|
||||||
|
connection.accessToken,
|
||||||
|
calendarId,
|
||||||
|
dateProperty,
|
||||||
|
event.title,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: pageId,
|
||||||
|
title: event.title,
|
||||||
|
start: event.start,
|
||||||
|
end: event.end || event.start,
|
||||||
|
source: 'notion',
|
||||||
|
calendarId,
|
||||||
|
calendarTitle: db?.title || '',
|
||||||
|
} as CalendarEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Provider ${connection.provider} does not support creating events yet.`);
|
throw new Error(`Provider ${connection.provider} does not support creating events yet.`);
|
||||||
@ -1060,6 +1164,34 @@ export const updateCalendarEvent = async (
|
|||||||
calendarId,
|
calendarId,
|
||||||
calendarTitle: '',
|
calendarTitle: '',
|
||||||
} as CalendarEvent;
|
} as CalendarEvent;
|
||||||
|
} else if (connection.provider === 'notion') {
|
||||||
|
const databases = (connection.calendars as any[] || []);
|
||||||
|
const db = databases.find((d: any) => d.id === calendarId);
|
||||||
|
const dateProperty = db?.dateProperty;
|
||||||
|
if (!dateProperty) throw new Error('No date property found for this Notion database');
|
||||||
|
|
||||||
|
const startDate = event.start?.dateTime || event.start?.date || undefined;
|
||||||
|
const endDate = event.end?.dateTime || event.end?.date || undefined;
|
||||||
|
|
||||||
|
const { updateEvent: updateNotionEvent } = await import('./notion-calendar');
|
||||||
|
await updateNotionEvent(
|
||||||
|
connection.accessToken,
|
||||||
|
eventId,
|
||||||
|
dateProperty,
|
||||||
|
event.title,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: eventId,
|
||||||
|
title: event.title || '',
|
||||||
|
start: event.start || { dateTime: '' },
|
||||||
|
end: event.end || event.start || { dateTime: '' },
|
||||||
|
source: 'notion',
|
||||||
|
calendarId,
|
||||||
|
calendarTitle: db?.title || '',
|
||||||
|
} as CalendarEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Provider ${connection.provider} does not support updating events yet.`);
|
throw new Error(`Provider ${connection.provider} does not support updating events yet.`);
|
||||||
@ -1125,6 +1257,10 @@ export const deleteCalendarEvent = async (
|
|||||||
m.deleteEvent(serverUrl, username, password, calendarId, eventId)
|
m.deleteEvent(serverUrl, username, password, calendarId, eventId)
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
} else if (connection.provider === 'notion') {
|
||||||
|
const { deleteEvent: deleteNotionEvent } = await import('./notion-calendar');
|
||||||
|
await deleteNotionEvent(connection.accessToken, eventId);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Provider ${connection.provider} does not support deleting events yet.`);
|
throw new Error(`Provider ${connection.provider} does not support deleting events yet.`);
|
||||||
|
|||||||
@ -29,7 +29,7 @@ export async function sendVerificationEmail(
|
|||||||
token: string
|
token: string
|
||||||
) {
|
) {
|
||||||
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
|
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
|
||||||
const verifyLink = `${baseUrl}/auth/verify-email?token=${token}`;
|
const verifyLink = `${baseUrl}/api/auth/verify-email?token=${token}`;
|
||||||
|
|
||||||
const html = `
|
const html = `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
|||||||
246
src/lib/notion-calendar.ts
Normal file
246
src/lib/notion-calendar.ts
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
import { Client } from '@notionhq/client';
|
||||||
|
|
||||||
|
export interface NotionDatabase {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
selected: boolean;
|
||||||
|
dateProperty?: string; // name of the date property to use
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotionEvent {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
start: string; // ISO date or datetime
|
||||||
|
end?: string;
|
||||||
|
allDay: boolean;
|
||||||
|
url?: string;
|
||||||
|
databaseId: string;
|
||||||
|
databaseTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all databases shared with the integration
|
||||||
|
*/
|
||||||
|
export async function getUserDatabases(accessToken: string): Promise<NotionDatabase[]> {
|
||||||
|
const notion = new Client({ auth: accessToken });
|
||||||
|
|
||||||
|
const response = await notion.search({
|
||||||
|
filter: { property: 'object', value: 'database' },
|
||||||
|
page_size: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const databases: NotionDatabase[] = [];
|
||||||
|
for (const result of response.results) {
|
||||||
|
if (result.object !== 'database') continue;
|
||||||
|
const db = result as any;
|
||||||
|
const title = db.title?.map((t: any) => t.plain_text).join('') || 'Untitled';
|
||||||
|
|
||||||
|
// Find a date property in the database schema
|
||||||
|
let dateProperty: string | undefined;
|
||||||
|
if (db.properties) {
|
||||||
|
for (const [name, prop] of Object.entries(db.properties)) {
|
||||||
|
if ((prop as any).type === 'date') {
|
||||||
|
dateProperty = name;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
databases.push({
|
||||||
|
id: db.id,
|
||||||
|
title,
|
||||||
|
selected: true,
|
||||||
|
dateProperty,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return databases;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query a Notion database for items with dates in the given range
|
||||||
|
*/
|
||||||
|
export async function getUpcomingEvents(
|
||||||
|
accessToken: string,
|
||||||
|
databaseId: string,
|
||||||
|
dateProperty: string,
|
||||||
|
timeMin: string,
|
||||||
|
timeMax: string,
|
||||||
|
databaseTitle: string,
|
||||||
|
): Promise<NotionEvent[]> {
|
||||||
|
const notion = new Client({ auth: accessToken });
|
||||||
|
|
||||||
|
const startDate = timeMin.split('T')[0];
|
||||||
|
const endDate = timeMax.split('T')[0];
|
||||||
|
|
||||||
|
let results: any[] = [];
|
||||||
|
let hasMore = true;
|
||||||
|
let startCursor: string | undefined;
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
const response = await notion.databases.query({
|
||||||
|
database_id: databaseId,
|
||||||
|
filter: {
|
||||||
|
and: [
|
||||||
|
{
|
||||||
|
property: dateProperty,
|
||||||
|
date: { on_or_after: startDate },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
property: dateProperty,
|
||||||
|
date: { on_or_before: endDate },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
start_cursor: startCursor,
|
||||||
|
page_size: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
results = results.concat(response.results);
|
||||||
|
hasMore = response.has_more;
|
||||||
|
startCursor = response.next_cursor || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.map((page: any) => {
|
||||||
|
// Extract title from the first title property
|
||||||
|
let title = 'Untitled';
|
||||||
|
for (const [, prop] of Object.entries(page.properties)) {
|
||||||
|
const p = prop as any;
|
||||||
|
if (p.type === 'title' && p.title?.length > 0) {
|
||||||
|
title = p.title.map((t: any) => t.plain_text).join('');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract date
|
||||||
|
const dateProp = page.properties[dateProperty];
|
||||||
|
const dateVal = dateProp?.date;
|
||||||
|
const start = dateVal?.start || '';
|
||||||
|
const end = dateVal?.end || undefined;
|
||||||
|
const allDay = start.length === 10; // YYYY-MM-DD = all day
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: page.id,
|
||||||
|
title,
|
||||||
|
description: undefined,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
allDay,
|
||||||
|
url: page.url,
|
||||||
|
databaseId,
|
||||||
|
databaseTitle,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a page in a Notion database
|
||||||
|
*/
|
||||||
|
export async function createEvent(
|
||||||
|
accessToken: string,
|
||||||
|
databaseId: string,
|
||||||
|
dateProperty: string,
|
||||||
|
title: string,
|
||||||
|
startDate: string,
|
||||||
|
endDate?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const notion = new Client({ auth: accessToken });
|
||||||
|
|
||||||
|
const page = await notion.pages.create({
|
||||||
|
parent: { database_id: databaseId },
|
||||||
|
properties: {
|
||||||
|
// The title property - find it dynamically
|
||||||
|
title: {
|
||||||
|
title: [{ text: { content: title } }],
|
||||||
|
},
|
||||||
|
[dateProperty]: {
|
||||||
|
date: {
|
||||||
|
start: startDate,
|
||||||
|
end: endDate || null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
return page.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a page in Notion
|
||||||
|
*/
|
||||||
|
export async function updateEvent(
|
||||||
|
accessToken: string,
|
||||||
|
pageId: string,
|
||||||
|
dateProperty: string,
|
||||||
|
title?: string,
|
||||||
|
startDate?: string,
|
||||||
|
endDate?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const notion = new Client({ auth: accessToken });
|
||||||
|
|
||||||
|
const properties: any = {};
|
||||||
|
if (title !== undefined) {
|
||||||
|
properties.title = { title: [{ text: { content: title } }] };
|
||||||
|
}
|
||||||
|
if (startDate !== undefined) {
|
||||||
|
properties[dateProperty] = {
|
||||||
|
date: { start: startDate, end: endDate || null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await notion.pages.update({
|
||||||
|
page_id: pageId,
|
||||||
|
properties,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archive (soft-delete) a page in Notion
|
||||||
|
*/
|
||||||
|
export async function deleteEvent(
|
||||||
|
accessToken: string,
|
||||||
|
pageId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const notion = new Client({ auth: accessToken });
|
||||||
|
|
||||||
|
await notion.pages.update({
|
||||||
|
page_id: pageId,
|
||||||
|
archived: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh Notion OAuth token
|
||||||
|
*/
|
||||||
|
export async function refreshAccessToken(
|
||||||
|
refreshToken: string,
|
||||||
|
clientId: string,
|
||||||
|
clientSecret: string,
|
||||||
|
): Promise<{ accessToken: string; refreshToken: string; expiresAt?: Date }> {
|
||||||
|
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
|
||||||
|
|
||||||
|
const response = await fetch('https://api.notion.com/v1/oauth/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${credentials}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
throw new Error(`Notion token refresh failed: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return {
|
||||||
|
accessToken: data.access_token,
|
||||||
|
refreshToken: data.refresh_token || refreshToken,
|
||||||
|
expiresAt: undefined, // Notion doesn't return expiry in refresh
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user