Compare commits

..

No commits in common. "main" and "v1.129.1" have entirely different histories.

7 changed files with 21 additions and 86 deletions

View File

@ -2,49 +2,6 @@
All notable changes to this project are documented in this file. All notable changes to this project are documented in this file.
## [1.129.3] - 2026-09-15
### Fixed
- **"Failed to update event" bei Outlook-Terminen** ("IDX14100: JWT is not well formed"
in den Server-Logs). Die Verschlüsselung von `accessToken`/`refreshToken` in
`CalendarConnection` (siehe [1.x] Security-Härtung) läuft als Prisma-Client-Extension
ausschließlich auf direkten `calendarConnection`-Queries — Next.js/Prisma-Extensions
fangen keine verschachtelten `include`-Relationen ab, die über ein anderes Modell
geladen werden. Vier Routen luden Verbindungen aber genau so:
`prisma.user.findUnique({ include: { calendarConnections: true } })`. Für Apple/Synology
(statisches App-Passwort, nie neu geschrieben) blieb das unbemerkt, weil deren Zeilen nie
verschlüsselt wurden. Outlook-Tokens werden dagegen bei jedem Refresh neu geschrieben und
damit verschlüsselt — ab dann schickte die App den Chiffretext statt des echten Tokens an
Microsoft Graph, die Anfrage lief mit 401 auf.
- Betroffen und korrigiert: `api/calendar/events/route.ts` (Termin erstellen/ändern/
löschen — der hier gemeldete Fehler), `api/calendar/sync/route.ts` (normale Anzeige-
Synchronisation), `api/calendar/background-sync/route.ts` (Hintergrund-Sync),
`api/calendar/connections/route.ts` (Verbindungsliste, inkl. Apple/Synology-
Farbaktualisierung im Hintergrund). Alle vier laden `CalendarConnection` jetzt per
direktem `calendarConnection.findMany`/`findFirst` statt über `user`-`include`.
Verifiziert nicht nur mit Typecheck/Build: Wegwerf-Postgres (Docker) aufgesetzt, eine
`CalendarConnection` mit echtem Prisma-Client (inkl. Verschlüsselungs-Extension)
angelegt — Rohwert in der DB trägt das `enc1:`-Präfix. Der alte `include`-Pfad lieferte
exakt den Chiffretext zurück (Reproduktion des Produktionsfehlers), der neue direkte
Query-Pfad das korrekte Klartext-Token.
## [1.129.2] - 2026-09-14
### Fixed
- **Kalendertermine und der "All day"-Zähler fehlten in weiten Ansichten** (Tage-Auswahl
> 14, z.B. 21 oder 31 Tage): `fetchCalendarEvents` fragte serverseitig immer nur ein
festes Fenster von `currentWeekStart - 7` bis `currentWeekStart + 14` Tage ab, egal wie
breit die Ansicht tatsächlich eingestellt war. Spalten jenseits von Tag 14 blieben
dadurch leer, und der Zähler neben "all day" zählte nur die tatsächlich geladenen
Termine mit — beides sah nach fehlenden Daten aus, obwohl die Termine existierten.
`timeMax` skaliert jetzt mit `viewDays` (`currentWeekStart + max(14, viewDays + 7)`
Tage), der Standard-7-Tage-Fall bleibt unverändert (weiterhin 14 Tage Vorlauf).
Verifiziert: Typecheck und Produktions-Build grün (Scratchpad-Klon), 118 Unit-Tests
grün, Datumsrechnung für viewDays 7/14/21/31 per Skript gegengeprüft — das neue Fenster
deckt in jedem Fall den letzten sichtbaren Tag plus Puffer ab.
## [1.129.1] - 2026-09-14 ## [1.129.1] - 2026-09-14
### Fixed ### Fixed

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "name": "my-weekly-todo-list",
"version": "1.129.3", "version": "1.129.1",
"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": {

View File

@ -16,17 +16,10 @@ export async function POST(request: NextRequest) {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { email: session.user.email }, where: { email: session.user.email },
select: { id: true }, include: { calendarConnections: true },
}); });
if (!user) { if (!user || user.calendarConnections.length === 0) {
return NextResponse.json({ queued: 0 });
}
// Queried directly (not via user's `include`) so the accessToken/refreshToken
// decryption hook in lib/prisma.ts runs — see comment in calendar/sync/route.ts.
const connections = await prisma.calendarConnection.findMany({ where: { userId: user.id } });
if (connections.length === 0) {
return NextResponse.json({ queued: 0 }); return NextResponse.json({ queued: 0 });
} }
@ -35,7 +28,7 @@ export async function POST(request: NextRequest) {
const tMax = timeMax ? new Date(timeMax) : new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000); const tMax = timeMax ? new Date(timeMax) : new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000);
const staleChecks = await Promise.all( const staleChecks = await Promise.all(
connections.map(async conn => ({ user.calendarConnections.map(async conn => ({
conn, conn,
stale: forceRefresh || await isCacheStale(conn.id, tMin), stale: forceRefresh || await isCacheStale(conn.id, tMin),
})) }))

View File

@ -21,7 +21,7 @@ export async function GET(request: NextRequest) {
// Find user by email // Find user by email
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { email: session.user.email }, where: { email: session.user.email },
select: { id: true } include: { calendarConnections: true }
}); });
if (!user) { if (!user) {
@ -31,13 +31,9 @@ export async function GET(request: NextRequest) {
); );
} }
// Queried directly (not via user's `include`) so the accessToken/refreshToken
// decryption hook in lib/prisma.ts runs — see comment in calendar/sync/route.ts.
const rawConnections = await prisma.calendarConnection.findMany({ where: { userId: user.id } });
// Return calendar connections (without sensitive tokens) // Return calendar connections (without sensitive tokens)
// Prune deleted calendars from providers that support live listing // Prune deleted calendars from providers that support live listing
const connections = rawConnections.map((conn) => { const connections = user.calendarConnections.map((conn) => {
let calendars = conn.calendars as any[] | null; let calendars = conn.calendars as any[] | null;
// For Apple connections, filter out VTODO/Reminders collections // For Apple connections, filter out VTODO/Reminders collections

View File

@ -10,16 +10,12 @@ import { upsertCachedEvent, deleteCachedEvent } from '@/lib/calendar-cache';
async function findConnectionForCalendar(email: string, calendarId: string) { async function findConnectionForCalendar(email: string, calendarId: string) {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { email }, where: { email },
select: { id: true } include: { calendarConnections: true }
}); });
if (!user) return null; if (!user) return null;
// Queried directly (not via user's `include`) so the accessToken/refreshToken for (const conn of user.calendarConnections) {
// decryption hook in lib/prisma.ts runs — see comment in calendar/sync/route.ts.
const connections = await prisma.calendarConnection.findMany({ where: { userId: user.id } });
for (const conn of connections) {
if (conn.calendars && Array.isArray(conn.calendars)) { if (conn.calendars && Array.isArray(conn.calendars)) {
const calendars = conn.calendars as any[]; const calendars = conn.calendars as any[];
if (calendars.some(c => c.id === calendarId)) { if (calendars.some(c => c.id === calendarId)) {

View File

@ -23,18 +23,14 @@ export async function POST(request: NextRequest) {
// Look up by email — session ID can be stale after DB restore/migration // Look up by email — session ID can be stale after DB restore/migration
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { email: session.user.email }, where: { email: session.user.email },
select: { id: true }, include: { calendarConnections: true },
}); });
if (!user) { if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 }); return NextResponse.json({ error: 'User not found' }, { status: 404 });
} }
// calendarConnection must be queried directly (not via user's `include`) so the let connections = user.calendarConnections;
// accessToken/refreshToken decryption hook in lib/prisma.ts actually runs — Prisma
// client extensions only intercept top-level queries on the model, not nested
// relation loads reached through another model's `include`.
let connections = await prisma.calendarConnection.findMany({ where: { userId: user.id } });
if (connectionId) { if (connectionId) {
connections = connections.filter(c => c.id === connectionId); connections = connections.filter(c => c.id === connectionId);
} }

View File

@ -1397,22 +1397,16 @@ export default function WeeklyView() {
startSync(); startSync();
setIsFetchingCalendar(true); setIsFetchingCalendar(true);
try { try {
// timeMax must cover the whole configured view (up to MAX_VIEW_DAYS=31), not just
// the default 7-day week — otherwise columns past day 14 silently show no events
// and the all-day count badge undercounts (it only tallies fetched events).
const forwardDays = Math.max(14, viewDays + 7);
const timeMin = new Date(
currentWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000,
).toISOString();
const timeMax = new Date(
currentWeekStart.getTime() + forwardDays * 24 * 60 * 60 * 1000,
).toISOString();
const response = await fetch("/api/calendar/sync", { const response = await fetch("/api/calendar/sync", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
timeMin, timeMin: new Date(
timeMax, currentWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000,
).toISOString(),
timeMax: new Date(
currentWeekStart.getTime() + 14 * 24 * 60 * 60 * 1000,
).toISOString(),
forceRefresh, forceRefresh,
...(connectionId ? { connectionId } : {}), ...(connectionId ? { connectionId } : {}),
}), }),
@ -1431,7 +1425,10 @@ export default function WeeklyView() {
fetch("/api/calendar/sync", { fetch("/api/calendar/sync", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ timeMin, timeMax }), body: JSON.stringify({
timeMin: new Date(currentWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(),
timeMax: new Date(currentWeekStart.getTime() + 14 * 24 * 60 * 60 * 1000).toISOString(),
}),
}).then(r => r.json()).then(d => { }).then(r => r.json()).then(d => {
if (d.events) setRawCalendarEvents(d.events); if (d.events) setRawCalendarEvents(d.events);
}).catch(() => {}); }).catch(() => {});
@ -1450,7 +1447,7 @@ export default function WeeklyView() {
setIsFetchingCalendar(false); setIsFetchingCalendar(false);
endSync(); endSync();
} }
}, [currentWeekStart, viewDays, startSync, endSync]); }, [currentWeekStart, startSync, endSync]);
// Weather fetch // Weather fetch
const fetchWeather = useCallback(async () => { const fetchWeather = useCallback(async () => {