diff --git a/ka-note/.env.example b/ka-note/.env.example index a2f9169..71f74bb 100644 --- a/ka-note/.env.example +++ b/ka-note/.env.example @@ -7,6 +7,14 @@ AZURE_TENANT_ID= # AI lock duration in hours (default: 24, use 168 for 7 days) # AI_LOCK_EXPIRY_HOURS=168 +# Graph API calendar integration (OBO flow) +# AZURE_CLIENT_SECRET= +# AZURE_OBO_CLIENT_ID= +# Only needed if VITE_AZURE_CLIENT_ID != AZURE_CLIENT_ID (separate frontend/backend registrations). +# The OBO assertion token's audience must match this client_id. +# Required: App Registration → API permissions → Microsoft Graph → Calendars.Read (delegated) → Grant admin consent +# Then: Certificates & secrets → New client secret → copy value here + # Client needs VITE_ prefix — create client/.env with: # VITE_AZURE_CLIENT_ID= # VITE_AZURE_TENANT_ID= diff --git a/ka-note/client/src/lib/components/JournalView.svelte b/ka-note/client/src/lib/components/JournalView.svelte index d61f764..21ecfdc 100644 --- a/ka-note/client/src/lib/components/JournalView.svelte +++ b/ka-note/client/src/lib/components/JournalView.svelte @@ -16,7 +16,9 @@ import { useUnsavedGuard } from '$lib/utils/unsavedGuard.svelte'; import EventCard from './EventCard.svelte'; import { eventsForDate } from '$lib/stores/agenda'; - import { createEvent } from '$lib/db/repositories'; + import { createEvent, updateEventNotes } from '$lib/db/repositories'; + import { fetchCalendarEvents, type CalendarEvent } from '$lib/utils/calendarApi'; + import type { PersonMeta } from '@ka-note/shared'; interface Props { contextId: string; @@ -211,6 +213,47 @@ let newEventTitle = $state(''); let newEventTime = $state(''); let newEventParticipants = $state(''); + let pendingBodyPreview = $state(''); + + // Calendar picker + let calendarPickerOpen = $state(false); + let calendarLoading = $state(false); + let calendarError = $state(null); + let calendarEvents = $state([]); + + async function openCalendarPicker() { + if (!showNewEventForm) openNewEventForm(); + calendarLoading = true; + calendarError = null; + calendarPickerOpen = true; + calendarEvents = []; + try { + calendarEvents = await fetchCalendarEvents(selectedDate); + } catch { + calendarError = 'Kalender nicht verfügbar'; + } finally { + calendarLoading = false; + } + } + + async function selectCalendarEvent(ev: CalendarEvent) { + newEventTitle = ev.subject; + newEventTime = ev.start; + pendingBodyPreview = ev.bodyPreview; + + const persons = await db.contexts + .filter(c => !c.deletedAt && c.type === 'person') + .toArray(); + const mentions = ev.attendees.map(att => { + const match = persons.find( + p => (p.meta as PersonMeta | null)?.email?.toLowerCase() === att.email.toLowerCase() + ); + const name = match ? match.name : att.name; + return name.includes(' ') ? `@"${name}"` : `@${name}`; + }); + newEventParticipants = mentions.join(' '); + calendarPickerOpen = false; + } const dateEvents = $derived(eventsForDate(selectedDate)); @@ -236,10 +279,14 @@ const title = newEventTitle.trim(); if (!title) return; const participants = parseEventParticipants(newEventParticipants); - await createEvent(selectedDate, newEventTime || '00:00', title, participants); + const event = await createEvent(selectedDate, newEventTime || '00:00', title, participants); + if (pendingBodyPreview.trim()) { + await updateEventNotes(event.id, pendingBodyPreview.trim()); + } newEventTitle = ''; newEventTime = ''; newEventParticipants = ''; + pendingBodyPreview = ''; showNewEventForm = false; } @@ -380,8 +427,41 @@ class="rounded bg-[#444] px-3 py-1 text-sm text-white hover:bg-[#555]" onclick={() => (showNewEventForm = false)} >Abbrechen + + {#if calendarPickerOpen} +
+ {#if calendarLoading} +
Lade...
+ {:else if calendarError} +
{calendarError}
+ {:else if calendarEvents.length === 0} +
Keine Termine für diesen Tag
+ {:else} + {#each calendarEvents as ev (ev.id)} + + {/each} + {/if} + +
+ {/if} {/if} {#each $dateEvents ?? [] as event (event.id)}
diff --git a/ka-note/client/src/lib/utils/calendarApi.ts b/ka-note/client/src/lib/utils/calendarApi.ts new file mode 100644 index 0000000..cded347 --- /dev/null +++ b/ka-note/client/src/lib/utils/calendarApi.ts @@ -0,0 +1,16 @@ +import { authFetch } from '$lib/auth/apiClient'; + +export interface CalendarEvent { + id: string; + subject: string; + start: string; // "HH:MM" + end: string; // "HH:MM" + bodyPreview: string; + attendees: { name: string; email: string }[]; +} + +export async function fetchCalendarEvents(date: string): Promise { + const res = await authFetch(`/api/calendar/events?date=${encodeURIComponent(date)}`); + if (!res.ok) throw new Error(`calendar fetch failed: ${res.status}`); + return res.json() as Promise; +} diff --git a/ka-note/server/ka-note.db-shm b/ka-note/server/ka-note.db-shm index 5349665..fe9ac28 100644 Binary files a/ka-note/server/ka-note.db-shm and b/ka-note/server/ka-note.db-shm differ diff --git a/ka-note/server/ka-note.db-wal b/ka-note/server/ka-note.db-wal index 615fd1a..e69de29 100644 Binary files a/ka-note/server/ka-note.db-wal and b/ka-note/server/ka-note.db-wal differ diff --git a/ka-note/server/src/index.ts b/ka-note/server/src/index.ts index 6c78936..8df172b 100644 --- a/ka-note/server/src/index.ts +++ b/ka-note/server/src/index.ts @@ -16,6 +16,7 @@ import pushRoutes from './routes/push.js'; import backupRoutes from './routes/backup.js'; import apiKeyRoutes from './routes/api-keys.js'; import searchRoutes from './routes/search.js'; +import calendarRoutes from './routes/calendar.js'; import { runScheduledBackup, runIfMissed, checkIntegrity } from './lib/backup-service.js'; import { sqlite } from './db/connection.js'; @@ -100,6 +101,9 @@ app.use('/api/search/*', authMiddleware); app.use('/api/search', authMiddleware); app.route('/api/search', searchRoutes); +app.use('/api/calendar/*', authMiddleware); +app.route('/api/calendar', calendarRoutes); + // OpenAPI spec + Scalar UI app.openAPIRegistry.registerComponent('securitySchemes', 'BearerAuth', { type: 'http', diff --git a/ka-note/server/src/lib/graph-service.ts b/ka-note/server/src/lib/graph-service.ts new file mode 100644 index 0000000..d8920b6 --- /dev/null +++ b/ka-note/server/src/lib/graph-service.ts @@ -0,0 +1,118 @@ +export interface CalendarEvent { + id: string; + subject: string; + start: string; // "HH:MM" + end: string; // "HH:MM" + bodyPreview: string; + attendees: { name: string; email: string }[]; +} + +interface OboTokenEntry { + accessToken: string; + expiresAt: number; // ms +} + +const tokenCache = new Map(); + +const tenantId = process.env.AZURE_TENANT_ID ?? ''; +// OBO client_id must match the audience of the client's access token. +// Use AZURE_OBO_CLIENT_ID if the frontend uses a different app registration than the server. +const oboClientId = process.env.AZURE_OBO_CLIENT_ID ?? process.env.AZURE_CLIENT_ID ?? ''; +const clientSecret = process.env.AZURE_CLIENT_SECRET ?? ''; + +async function getOboToken(userToken: string, userId: string): Promise { + const cached = tokenCache.get(userId); + if (cached && cached.expiresAt > Date.now() + 60_000) { + return cached.accessToken; + } + + const body = new URLSearchParams({ + client_id: oboClientId, + client_secret: clientSecret, + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: userToken, + requested_token_use: 'on_behalf_of', + scope: 'https://graph.microsoft.com/Calendars.Read', + }); + + const res = await fetch( + `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, + { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body }, + ); + + if (!res.ok) { + const detail = await res.text(); + throw new Error(`OBO token exchange failed (${res.status}): ${detail}`); + } + + const json = await res.json() as { access_token: string; expires_in: number }; + const entry: OboTokenEntry = { + accessToken: json.access_token, + expiresAt: Date.now() + json.expires_in * 1000, + }; + tokenCache.set(userId, entry); + return entry.accessToken; +} + +function toHHMM(dateTime: string): string { + // Graph returns "2026-02-26T09:30:00.0000000" (local TZ, no Z) + return dateTime.slice(11, 16); +} + +export async function getCalendarEvents( + userToken: string, + userId: string, + userEmail: string, + date: string, +): Promise { + + const graphToken = await getOboToken(userToken, userId); + + const start = `${date}T00:00:00`; + const end = `${date}T23:59:59`; + const select = 'id,subject,start,end,bodyPreview,attendees,isAllDay'; + const url = + `https://graph.microsoft.com/v1.0/me/calendarView` + + `?startDateTime=${encodeURIComponent(start)}` + + `&endDateTime=${encodeURIComponent(end)}` + + `&$select=${select}` + + `&$top=50`; + + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${graphToken}`, + 'Content-Type': 'application/json', + }, + }); + + if (!res.ok) { + const detail = await res.text(); + throw new Error(`Graph calendarView failed (${res.status}): ${detail}`); + } + + type GraphEvent = { + id: string; + subject: string; + start: { dateTime: string }; + end: { dateTime: string }; + bodyPreview: string; + isAllDay: boolean; + attendees: { emailAddress: { name: string; address: string }; type: string }[]; + }; + + const data = await res.json() as { value: GraphEvent[] }; + + return data.value + .filter((e) => !e.isAllDay) + .map((e) => ({ + id: e.id, + subject: e.subject ?? '', + start: toHHMM(e.start.dateTime), + end: toHHMM(e.end.dateTime), + bodyPreview: e.bodyPreview ?? '', + attendees: (e.attendees ?? []) + .filter((a) => a.emailAddress.address.toLowerCase() !== userEmail.toLowerCase()) + .map((a) => ({ name: a.emailAddress.name, email: a.emailAddress.address })), + })) + .sort((a, b) => a.start.localeCompare(b.start)); +} diff --git a/ka-note/server/src/routes/calendar.ts b/ka-note/server/src/routes/calendar.ts new file mode 100644 index 0000000..5574145 --- /dev/null +++ b/ka-note/server/src/routes/calendar.ts @@ -0,0 +1,27 @@ +import { Hono } from 'hono'; +import type { AuthEnv } from '../middleware/auth.js'; +import { getCalendarEvents } from '../lib/graph-service.js'; + +const calendar = new Hono(); + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +calendar.get('/events', async (c) => { + const date = c.req.query('date') ?? ''; + if (!DATE_RE.test(date)) { + return c.json({ error: 'date param required (YYYY-MM-DD)' }, 400); + } + + const auth = c.get('auth'); + const rawToken = c.req.header('Authorization')?.slice(7) ?? ''; + + try { + const events = await getCalendarEvents(rawToken, auth.userId, auth.email, date); + return c.json(events); + } catch (err) { + console.error('[calendar] Graph error:', err instanceof Error ? err.message : err); + return c.json({ error: 'graph_unavailable', detail: err instanceof Error ? err.message : String(err) }, 502); + } +}); + +export default calendar;