add features
This commit is contained in:
614
backend/src/services/training.service.ts
Normal file
614
backend/src/services/training.service.ts
Normal file
@@ -0,0 +1,614 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user