Files
tessera-ctl/apps/api/src/calendar/providers/ics.provider.ts
T
schalli 636fe0df8f refactor(quick-260921-bi2): maschinelle Lint-Fixe und toten Code abbauen
- Aufgabe 2: vier sichere Biome-Regeln (useImportType pfadgebunden auf
  apps/web+packages, noUselessEscapeInRegex, useConst,
  useExponentiationOperator) sowie fuenf ungesicherte Regeln
  (useNodejsImportProtocol, useLiteralKeys, useOptionalChain, useTemplate,
  useParseIntRadix) angewendet und den gesamten Diff von Hand gelesen
  (ldap.service.ts zeichenweise gegen Gross-/Kleinschreibung der
  AD-Merkmale, auth.service.ts/jwt.strategy.ts gegen Durchwinken bei
  fehlender Sitzung geprueft)
- noUselessSwitchCase bleibt bewusst stehen (tender-normalizer.service.ts:60,
  die Fallmarke dokumentiert Absicht)
- Toter Code (D-03): fuenf folgenlose Auffangvariablen entfernt, eine
  nicht benutzte Funktion (forSystemQuery, Pruefskript) entfernt, ein
  positionsgebundener Dekoratorparameter umbenannt (current-user.decorator.ts),
  fuenf Symptomfunde entfernt und als Folgeaufgaben zu melden (siehe unten)
- Sechs weitere, im Plan nicht namentlich gelistete aber
  gleich-kategorische Dead-Code-Fundstellen in Testdateien zusaetzlich
  bereinigt (groups.service.spec.ts, cert-manager.test.tsx,
  ldap.service.spec.ts, prisma-tenant.extension.spec.ts x3) — noetig, um
  die vom Plan selbst verlangten Nullstaende bei noUnusedVariables/
  noUnusedImports/noUnusedFunctionParameters zu erreichen

Dekoratordaten aus apps/api unveraendert (593 Zeilen, sha256 6e1583f1...).
Endstand 620 Befunde (541 echt, 79 Test) statt der im Plan geschaetzten
621/542 — eine Differenz von 1, weil das Streichen des Namens aus
`catch (e: any)` in calendar.service.ts (Symptom-Fix) den dort ebenfalls
gemeldeten noExplicitAny-Befund miteliminiert; das ist eine erwuenschte
Nebenwirkung, keine Regression. Fehlerstufe 0, beide Testlaeufe
punktgleich gruen (69/1124, 66/459), pnpm type-check 4/4, pnpm lint
--force 5/5.

Folgeaufgaben aus D-03 (nicht in diesem Vorgang behoben):
- force-password-change.interceptor.ts: Freigabeliste prueft nur den Pfad,
  nicht die HTTP-Methode
- change-password/page.tsx: nach erzwungenem Wechsel bleibt die Person auf
  der Seite stehen (keine Weiterleitung, keine Aktualisierung der
  Benutzerablage)
- VehicleTable.tsx: Loeschschaltflaeche hat keinen Besetztzustand, laesst
  sich doppelt ausloesen
- SplitTab.tsx: downloadAllAsZip erhielt eine ungenutzte
  Uebersetzungsfunktion, Hinweis auf fest verdrahtete Texte im Zip-Pfad

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 08:58:32 +02:00

152 lines
5.0 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import * as ical from 'node-ical';
import { CalendarEvent, CalendarProvider } from '../calendar.service';
/**
* ICS calendar provider — fetches and parses .ics files via HTTPS.
*
* Uses node-ical for parsing iCal data including recurring event expansion (A6).
* Applies date-range filtering and maps to normalized CalendarEvent format.
*/
@Injectable()
export class ICSProvider implements CalendarProvider {
private readonly logger = new Logger(ICSProvider.name);
/**
* Fetches events from an ICS URL and filters to the given date range.
* Expands recurring events via node-ical's expandRecurringEvent (A6).
*/
async fetchEvents(
source: { url: string; id: string; color?: string | null },
from: Date,
to: Date,
): Promise<CalendarEvent[]> {
const events: CalendarEvent[] = [];
try {
// Fetch with timeout (Pitfall 4) then parse
const icsText = await this.fetchIcsText(source.url);
const data = ical.sync.parseICS(icsText);
for (const key of Object.keys(data)) {
const component = data[key];
if (component?.type !== 'VEVENT') continue;
const vevent = component as ical.VEvent;
// Handle recurring events
if (vevent.rrule) {
try {
const instances = ical.expandRecurringEvent(vevent, {
from,
to,
includeOverrides: true,
excludeExdates: true,
});
for (const instance of instances) {
events.push({
id: `${source.id}-${vevent.uid}-${instance.start.getTime()}`,
sourceId: source.id,
title: extractString(instance.summary ?? vevent.summary),
start: new Date(instance.start),
end: new Date(instance.end),
allDay: instance.isFullDay,
location: vevent.location ? extractString(vevent.location) : undefined,
description: vevent.description ? extractString(vevent.description) : undefined,
color: source.color ?? undefined,
});
}
} catch (err) {
// Fallback: if RRULE expansion fails, include the base event if in range
this.logger.warn(`RRULE expansion failed for ${vevent.uid}: ${err}`);
const mapped = this.mapSingleEvent(vevent, source, from, to);
if (mapped) events.push(mapped);
}
} else {
// Non-recurring event — check if in range
const mapped = this.mapSingleEvent(vevent, source, from, to);
if (mapped) events.push(mapped);
}
}
} catch (error) {
this.logger.error(`Failed to fetch ICS from ${source.url}: ${(error as Error).message}`);
throw error;
}
return events;
}
/**
* Tests connection by fetching the ICS URL and verifying it parses.
*/
async testConnection(
source: { url: string; id: string },
): Promise<boolean> {
try {
const icsText = await this.fetchIcsText(source.url);
const data = ical.sync.parseICS(icsText);
return Object.keys(data).length > 0;
} catch {
return false;
}
}
/**
* Fetches raw ICS text from a URL with an 8-second timeout.
*/
private async fetchIcsText(url: string): Promise<string> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.text();
} finally {
clearTimeout(timeout);
}
}
/**
* Maps a single (non-recurring) VEvent to CalendarEvent if it falls within the date range.
*/
private mapSingleEvent(
vevent: ical.VEvent,
source: { id: string; color?: string | null },
from: Date,
to: Date,
): CalendarEvent | null {
const start = new Date(vevent.start);
const end = vevent.end ? new Date(vevent.end) : new Date(start.getTime() + 3600000);
// Check if event overlaps with the requested range
if (end < from || start > to) return null;
return {
id: `${source.id}-${vevent.uid}`,
sourceId: source.id,
title: extractString(vevent.summary),
start,
end,
allDay: vevent.datetype === 'date',
location: vevent.location ? extractString(vevent.location) : undefined,
description: vevent.description ? extractString(vevent.description) : undefined,
color: source.color ?? undefined,
};
}
}
/**
* Extracts a plain string from a node-ical ParameterValue
* (which can be a string or an object with `val` property).
*/
function extractString(value: ical.ParameterValue | string | undefined): string {
if (!value) return '';
if (typeof value === 'string') return value;
if (typeof value === 'object' && 'val' in value) return String(value.val);
return String(value);
}