diff --git a/backend/src/database/migrations/017_add_kategorie_zielgruppen.sql b/backend/src/database/migrations/017_add_kategorie_zielgruppen.sql new file mode 100644 index 0000000..507314a --- /dev/null +++ b/backend/src/database/migrations/017_add_kategorie_zielgruppen.sql @@ -0,0 +1,7 @@ +-- Migration 017: Add zielgruppen (target groups) to event categories +-- Links categories to user groups for visibility filtering +ALTER TABLE veranstaltung_kategorien + ADD COLUMN IF NOT EXISTS zielgruppen TEXT[] NOT NULL DEFAULT '{}'; + +-- Comment for documentation +COMMENT ON COLUMN veranstaltung_kategorien.zielgruppen IS 'Array of Authentik group names this category is linked to'; diff --git a/backend/src/models/events.model.ts b/backend/src/models/events.model.ts index 470b487..71f97eb 100644 --- a/backend/src/models/events.model.ts +++ b/backend/src/models/events.model.ts @@ -10,6 +10,7 @@ export interface VeranstaltungKategorie { beschreibung?: string | null; farbe?: string | null; icon?: string | null; + zielgruppen: string[]; erstellt_von?: string | null; erstellt_am: Date; aktualisiert_am: Date; @@ -76,6 +77,7 @@ export const CreateKategorieSchema = z.object({ .regex(/^#[0-9a-fA-F]{6}$/, 'Farbe muss ein gültiger Hex-Farbwert sein (z.B. #1976d2)') .optional(), icon: z.string().max(100).optional(), + zielgruppen: z.array(z.string()).optional(), }); export type CreateKategorieData = z.infer; diff --git a/backend/src/services/booking.service.ts b/backend/src/services/booking.service.ts index c209bb6..8dfc1e1 100644 --- a/backend/src/services/booking.service.ts +++ b/backend/src/services/booking.service.ts @@ -310,7 +310,7 @@ class BookingService { logger.info('Created new iCal token for user', { userId }); } - const baseUrl = process.env.ICAL_BASE_URL || 'http://localhost:3000'; + const baseUrl = (process.env.ICAL_BASE_URL || process.env.CORS_ORIGIN || 'http://localhost:3000').replace(/\/$/, ''); const subscribeUrl = `${baseUrl}/api/bookings/calendar.ics?token=${token}`; return { token, subscribeUrl }; } diff --git a/backend/src/services/events.service.ts b/backend/src/services/events.service.ts index 2addce2..2d25d68 100644 --- a/backend/src/services/events.service.ts +++ b/backend/src/services/events.service.ts @@ -104,7 +104,7 @@ class EventsService { /** Returns all event categories ordered by name. */ async getKategorien(): Promise { const result = await pool.query(` - SELECT id, name, beschreibung, farbe, icon, erstellt_von, erstellt_am, aktualisiert_am + SELECT id, name, beschreibung, farbe, icon, zielgruppen, erstellt_von, erstellt_am, aktualisiert_am FROM veranstaltung_kategorien ORDER BY name ASC `); @@ -114,6 +114,7 @@ class EventsService { beschreibung: row.beschreibung ?? null, farbe: row.farbe ?? null, icon: row.icon ?? null, + zielgruppen: row.zielgruppen ?? [], erstellt_von: row.erstellt_von ?? null, erstellt_am: new Date(row.erstellt_am), aktualisiert_am: new Date(row.aktualisiert_am), @@ -123,10 +124,10 @@ class EventsService { /** Creates a new event category. */ async createKategorie(data: CreateKategorieData, userId: string): Promise { const result = await pool.query( - `INSERT INTO veranstaltung_kategorien (name, beschreibung, farbe, icon, erstellt_von) - VALUES ($1, $2, $3, $4, $5) - RETURNING id, name, beschreibung, farbe, icon, erstellt_von, erstellt_am, aktualisiert_am`, - [data.name, data.beschreibung ?? null, data.farbe ?? null, data.icon ?? null, userId] + `INSERT INTO veranstaltung_kategorien (name, beschreibung, farbe, icon, zielgruppen, erstellt_von) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, name, beschreibung, farbe, icon, zielgruppen, erstellt_von, erstellt_am, aktualisiert_am`, + [data.name, data.beschreibung ?? null, data.farbe ?? null, data.icon ?? null, data.zielgruppen ?? [], userId] ); const row = result.rows[0]; return { @@ -135,6 +136,7 @@ class EventsService { beschreibung: row.beschreibung ?? null, farbe: row.farbe ?? null, icon: row.icon ?? null, + zielgruppen: row.zielgruppen ?? [], erstellt_von: row.erstellt_von ?? null, erstellt_am: new Date(row.erstellt_am), aktualisiert_am: new Date(row.aktualisiert_am), @@ -151,11 +153,12 @@ class EventsService { if (data.beschreibung !== undefined) { fields.push(`beschreibung = $${idx++}`); values.push(data.beschreibung); } if (data.farbe !== undefined) { fields.push(`farbe = $${idx++}`); values.push(data.farbe); } if (data.icon !== undefined) { fields.push(`icon = $${idx++}`); values.push(data.icon); } + if (data.zielgruppen !== undefined) { fields.push(`zielgruppen = $${idx++}`); values.push(data.zielgruppen); } if (fields.length === 0) { // Nothing to update — return the existing record const existing = await pool.query( - `SELECT id, name, beschreibung, farbe, icon, erstellt_von, erstellt_am, aktualisiert_am + `SELECT id, name, beschreibung, farbe, icon, zielgruppen, erstellt_von, erstellt_am, aktualisiert_am FROM veranstaltung_kategorien WHERE id = $1`, [id] ); @@ -163,7 +166,8 @@ class EventsService { const row = existing.rows[0]; return { id: row.id, name: row.name, beschreibung: row.beschreibung ?? null, - farbe: row.farbe ?? null, icon: row.icon ?? null, erstellt_von: row.erstellt_von ?? null, + farbe: row.farbe ?? null, icon: row.icon ?? null, zielgruppen: row.zielgruppen ?? [], + erstellt_von: row.erstellt_von ?? null, erstellt_am: new Date(row.erstellt_am), aktualisiert_am: new Date(row.aktualisiert_am), }; } @@ -174,14 +178,15 @@ class EventsService { const result = await pool.query( `UPDATE veranstaltung_kategorien SET ${fields.join(', ')} WHERE id = $${idx} - RETURNING id, name, beschreibung, farbe, icon, erstellt_von, erstellt_am, aktualisiert_am`, + RETURNING id, name, beschreibung, farbe, icon, zielgruppen, erstellt_von, erstellt_am, aktualisiert_am`, values ); if (result.rows.length === 0) return null; const row = result.rows[0]; return { id: row.id, name: row.name, beschreibung: row.beschreibung ?? null, - farbe: row.farbe ?? null, icon: row.icon ?? null, erstellt_von: row.erstellt_von ?? null, + farbe: row.farbe ?? null, icon: row.icon ?? null, zielgruppen: row.zielgruppen ?? [], + erstellt_von: row.erstellt_von ?? null, erstellt_am: new Date(row.erstellt_am), aktualisiert_am: new Date(row.aktualisiert_am), }; } @@ -419,7 +424,7 @@ class EventsService { token = inserted.rows[0].token; } - const baseUrl = (process.env.ICAL_BASE_URL ?? '').replace(/\/$/, ''); + const baseUrl = (process.env.ICAL_BASE_URL || process.env.CORS_ORIGIN || '').replace(/\/$/, ''); const subscribeUrl = `${baseUrl}/api/events/calendar.ics?token=${token}`; return { token, subscribeUrl }; diff --git a/docker-compose.yml b/docker-compose.yml index 6e5ca71..84e9623 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,7 @@ services: AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:?AUTHENTIK_CLIENT_SECRET is required} AUTHENTIK_REDIRECT_URI: ${AUTHENTIK_REDIRECT_URI:-https://start.feuerwehr-rems.at/auth/callback} NEXTCLOUD_URL: ${NEXTCLOUD_URL:-https://cloud.feuerwehr-rems.at} + ICAL_BASE_URL: ${ICAL_BASE_URL:-https://start.feuerwehr-rems.at} ports: - "${BACKEND_PORT:-3000}:3000" depends_on: diff --git a/frontend/src/pages/Kalender.tsx b/frontend/src/pages/Kalender.tsx index 4129fad..3995af1 100644 --- a/frontend/src/pages/Kalender.tsx +++ b/frontend/src/pages/Kalender.tsx @@ -435,29 +435,6 @@ function MonthCalendar({ ); })} - - {/* Legend */} - - {Object.entries(TYP_DOT_COLOR).map(([typ, color]) => ( - - - {typ} - - ))} - - - Pflichtveranstaltung - - - - Veranstaltung - - ); } diff --git a/frontend/src/pages/VeranstaltungKategorien.tsx b/frontend/src/pages/VeranstaltungKategorien.tsx index 47f2071..89e3bd2 100644 --- a/frontend/src/pages/VeranstaltungKategorien.tsx +++ b/frontend/src/pages/VeranstaltungKategorien.tsx @@ -23,6 +23,9 @@ import { Paper, Tooltip, Chip, + Checkbox, + FormGroup, + FormControlLabel, } from '@mui/material'; import { Add, @@ -34,7 +37,7 @@ import DashboardLayout from '../components/dashboard/DashboardLayout'; import { useAuth } from '../contexts/AuthContext'; import { useNotification } from '../contexts/NotificationContext'; import { eventsApi } from '../services/events'; -import type { VeranstaltungKategorie } from '../types/events.types'; +import type { VeranstaltungKategorie, GroupInfo } from '../types/events.types'; // --------------------------------------------------------------------------- // Category Form Dialog @@ -45,6 +48,7 @@ interface KategorieFormData { beschreibung: string; farbe: string; icon: string; + zielgruppen: string[]; } const EMPTY_FORM: KategorieFormData = { @@ -52,6 +56,7 @@ const EMPTY_FORM: KategorieFormData = { beschreibung: '', farbe: '#1976d2', icon: '', + zielgruppen: [], }; interface KategorieDialogProps { @@ -59,9 +64,10 @@ interface KategorieDialogProps { onClose: () => void; onSaved: () => void; editing: VeranstaltungKategorie | null; + groups: GroupInfo[]; } -function KategorieDialog({ open, onClose, onSaved, editing }: KategorieDialogProps) { +function KategorieDialog({ open, onClose, onSaved, editing, groups }: KategorieDialogProps) { const notification = useNotification(); const [loading, setLoading] = useState(false); const [form, setForm] = useState({ ...EMPTY_FORM }); @@ -74,6 +80,7 @@ function KategorieDialog({ open, onClose, onSaved, editing }: KategorieDialogPro beschreibung: editing.beschreibung ?? '', farbe: editing.farbe, icon: editing.icon ?? '', + zielgruppen: editing.zielgruppen ?? [], }); } else { setForm({ ...EMPTY_FORM }); @@ -84,6 +91,15 @@ function KategorieDialog({ open, onClose, onSaved, editing }: KategorieDialogPro setForm((prev) => ({ ...prev, [field]: value })); }; + const handleGroupToggle = (groupId: string) => { + setForm((prev) => ({ + ...prev, + zielgruppen: prev.zielgruppen.includes(groupId) + ? prev.zielgruppen.filter((g) => g !== groupId) + : [...prev.zielgruppen, groupId], + })); + }; + const handleSave = async () => { if (!form.name.trim()) { notification.showError('Name ist erforderlich'); @@ -96,6 +112,7 @@ function KategorieDialog({ open, onClose, onSaved, editing }: KategorieDialogPro beschreibung: form.beschreibung.trim() || undefined, farbe: form.farbe, icon: form.icon.trim() || undefined, + zielgruppen: form.zielgruppen, }; if (editing) { await eventsApi.updateKategorie(editing.id, payload); @@ -171,6 +188,29 @@ function KategorieDialog({ open, onClose, onSaved, editing }: KategorieDialogPro placeholder="z.B. EmojiEvents" helperText="Name eines MUI Material Icons" /> + {/* Group checkboxes */} + {groups.length > 0 && ( + + + Zielgruppen + + + {groups.map((group) => ( + handleGroupToggle(group.id)} + size="small" + /> + } + label={group.label} + /> + ))} + + + )} @@ -244,6 +284,7 @@ export default function VeranstaltungKategorien() { user?.groups?.some((g) => ['dashboard_admin', 'dashboard_moderator'].includes(g)) ?? false; const [kategorien, setKategorien] = useState([]); + const [groups, setGroups] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -258,8 +299,12 @@ export default function VeranstaltungKategorien() { setLoading(true); setError(null); try { - const data = await eventsApi.getKategorien(); + const [data, groupData] = await Promise.all([ + eventsApi.getKategorien(), + eventsApi.getGroups(), + ]); setKategorien(data); + setGroups(groupData); } catch (e: unknown) { const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Kategorien'; setError(msg); @@ -324,13 +369,14 @@ export default function VeranstaltungKategorien() { Name Beschreibung Icon + Gruppen {canManage && Aktionen} {kategorien.length === 0 ? ( - + Noch keine Kategorien vorhanden. @@ -386,6 +432,27 @@ export default function VeranstaltungKategorien() { + {/* Gruppen */} + + + {(kat.zielgruppen ?? []).length === 0 + ? + : (kat.zielgruppen ?? []).map((gId) => { + const group = groups.find((g) => g.id === gId); + return ( + + ); + }) + } + + + {/* Actions */} {canManage && ( @@ -424,6 +491,7 @@ export default function VeranstaltungKategorien() { onClose={() => { setFormOpen(false); setEditingKat(null); }} onSaved={loadKategorien} editing={editingKat} + groups={groups} /> {/* Delete Confirm Dialog */} diff --git a/frontend/src/services/events.ts b/frontend/src/services/events.ts index f65d798..17d9c17 100644 --- a/frontend/src/services/events.ts +++ b/frontend/src/services/events.ts @@ -37,6 +37,7 @@ export const eventsApi = { beschreibung?: string; farbe?: string; icon?: string; + zielgruppen?: string[]; }): Promise { return api .post>('/api/events/kategorien', data) @@ -46,7 +47,7 @@ export const eventsApi = { /** Update an existing event category */ updateKategorie( id: string, - data: Partial<{ name: string; beschreibung?: string; farbe?: string; icon?: string }> + data: Partial<{ name: string; beschreibung?: string; farbe?: string; icon?: string; zielgruppen?: string[] }> ): Promise { return api .patch>(`/api/events/kategorien/${id}`, data) diff --git a/frontend/src/types/events.types.ts b/frontend/src/types/events.types.ts index 8f1df1a..6f27616 100644 --- a/frontend/src/types/events.types.ts +++ b/frontend/src/types/events.types.ts @@ -8,6 +8,7 @@ export interface VeranstaltungKategorie { beschreibung?: string | null; farbe: string; // hex color e.g. '#1976d2' icon?: string | null; // MUI icon name + zielgruppen: string[]; erstellt_am: string; aktualisiert_am: string; }