615 lines
20 KiB
TypeScript
615 lines
20 KiB
TypeScript
import { randomBytes } from 'crypto';
|
|
import pool from '../config/database';
|
|
import logger from '../utils/logger';
|
|
import {
|
|
Uebung,
|
|
UebungWithAttendance,
|
|
UebungListItem,
|
|
MemberParticipationStats,
|
|
CreateUebungData,
|
|
UpdateUebungData,
|
|
TeilnahmeStatus,
|
|
Teilnahme,
|
|
} from '../models/training.model';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Internal helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Columns used in all SELECT queries to hydrate a full Uebung row */
|
|
const UEBUNG_COLUMNS = `
|
|
u.id, u.titel, u.beschreibung, u.typ::text AS typ,
|
|
u.datum_von, u.datum_bis, u.ort, u.treffpunkt,
|
|
u.pflichtveranstaltung, u.mindest_teilnehmer, u.max_teilnehmer,
|
|
u.angelegt_von, u.erstellt_am, u.aktualisiert_am,
|
|
u.abgesagt, u.absage_grund
|
|
`;
|
|
|
|
const ATTENDANCE_COUNT_COLUMNS = `
|
|
COUNT(t.user_id) AS gesamt_eingeladen,
|
|
COUNT(t.user_id) FILTER (WHERE t.status = 'zugesagt') AS anzahl_zugesagt,
|
|
COUNT(t.user_id) FILTER (WHERE t.status = 'abgesagt') AS anzahl_abgesagt,
|
|
COUNT(t.user_id) FILTER (WHERE t.status = 'erschienen') AS anzahl_erschienen,
|
|
COUNT(t.user_id) FILTER (WHERE t.status = 'entschuldigt') AS anzahl_entschuldigt,
|
|
COUNT(t.user_id) FILTER (WHERE t.status = 'unbekannt') AS anzahl_unbekannt
|
|
`;
|
|
|
|
/** Map a raw DB row to UebungListItem, optionally including eigener_status */
|
|
function rowToListItem(row: any, eigenerStatus?: TeilnahmeStatus): UebungListItem {
|
|
return {
|
|
id: row.id,
|
|
titel: row.titel,
|
|
typ: row.typ,
|
|
datum_von: new Date(row.datum_von),
|
|
datum_bis: new Date(row.datum_bis),
|
|
ort: row.ort ?? null,
|
|
pflichtveranstaltung: row.pflichtveranstaltung,
|
|
abgesagt: row.abgesagt,
|
|
anzahl_zugesagt: Number(row.anzahl_zugesagt ?? 0),
|
|
anzahl_erschienen: Number(row.anzahl_erschienen ?? 0),
|
|
gesamt_eingeladen: Number(row.gesamt_eingeladen ?? 0),
|
|
eigener_status: eigenerStatus ?? row.eigener_status ?? undefined,
|
|
};
|
|
}
|
|
|
|
function rowToUebung(row: any): Uebung {
|
|
return {
|
|
id: row.id,
|
|
titel: row.titel,
|
|
beschreibung: row.beschreibung ?? null,
|
|
typ: row.typ,
|
|
datum_von: new Date(row.datum_von),
|
|
datum_bis: new Date(row.datum_bis),
|
|
ort: row.ort ?? null,
|
|
treffpunkt: row.treffpunkt ?? null,
|
|
pflichtveranstaltung: row.pflichtveranstaltung,
|
|
mindest_teilnehmer: row.mindest_teilnehmer ?? null,
|
|
max_teilnehmer: row.max_teilnehmer ?? null,
|
|
angelegt_von: row.angelegt_von ?? null,
|
|
erstellt_am: new Date(row.erstellt_am),
|
|
aktualisiert_am: new Date(row.aktualisiert_am),
|
|
abgesagt: row.abgesagt,
|
|
absage_grund: row.absage_grund ?? null,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Training Service
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class TrainingService {
|
|
/**
|
|
* Returns upcoming (future) events sorted ascending by datum_von.
|
|
* Used by the dashboard widget and the list view.
|
|
*/
|
|
async getUpcomingEvents(limit = 10, userId?: string): Promise<UebungListItem[]> {
|
|
const query = `
|
|
SELECT
|
|
${UEBUNG_COLUMNS},
|
|
${ATTENDANCE_COUNT_COLUMNS}
|
|
${userId ? `, own_t.status::text AS eigener_status` : ''}
|
|
FROM uebungen u
|
|
LEFT JOIN uebung_teilnahmen t ON t.uebung_id = u.id
|
|
${userId ? `LEFT JOIN uebung_teilnahmen own_t ON own_t.uebung_id = u.id AND own_t.user_id = $2` : ''}
|
|
WHERE u.datum_von > NOW()
|
|
AND u.abgesagt = FALSE
|
|
GROUP BY u.id ${userId ? `, own_t.status` : ''}
|
|
ORDER BY u.datum_von ASC
|
|
LIMIT $1
|
|
`;
|
|
|
|
const values = userId ? [limit, userId] : [limit];
|
|
const result = await pool.query(query, values);
|
|
return result.rows.map((r) => rowToListItem(r));
|
|
}
|
|
|
|
/**
|
|
* Returns all events within a date range (inclusive) for the calendar view.
|
|
* Does NOT filter out cancelled events — the frontend shows them struck through.
|
|
*/
|
|
async getEventsByDateRange(from: Date, to: Date, userId?: string): Promise<UebungListItem[]> {
|
|
const query = `
|
|
SELECT
|
|
${UEBUNG_COLUMNS},
|
|
${ATTENDANCE_COUNT_COLUMNS}
|
|
${userId ? `, own_t.status::text AS eigener_status` : ''}
|
|
FROM uebungen u
|
|
LEFT JOIN uebung_teilnahmen t ON t.uebung_id = u.id
|
|
${userId ? `LEFT JOIN uebung_teilnahmen own_t ON own_t.uebung_id = u.id AND own_t.user_id = $3` : ''}
|
|
WHERE u.datum_von >= $1
|
|
AND u.datum_von <= $2
|
|
GROUP BY u.id ${userId ? `, own_t.status` : ''}
|
|
ORDER BY u.datum_von ASC
|
|
`;
|
|
|
|
const values = userId ? [from, to, userId] : [from, to];
|
|
const result = await pool.query(query, values);
|
|
return result.rows.map((r) => rowToListItem(r));
|
|
}
|
|
|
|
/**
|
|
* Returns the full event detail including attendance counts and, for
|
|
* privileged users, the individual attendee list.
|
|
*/
|
|
async getEventById(
|
|
id: string,
|
|
userId?: string,
|
|
includeTeilnahmen = false
|
|
): Promise<UebungWithAttendance | null> {
|
|
const eventQuery = `
|
|
SELECT
|
|
${UEBUNG_COLUMNS},
|
|
${ATTENDANCE_COUNT_COLUMNS},
|
|
creator.name AS angelegt_von_name
|
|
${userId ? `, own_t.status::text AS eigener_status` : ''}
|
|
FROM uebungen u
|
|
LEFT JOIN uebung_teilnahmen t ON t.uebung_id = u.id
|
|
LEFT JOIN users creator ON creator.id = u.angelegt_von
|
|
${userId ? `LEFT JOIN uebung_teilnahmen own_t ON own_t.uebung_id = u.id AND own_t.user_id = $2` : ''}
|
|
WHERE u.id = $1
|
|
GROUP BY u.id, creator.name ${userId ? `, own_t.status` : ''}
|
|
`;
|
|
|
|
const values = userId ? [id, userId] : [id];
|
|
const eventResult = await pool.query(eventQuery, values);
|
|
|
|
if (eventResult.rows.length === 0) return null;
|
|
|
|
const row = eventResult.rows[0];
|
|
const uebung = rowToUebung(row);
|
|
|
|
const result: UebungWithAttendance = {
|
|
...uebung,
|
|
gesamt_eingeladen: Number(row.gesamt_eingeladen ?? 0),
|
|
anzahl_zugesagt: Number(row.anzahl_zugesagt ?? 0),
|
|
anzahl_abgesagt: Number(row.anzahl_abgesagt ?? 0),
|
|
anzahl_erschienen: Number(row.anzahl_erschienen ?? 0),
|
|
anzahl_entschuldigt: Number(row.anzahl_entschuldigt ?? 0),
|
|
anzahl_unbekannt: Number(row.anzahl_unbekannt ?? 0),
|
|
angelegt_von_name: row.angelegt_von_name ?? null,
|
|
eigener_status: row.eigener_status ?? undefined,
|
|
};
|
|
|
|
if (includeTeilnahmen) {
|
|
const teilnahmenQuery = `
|
|
SELECT
|
|
t.uebung_id,
|
|
t.user_id,
|
|
t.status::text AS status,
|
|
t.antwort_am,
|
|
t.erschienen_erfasst_am,
|
|
t.erschienen_erfasst_von,
|
|
t.bemerkung,
|
|
COALESCE(u.name, u.preferred_username, u.email) AS user_name,
|
|
u.email AS user_email
|
|
FROM uebung_teilnahmen t
|
|
JOIN users u ON u.id = t.user_id
|
|
WHERE t.uebung_id = $1
|
|
ORDER BY u.name ASC NULLS LAST
|
|
`;
|
|
const teilnahmenResult = await pool.query(teilnahmenQuery, [id]);
|
|
result.teilnahmen = teilnahmenResult.rows as Teilnahme[];
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Creates a new training event.
|
|
* The database trigger automatically creates 'unbekannt' teilnahmen
|
|
* rows for all active members.
|
|
*/
|
|
async createEvent(data: CreateUebungData, createdBy: string): Promise<Uebung> {
|
|
const query = `
|
|
INSERT INTO uebungen (
|
|
titel, beschreibung, typ, datum_von, datum_bis, ort, treffpunkt,
|
|
pflichtveranstaltung, mindest_teilnehmer, max_teilnehmer, angelegt_von
|
|
)
|
|
VALUES ($1, $2, $3::uebung_typ, $4, $5, $6, $7, $8, $9, $10, $11)
|
|
RETURNING
|
|
id, titel, beschreibung, typ::text AS typ,
|
|
datum_von, datum_bis, ort, treffpunkt,
|
|
pflichtveranstaltung, mindest_teilnehmer, max_teilnehmer,
|
|
angelegt_von, erstellt_am, aktualisiert_am,
|
|
abgesagt, absage_grund
|
|
`;
|
|
|
|
const values = [
|
|
data.titel,
|
|
data.beschreibung ?? null,
|
|
data.typ,
|
|
data.datum_von,
|
|
data.datum_bis,
|
|
data.ort ?? null,
|
|
data.treffpunkt ?? null,
|
|
data.pflichtveranstaltung,
|
|
data.mindest_teilnehmer ?? null,
|
|
data.max_teilnehmer ?? null,
|
|
createdBy,
|
|
];
|
|
|
|
const result = await pool.query(query, values);
|
|
const event = rowToUebung(result.rows[0]);
|
|
|
|
logger.info('Training event created', {
|
|
eventId: event.id,
|
|
titel: event.titel,
|
|
typ: event.typ,
|
|
datum_von: event.datum_von,
|
|
createdBy,
|
|
});
|
|
|
|
return event;
|
|
}
|
|
|
|
/**
|
|
* Updates mutable fields of an existing event.
|
|
* Only provided fields are updated (partial update semantics).
|
|
*/
|
|
async updateEvent(
|
|
id: string,
|
|
data: UpdateUebungData,
|
|
_updatedBy: string
|
|
): Promise<Uebung> {
|
|
const fields: string[] = [];
|
|
const values: unknown[] = [];
|
|
let p = 1;
|
|
|
|
const add = (col: string, val: unknown, cast = '') => {
|
|
fields.push(`${col} = $${p++}${cast}`);
|
|
values.push(val);
|
|
};
|
|
|
|
if (data.titel !== undefined) add('titel', data.titel);
|
|
if (data.beschreibung !== undefined) add('beschreibung', data.beschreibung);
|
|
if (data.typ !== undefined) add('typ', data.typ, '::uebung_typ');
|
|
if (data.datum_von !== undefined) add('datum_von', data.datum_von);
|
|
if (data.datum_bis !== undefined) add('datum_bis', data.datum_bis);
|
|
if (data.ort !== undefined) add('ort', data.ort);
|
|
if (data.treffpunkt !== undefined) add('treffpunkt', data.treffpunkt);
|
|
if (data.pflichtveranstaltung !== undefined) add('pflichtveranstaltung', data.pflichtveranstaltung);
|
|
if (data.mindest_teilnehmer !== undefined) add('mindest_teilnehmer', data.mindest_teilnehmer);
|
|
if (data.max_teilnehmer !== undefined) add('max_teilnehmer', data.max_teilnehmer);
|
|
|
|
if (fields.length === 0) {
|
|
// Nothing to update — return existing event
|
|
const existing = await this.getEventById(id);
|
|
if (!existing) throw new Error('Event not found');
|
|
return existing;
|
|
}
|
|
|
|
values.push(id);
|
|
const query = `
|
|
UPDATE uebungen
|
|
SET ${fields.join(', ')}
|
|
WHERE id = $${p}
|
|
RETURNING
|
|
id, titel, beschreibung, typ::text AS typ,
|
|
datum_von, datum_bis, ort, treffpunkt,
|
|
pflichtveranstaltung, mindest_teilnehmer, max_teilnehmer,
|
|
angelegt_von, erstellt_am, aktualisiert_am,
|
|
abgesagt, absage_grund
|
|
`;
|
|
|
|
const result = await pool.query(query, values);
|
|
if (result.rows.length === 0) throw new Error('Event not found');
|
|
|
|
logger.info('Training event updated', { eventId: id, updatedBy: _updatedBy });
|
|
return rowToUebung(result.rows[0]);
|
|
}
|
|
|
|
/**
|
|
* Soft-cancels an event. Sets abgesagt=true and records the reason.
|
|
* Does NOT delete the event or its attendance rows.
|
|
*/
|
|
async cancelEvent(id: string, reason: string, updatedBy: string): Promise<void> {
|
|
const result = await pool.query(
|
|
`UPDATE uebungen
|
|
SET abgesagt = TRUE, absage_grund = $2
|
|
WHERE id = $1
|
|
RETURNING id`,
|
|
[id, reason]
|
|
);
|
|
|
|
if (result.rows.length === 0) throw new Error('Event not found');
|
|
|
|
logger.info('Training event cancelled', { eventId: id, updatedBy, reason });
|
|
}
|
|
|
|
/**
|
|
* Member updates their own RSVP — only 'zugesagt' or 'abgesagt' allowed here.
|
|
* Sets antwort_am to now.
|
|
*/
|
|
async updateAttendanceRSVP(
|
|
uebungId: string,
|
|
userId: string,
|
|
status: 'zugesagt' | 'abgesagt',
|
|
bemerkung?: string | null
|
|
): Promise<void> {
|
|
const result = await pool.query(
|
|
`UPDATE uebung_teilnahmen
|
|
SET status = $3::teilnahme_status,
|
|
antwort_am = NOW(),
|
|
bemerkung = COALESCE($4, bemerkung)
|
|
WHERE uebung_id = $1 AND user_id = $2
|
|
RETURNING uebung_id`,
|
|
[uebungId, userId, status, bemerkung ?? null]
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
// Row might not exist if member joined after event was created — insert it
|
|
await pool.query(
|
|
`INSERT INTO uebung_teilnahmen (uebung_id, user_id, status, antwort_am, bemerkung)
|
|
VALUES ($1, $2, $3::teilnahme_status, NOW(), $4)
|
|
ON CONFLICT (uebung_id, user_id) DO UPDATE
|
|
SET status = EXCLUDED.status,
|
|
antwort_am = EXCLUDED.antwort_am,
|
|
bemerkung = COALESCE(EXCLUDED.bemerkung, uebung_teilnahmen.bemerkung)`,
|
|
[uebungId, userId, status, bemerkung ?? null]
|
|
);
|
|
}
|
|
|
|
logger.info('RSVP updated', { uebungId, userId, status });
|
|
}
|
|
|
|
/**
|
|
* Gruppenführer / Kommandant bulk-marks members as 'erschienen'.
|
|
* Marks erschienen_erfasst_am and erschienen_erfasst_von.
|
|
*/
|
|
async markAttendance(
|
|
uebungId: string,
|
|
userIds: string[],
|
|
markedBy: string
|
|
): Promise<void> {
|
|
if (userIds.length === 0) return;
|
|
|
|
// Build parameterized IN clause: $3, $4, $5, ...
|
|
const placeholders = userIds.map((_, i) => `$${i + 3}`).join(', ');
|
|
|
|
await pool.query(
|
|
`UPDATE uebung_teilnahmen
|
|
SET status = 'erschienen'::teilnahme_status,
|
|
erschienen_erfasst_am = NOW(),
|
|
erschienen_erfasst_von = $2
|
|
WHERE uebung_id = $1
|
|
AND user_id IN (${placeholders})`,
|
|
[uebungId, markedBy, ...userIds]
|
|
);
|
|
|
|
logger.info('Bulk attendance marked', {
|
|
uebungId,
|
|
count: userIds.length,
|
|
markedBy,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Annual participation statistics for all active members.
|
|
* Filters to events within the given calendar year.
|
|
* "unbekannt" responses are NOT treated as absent.
|
|
*/
|
|
async getMemberParticipationStats(year: number): Promise<MemberParticipationStats[]> {
|
|
const query = `
|
|
SELECT
|
|
usr.id AS user_id,
|
|
COALESCE(usr.name, usr.preferred_username, usr.email) AS name,
|
|
COUNT(t.uebung_id) AS total_uebungen,
|
|
COUNT(t.uebung_id) FILTER (WHERE t.status = 'erschienen') AS attended,
|
|
COUNT(t.uebung_id) FILTER (WHERE u.pflichtveranstaltung = TRUE) AS pflicht_gesamt,
|
|
COUNT(t.uebung_id) FILTER (
|
|
WHERE u.pflichtveranstaltung = TRUE AND t.status = 'erschienen'
|
|
) AS pflicht_erschienen,
|
|
ROUND(
|
|
CASE
|
|
WHEN COUNT(t.uebung_id) FILTER (WHERE u.typ = 'Übungsabend') = 0 THEN 0
|
|
ELSE
|
|
COUNT(t.uebung_id) FILTER (
|
|
WHERE u.typ = 'Übungsabend' AND t.status = 'erschienen'
|
|
)::NUMERIC /
|
|
COUNT(t.uebung_id) FILTER (WHERE u.typ = 'Übungsabend') * 100
|
|
END, 1
|
|
) AS uebungsabend_quote_pct
|
|
FROM users usr
|
|
JOIN uebung_teilnahmen t ON t.user_id = usr.id
|
|
JOIN uebungen u ON u.id = t.uebung_id
|
|
WHERE usr.is_active = TRUE
|
|
AND u.abgesagt = FALSE
|
|
AND EXTRACT(YEAR FROM u.datum_von) = $1
|
|
GROUP BY usr.id, usr.name, usr.preferred_username, usr.email
|
|
ORDER BY name ASC
|
|
`;
|
|
|
|
const result = await pool.query(query, [year]);
|
|
|
|
return result.rows.map((r) => ({
|
|
userId: r.user_id,
|
|
name: r.name,
|
|
totalUebungen: Number(r.total_uebungen),
|
|
attended: Number(r.attended),
|
|
attendancePercent:
|
|
Number(r.total_uebungen) === 0
|
|
? 0
|
|
: Math.round((Number(r.attended) / Number(r.total_uebungen)) * 1000) / 10,
|
|
pflichtGesamt: Number(r.pflicht_gesamt),
|
|
pflichtErschienen: Number(r.pflicht_erschienen),
|
|
uebungsabendQuotePct: Number(r.uebungsabend_quote_pct),
|
|
}));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// iCal token management
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Returns the existing calendar token for a user, or creates a new one.
|
|
* Tokens are 32-byte hex strings (URL-safe).
|
|
*/
|
|
async getOrCreateCalendarToken(userId: string): Promise<string> {
|
|
const existing = await pool.query(
|
|
`SELECT token FROM calendar_tokens WHERE user_id = $1`,
|
|
[userId]
|
|
);
|
|
if (existing.rows.length > 0) return existing.rows[0].token;
|
|
|
|
const token = randomBytes(32).toString('hex');
|
|
await pool.query(
|
|
`INSERT INTO calendar_tokens (user_id, token) VALUES ($1, $2)`,
|
|
[userId, token]
|
|
);
|
|
return token;
|
|
}
|
|
|
|
/**
|
|
* Looks up the userId associated with a calendar token and
|
|
* touches last_used_at.
|
|
*/
|
|
async resolveCalendarToken(token: string): Promise<string | null> {
|
|
const result = await pool.query(
|
|
`UPDATE calendar_tokens
|
|
SET last_used_at = NOW()
|
|
WHERE token = $1
|
|
RETURNING user_id`,
|
|
[token]
|
|
);
|
|
return result.rows[0]?.user_id ?? null;
|
|
}
|
|
|
|
/**
|
|
* Generates iCal content for the given user (or public feed for all events
|
|
* when userId is undefined).
|
|
*/
|
|
async getCalendarExport(userId?: string): Promise<string> {
|
|
// Fetch events for the next 12 months + past 3 months
|
|
const from = new Date();
|
|
from.setMonth(from.getMonth() - 3);
|
|
const to = new Date();
|
|
to.setFullYear(to.getFullYear() + 1);
|
|
|
|
const events = await this.getEventsByDateRange(from, to, userId);
|
|
return generateICS(events, 'Feuerwehr Rems');
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// iCal generation — zero-dependency RFC 5545 implementation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Formats a Date to the iCal DTSTART/DTEND format with UTC timezone.
|
|
* Output: 20260304T190000Z
|
|
*/
|
|
function formatIcsDate(date: Date): string {
|
|
return date
|
|
.toISOString()
|
|
.replace(/[-:]/g, '')
|
|
.replace(/\.\d{3}/, '');
|
|
}
|
|
|
|
/**
|
|
* Folds long iCal lines at 75 octets (RFC 5545 §3.1).
|
|
* Continuation lines start with a single space.
|
|
*/
|
|
function foldLine(line: string): string {
|
|
const MAX = 75;
|
|
if (line.length <= MAX) return line;
|
|
|
|
let result = '';
|
|
while (line.length > MAX) {
|
|
result += line.substring(0, MAX) + '\r\n ';
|
|
line = line.substring(MAX);
|
|
}
|
|
result += line;
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Escapes text field values per RFC 5545 §3.3.11.
|
|
*/
|
|
function escapeIcsText(value: string): string {
|
|
return value
|
|
.replace(/\\/g, '\\\\')
|
|
.replace(/;/g, '\\;')
|
|
.replace(/,/g, '\\,')
|
|
.replace(/\n/g, '\\n')
|
|
.replace(/\r/g, '');
|
|
}
|
|
|
|
/** Maps UebungTyp to a human-readable category string */
|
|
const TYP_CATEGORY: Record<string, string> = {
|
|
'Übungsabend': 'Training',
|
|
'Lehrgang': 'Course',
|
|
'Sonderdienst': 'Special Duty',
|
|
'Versammlung': 'Meeting',
|
|
'Gemeinschaftsübung': 'Joint Exercise',
|
|
'Sonstiges': 'Other',
|
|
};
|
|
|
|
export function generateICS(
|
|
events: Array<{
|
|
id: string;
|
|
titel: string;
|
|
beschreibung?: string | null;
|
|
typ: string;
|
|
datum_von: Date;
|
|
datum_bis: Date;
|
|
ort?: string | null;
|
|
pflichtveranstaltung: boolean;
|
|
abgesagt: boolean;
|
|
}>,
|
|
organizerName: string
|
|
): string {
|
|
const lines: string[] = [
|
|
'BEGIN:VCALENDAR',
|
|
'VERSION:2.0',
|
|
`PRODID:-//Feuerwehr Rems//Dashboard//DE`,
|
|
'CALSCALE:GREGORIAN',
|
|
'METHOD:PUBLISH',
|
|
`X-WR-CALNAME:${escapeIcsText(organizerName)} - Dienstkalender`,
|
|
'X-WR-TIMEZONE:Europe/Vienna',
|
|
'X-WR-CALDESC:Übungs- und Dienstkalender der Feuerwehr Rems',
|
|
];
|
|
|
|
const stampNow = formatIcsDate(new Date());
|
|
|
|
for (const event of events) {
|
|
const summary = event.abgesagt
|
|
? `[ABGESAGT] ${event.titel}`
|
|
: event.pflichtveranstaltung
|
|
? `* ${event.titel}`
|
|
: event.titel;
|
|
|
|
const descParts: string[] = [];
|
|
if (event.beschreibung) descParts.push(event.beschreibung);
|
|
if (event.pflichtveranstaltung) descParts.push('PFLICHTVERANSTALTUNG');
|
|
if (event.abgesagt) descParts.push('Diese Veranstaltung wurde abgesagt.');
|
|
descParts.push(`Typ: ${event.typ}`);
|
|
|
|
lines.push('BEGIN:VEVENT');
|
|
lines.push(foldLine(`UID:${event.id}@feuerwehr-rems.at`));
|
|
lines.push(`DTSTAMP:${stampNow}`);
|
|
lines.push(`DTSTART:${formatIcsDate(event.datum_von)}`);
|
|
lines.push(`DTEND:${formatIcsDate(event.datum_bis)}`);
|
|
lines.push(foldLine(`SUMMARY:${escapeIcsText(summary)}`));
|
|
|
|
if (descParts.length > 0) {
|
|
lines.push(foldLine(`DESCRIPTION:${escapeIcsText(descParts.join('\\n'))}`));
|
|
}
|
|
if (event.ort) {
|
|
lines.push(foldLine(`LOCATION:${escapeIcsText(event.ort)}`));
|
|
}
|
|
|
|
lines.push(`CATEGORIES:${TYP_CATEGORY[event.typ] ?? 'Other'}`);
|
|
|
|
if (event.abgesagt) {
|
|
lines.push('STATUS:CANCELLED');
|
|
}
|
|
|
|
lines.push('END:VEVENT');
|
|
}
|
|
|
|
lines.push('END:VCALENDAR');
|
|
return lines.join('\r\n') + '\r\n';
|
|
}
|
|
|
|
export default new TrainingService();
|