From 8a0c4200ffcad85b1487c11348ae3b01edf72225 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Fri, 17 Apr 2026 09:10:57 +0200 Subject: [PATCH] feat(geplante-nachrichten): scheduled message rule engine with bot delivery, admin UI, and manual trigger --- backend/src/app.ts | 2 + backend/src/controllers/issue.controller.ts | 8 +- .../scheduledMessages.controller.ts | 138 +++++ backend/src/controllers/vehicle.controller.ts | 12 + .../migrations/094_scheduled_messages.sql | 42 ++ .../095_scheduled_messages_permissions.sql | 18 + backend/src/jobs/scheduled-messages.job.ts | 110 ++++ .../src/routes/scheduledMessages.routes.ts | 72 +++ backend/src/server.ts | 5 + backend/src/services/issue.service.ts | 18 +- .../src/services/scheduledMessages.service.ts | 540 ++++++++++++++++++ frontend/src/App.tsx | 36 ++ .../components/admin/ModuleSettingsIssues.tsx | 12 +- .../admin/ToolSettingsNextcloud.tsx | 168 ++++-- frontend/src/components/shared/Sidebar.tsx | 7 + frontend/src/constants/widgets.ts | 1 - frontend/src/pages/GeplanteMachrichten.tsx | 217 +++++++ .../pages/GeplanteMachrichtenBearbeiten.tsx | 7 + .../src/pages/GeplanteMachrichtenDetail.tsx | 252 ++++++++ .../src/pages/GeplanteMachrichtenForm.tsx | 426 ++++++++++++++ frontend/src/pages/GeplanteMachrichtenNeu.tsx | 5 + frontend/src/pages/Settings.tsx | 93 +++ frontend/src/services/scheduledMessages.ts | 36 ++ frontend/src/types/scheduledMessages.types.ts | 52 ++ 24 files changed, 2208 insertions(+), 69 deletions(-) create mode 100644 backend/src/controllers/scheduledMessages.controller.ts create mode 100644 backend/src/database/migrations/094_scheduled_messages.sql create mode 100644 backend/src/database/migrations/095_scheduled_messages_permissions.sql create mode 100644 backend/src/jobs/scheduled-messages.job.ts create mode 100644 backend/src/routes/scheduledMessages.routes.ts create mode 100644 backend/src/services/scheduledMessages.service.ts create mode 100644 frontend/src/pages/GeplanteMachrichten.tsx create mode 100644 frontend/src/pages/GeplanteMachrichtenBearbeiten.tsx create mode 100644 frontend/src/pages/GeplanteMachrichtenDetail.tsx create mode 100644 frontend/src/pages/GeplanteMachrichtenForm.tsx create mode 100644 frontend/src/pages/GeplanteMachrichtenNeu.tsx create mode 100644 frontend/src/services/scheduledMessages.ts create mode 100644 frontend/src/types/scheduledMessages.types.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index b91de9a..4e2c728 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -110,6 +110,7 @@ import ausruestungTypRoutes from './routes/ausruestungTyp.routes'; import buchhaltungRoutes from './routes/buchhaltung.routes'; import personalEquipmentRoutes from './routes/personalEquipment.routes'; import toolConfigRoutes from './routes/toolConfig.routes'; +import scheduledMessagesRoutes from './routes/scheduledMessages.routes'; app.use('/api/auth', authRoutes); app.use('/api/user', userRoutes); @@ -142,6 +143,7 @@ app.use('/api/ausruestung-typen', ausruestungTypRoutes); app.use('/api/buchhaltung', buchhaltungRoutes); app.use('/api/persoenliche-ausruestung', personalEquipmentRoutes); app.use('/api/admin/tools', toolConfigRoutes); +app.use('/api/scheduled-messages', scheduledMessagesRoutes); // Static file serving for uploads (authenticated) const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads'); diff --git a/backend/src/controllers/issue.controller.ts b/backend/src/controllers/issue.controller.ts index bdf5117..2821d72 100644 --- a/backend/src/controllers/issue.controller.ts +++ b/backend/src/controllers/issue.controller.ts @@ -382,7 +382,7 @@ class IssueController { return; } try { - const type = await issueService.deactivateType(id); + const type = await issueService.deleteType(id); if (!type) { res.status(404).json({ success: false, message: 'Issue-Typ nicht gefunden' }); return; @@ -390,7 +390,7 @@ class IssueController { res.status(200).json({ success: true, data: type }); } catch (error) { logger.error('IssueController.deleteType error', { error }); - res.status(500).json({ success: false, message: 'Issue-Typ konnte nicht deaktiviert werden' }); + res.status(500).json({ success: false, message: 'Issue-Typ konnte nicht gelöscht werden' }); } } @@ -461,7 +461,7 @@ class IssueController { res.status(200).json({ success: true, data: item }); } catch (error) { logger.error('IssueController.deleteIssueStatus error', { error }); - res.status(500).json({ success: false, message: 'Issue-Status konnte nicht deaktiviert werden' }); + res.status(500).json({ success: false, message: 'Issue-Status konnte nicht gelöscht werden' }); } } @@ -512,7 +512,7 @@ class IssueController { res.status(200).json({ success: true, data: item }); } catch (error) { logger.error('IssueController.deleteIssuePriority error', { error }); - res.status(500).json({ success: false, message: 'Priorität konnte nicht deaktiviert werden' }); + res.status(500).json({ success: false, message: 'Priorität konnte nicht gelöscht werden' }); } } diff --git a/backend/src/controllers/scheduledMessages.controller.ts b/backend/src/controllers/scheduledMessages.controller.ts new file mode 100644 index 0000000..aaf443c --- /dev/null +++ b/backend/src/controllers/scheduledMessages.controller.ts @@ -0,0 +1,138 @@ +import { Request, Response } from 'express'; +import scheduledMessagesService from '../services/scheduledMessages.service'; +import logger from '../utils/logger'; + +class ScheduledMessagesController { + async getAll(_req: Request, res: Response): Promise { + try { + const rules = await scheduledMessagesService.getAll(); + res.json({ success: true, data: rules }); + } catch (error) { + logger.error('ScheduledMessagesController.getAll error', { error }); + res.status(500).json({ success: false, message: 'Regeln konnten nicht geladen werden' }); + } + } + + async getById(req: Request, res: Response): Promise { + try { + const { id } = req.params as Record; + const userId = req.user?.id; + const rule = await scheduledMessagesService.getById(id, userId); + if (!rule) { + res.status(404).json({ success: false, message: 'Regel nicht gefunden' }); + return; + } + res.json({ success: true, data: rule }); + } catch (error) { + logger.error('ScheduledMessagesController.getById error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Regel konnte nicht geladen werden' }); + } + } + + async create(req: Request, res: Response): Promise { + try { + const userId = req.user!.id; + const rule = await scheduledMessagesService.create(req.body, userId); + res.status(201).json({ success: true, data: rule }); + } catch (error) { + logger.error('ScheduledMessagesController.create error', { error }); + res.status(500).json({ success: false, message: 'Regel konnte nicht erstellt werden' }); + } + } + + async update(req: Request, res: Response): Promise { + try { + const { id } = req.params as Record; + const rule = await scheduledMessagesService.update(id, req.body); + if (!rule) { + res.status(404).json({ success: false, message: 'Regel nicht gefunden' }); + return; + } + res.json({ success: true, data: rule }); + } catch (error) { + logger.error('ScheduledMessagesController.update error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Regel konnte nicht aktualisiert werden' }); + } + } + + async delete(req: Request, res: Response): Promise { + try { + const { id } = req.params as Record; + const deleted = await scheduledMessagesService.delete(id); + if (!deleted) { + res.status(404).json({ success: false, message: 'Regel nicht gefunden' }); + return; + } + res.status(204).send(); + } catch (error) { + logger.error('ScheduledMessagesController.delete error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Regel konnte nicht gelöscht werden' }); + } + } + + async getRooms(_req: Request, res: Response): Promise { + try { + const result = await scheduledMessagesService.getRooms(); + if (!result.configured) { + res.json({ configured: false }); + return; + } + res.json({ configured: true, data: result.data }); + } catch (error) { + logger.error('ScheduledMessagesController.getRooms error', { error }); + res.status(500).json({ success: false, message: 'Räume konnten nicht geladen werden' }); + } + } + + async subscribe(req: Request, res: Response): Promise { + try { + const { id } = req.params as Record; + const userId = req.user!.id; + const { roomToken } = req.body; + if (!roomToken) { + res.status(400).json({ success: false, message: 'roomToken ist erforderlich' }); + return; + } + await scheduledMessagesService.subscribe(id, userId, roomToken); + res.json({ success: true }); + } catch (error) { + logger.error('ScheduledMessagesController.subscribe error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Abonnement konnte nicht erstellt werden' }); + } + } + + async unsubscribe(req: Request, res: Response): Promise { + try { + const { id } = req.params as Record; + const userId = req.user!.id; + await scheduledMessagesService.unsubscribe(id, userId); + res.status(204).send(); + } catch (error) { + logger.error('ScheduledMessagesController.unsubscribe error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Abonnement konnte nicht entfernt werden' }); + } + } + + async triggerNow(req: Request, res: Response): Promise { + try { + const { id } = req.params as Record; + const rule = await scheduledMessagesService.getById(id); + if (!rule) { + res.status(404).json({ success: false, message: 'Regel nicht gefunden' }); + return; + } + const rooms = await scheduledMessagesService.getRooms(); + if (!rooms.configured) { + res.status(400).json({ success: false, configured: false, message: 'Bot nicht konfiguriert' }); + return; + } + await scheduledMessagesService.buildAndSend(rule); + res.json({ success: true }); + } catch (error) { + logger.error('ScheduledMessagesController.triggerNow error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Nachricht konnte nicht gesendet werden' }); + } + } +} + +export default new ScheduledMessagesController(); diff --git a/backend/src/controllers/vehicle.controller.ts b/backend/src/controllers/vehicle.controller.ts index f3e2f7e..c34ff27 100644 --- a/backend/src/controllers/vehicle.controller.ts +++ b/backend/src/controllers/vehicle.controller.ts @@ -2,6 +2,7 @@ import { Request, Response } from 'express'; import { z } from 'zod'; import vehicleService from '../services/vehicle.service'; import equipmentService from '../services/equipment.service'; +import scheduledMessagesService from '../services/scheduledMessages.service'; import { FahrzeugStatus } from '../models/vehicle.model'; import logger from '../utils/logger'; @@ -314,6 +315,17 @@ class VehicleController { parsed.data.ausserDienstVon ? new Date(parsed.data.ausserDienstVon) : null, parsed.data.ausserDienstBis ? new Date(parsed.data.ausserDienstBis) : null, ); + + // Fire-and-forget: notify scheduled messages when vehicle goes out of service + if (parsed.data.status !== FahrzeugStatus.Einsatzbereit) { + scheduledMessagesService.sendVehicleEvent(id).catch(err => { + logger.error('Failed to send vehicle event notification', { + vehicleId: id, + error: err instanceof Error ? err.message : String(err), + }); + }); + } + res.status(200).json({ success: true, data: result }); } catch (error: any) { if (error?.message === 'Vehicle not found') { diff --git a/backend/src/database/migrations/094_scheduled_messages.sql b/backend/src/database/migrations/094_scheduled_messages.sql new file mode 100644 index 0000000..d9d2fff --- /dev/null +++ b/backend/src/database/migrations/094_scheduled_messages.sql @@ -0,0 +1,42 @@ +-- Migration 094: Scheduled Messages tables + +CREATE TABLE IF NOT EXISTS scheduled_message_rules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + message_type TEXT NOT NULL, + -- message_type values: event_summary | birthday_list | dienstjubilaeen | + -- fahrzeug_status | fahrzeug_event | bestellungen + trigger_mode TEXT NOT NULL, + -- trigger_mode values: day_of_week | days_before_month_start | event + day_of_week INT, -- 0-6, used when trigger_mode = day_of_week + send_time TIME, -- used when trigger_mode = day_of_week + days_before_month_start INT, -- used when trigger_mode = days_before_month_start + window_mode TEXT, -- rolling | calendar_month | NULL for event types + window_days INT, -- used when window_mode = rolling + target_room_token TEXT NOT NULL, + target_room_name TEXT, + template TEXT NOT NULL, + extra_config JSONB, -- e.g. {"min_days_overdue": 14} for bestellungen + subscribable BOOLEAN NOT NULL DEFAULT FALSE, + allowed_groups TEXT[], -- Authentik group names allowed to subscribe + last_sent_at DATE, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID REFERENCES users(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS scheduled_message_subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + rule_id UUID NOT NULL REFERENCES scheduled_message_rules(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + room_token TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (rule_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_smr_active_trigger + ON scheduled_message_rules(active, trigger_mode); +CREATE INDEX IF NOT EXISTS idx_sms_rule_id + ON scheduled_message_subscriptions(rule_id); +CREATE INDEX IF NOT EXISTS idx_sms_user_id + ON scheduled_message_subscriptions(user_id); diff --git a/backend/src/database/migrations/095_scheduled_messages_permissions.sql b/backend/src/database/migrations/095_scheduled_messages_permissions.sql new file mode 100644 index 0000000..fb1dcd5 --- /dev/null +++ b/backend/src/database/migrations/095_scheduled_messages_permissions.sql @@ -0,0 +1,18 @@ +-- Migration 095: Scheduled Messages permissions + +INSERT INTO feature_groups (id, label, sort_order) +VALUES ('scheduled_messages', 'Geplante Nachrichten', 12) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES + ('scheduled_messages:view', 'scheduled_messages', 'Ansehen', 'Geplante Nachrichten ansehen', 1), + ('scheduled_messages:edit', 'scheduled_messages', 'Verwalten', 'Automationen verwalten', 2), + ('scheduled_messages:subscribe', 'scheduled_messages', 'Abonnieren', 'Nachrichten abonnieren', 3) +ON CONFLICT (id) DO NOTHING; + +-- Only dashboard_admin gets defaults; other groups assigned manually on live system +INSERT INTO group_permissions (authentik_group, permission_id) VALUES + ('dashboard_admin', 'scheduled_messages:view'), + ('dashboard_admin', 'scheduled_messages:edit'), + ('dashboard_admin', 'scheduled_messages:subscribe') +ON CONFLICT DO NOTHING; diff --git a/backend/src/jobs/scheduled-messages.job.ts b/backend/src/jobs/scheduled-messages.job.ts new file mode 100644 index 0000000..1cbb603 --- /dev/null +++ b/backend/src/jobs/scheduled-messages.job.ts @@ -0,0 +1,110 @@ +import pool from '../config/database'; +import scheduledMessagesService, { ScheduledMessageRule } from '../services/scheduledMessages.service'; +import logger from '../utils/logger'; + +const INTERVAL_MS = 60 * 1000; // 60 seconds +let jobInterval: ReturnType | null = null; +let isRunning = false; + +function isDue(rule: ScheduledMessageRule): boolean { + const now = new Date(); + const today = now.toISOString().slice(0, 10); + + // Skip if already sent today + if (rule.last_sent_at && rule.last_sent_at.slice(0, 10) === today) { + return false; + } + + if (rule.trigger_mode === 'day_of_week') { + // Check weekday matches + if (now.getDay() !== rule.day_of_week) return false; + + // Check HH:MM matches (within the 60s window) + if (!rule.send_time) return false; + const [ruleHour, ruleMinute] = rule.send_time.split(':').map(Number); + if (now.getHours() !== ruleHour || now.getMinutes() !== ruleMinute) return false; + + return true; + } + + if (rule.trigger_mode === 'days_before_month_start') { + if (rule.days_before_month_start == null) return false; + + // Compute target date: 1st of next month minus N days + const firstOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1); + const targetDate = new Date(firstOfNextMonth.getTime() - rule.days_before_month_start * 86400000); + const targetStr = targetDate.toISOString().slice(0, 10); + + return today === targetStr; + } + + // event-triggered rules are not handled by the job + return false; +} + +async function runScheduledMessagesCheck(): Promise { + if (isRunning) { + logger.warn('ScheduledMessagesJob: previous run still in progress — skipping'); + return; + } + isRunning = true; + + try { + const result = await pool.query( + `SELECT * FROM scheduled_message_rules WHERE active = true AND trigger_mode != 'event'`, + ); + + let processed = 0; + for (const row of result.rows as ScheduledMessageRule[]) { + if (!isDue(row)) continue; + + try { + await scheduledMessagesService.buildAndSend(row); + await pool.query( + 'UPDATE scheduled_message_rules SET last_sent_at = CURRENT_DATE WHERE id = $1', + [row.id], + ); + processed++; + logger.info('ScheduledMessagesJob: sent message', { + ruleId: row.id, + name: row.name, + type: row.message_type, + }); + } catch (error) { + logger.error('ScheduledMessagesJob: failed to process rule', { + ruleId: row.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + if (processed > 0) { + logger.info(`ScheduledMessagesJob: processed ${processed} rules`); + } + } catch (error) { + logger.error('ScheduledMessagesJob: unexpected error', { + error: error instanceof Error ? error.message : String(error), + }); + } finally { + isRunning = false; + } +} + +export function startScheduledMessagesJob(): void { + if (jobInterval !== null) { + logger.warn('Scheduled messages job already running — skipping duplicate start'); + return; + } + // Run once after short delay, then repeat + setTimeout(() => runScheduledMessagesCheck(), 30 * 1000); + jobInterval = setInterval(() => runScheduledMessagesCheck(), INTERVAL_MS); + logger.info('Scheduled messages job started (every 60 seconds)'); +} + +export function stopScheduledMessagesJob(): void { + if (jobInterval !== null) { + clearInterval(jobInterval); + jobInterval = null; + } + logger.info('Scheduled messages job stopped'); +} diff --git a/backend/src/routes/scheduledMessages.routes.ts b/backend/src/routes/scheduledMessages.routes.ts new file mode 100644 index 0000000..d3ea8d0 --- /dev/null +++ b/backend/src/routes/scheduledMessages.routes.ts @@ -0,0 +1,72 @@ +import { Router } from 'express'; +import scheduledMessagesController from '../controllers/scheduledMessages.controller'; +import { authenticate } from '../middleware/auth.middleware'; +import { requirePermission } from '../middleware/rbac.middleware'; + +const router = Router(); + +// /rooms MUST come before /:id to avoid being captured as an id param +router.get( + '/rooms', + authenticate, + requirePermission('scheduled_messages:edit'), + scheduledMessagesController.getRooms.bind(scheduledMessagesController), +); + +router.get( + '/', + authenticate, + requirePermission('scheduled_messages:view'), + scheduledMessagesController.getAll.bind(scheduledMessagesController), +); + +router.post( + '/', + authenticate, + requirePermission('scheduled_messages:edit'), + scheduledMessagesController.create.bind(scheduledMessagesController), +); + +router.get( + '/:id', + authenticate, + requirePermission('scheduled_messages:view'), + scheduledMessagesController.getById.bind(scheduledMessagesController), +); + +router.patch( + '/:id', + authenticate, + requirePermission('scheduled_messages:edit'), + scheduledMessagesController.update.bind(scheduledMessagesController), +); + +router.delete( + '/:id', + authenticate, + requirePermission('scheduled_messages:edit'), + scheduledMessagesController.delete.bind(scheduledMessagesController), +); + +router.post( + '/:id/subscribe', + authenticate, + requirePermission('scheduled_messages:subscribe'), + scheduledMessagesController.subscribe.bind(scheduledMessagesController), +); + +router.delete( + '/:id/subscribe', + authenticate, + requirePermission('scheduled_messages:subscribe'), + scheduledMessagesController.unsubscribe.bind(scheduledMessagesController), +); + +router.post( + '/:id/trigger', + authenticate, + requirePermission('scheduled_messages:edit'), + scheduledMessagesController.triggerNow.bind(scheduledMessagesController), +); + +export default router; diff --git a/backend/src/server.ts b/backend/src/server.ts index f91de4d..998fe31 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -8,6 +8,7 @@ import { startReminderJob, stopReminderJob } from './jobs/reminder.job'; import { startIssueReminderJob, stopIssueReminderJob } from './jobs/issue-reminder.job'; import { startChecklistReminderJob, stopChecklistReminderJob } from './jobs/checklist-reminder.job'; import { startBuchhaltungRecurringJob, stopBuchhaltungRecurringJob } from './jobs/buchhaltung-recurring.job'; +import { startScheduledMessagesJob, stopScheduledMessagesJob } from './jobs/scheduled-messages.job'; import { permissionService } from './services/permission.service'; const startServer = async (): Promise => { @@ -44,6 +45,9 @@ const startServer = async (): Promise => { // Start the buchhaltung recurring transaction job startBuchhaltungRecurringJob(); + // Start the scheduled messages job + startScheduledMessagesJob(); + // Start the server const server = app.listen(environment.port, () => { logger.info('Server started successfully', { @@ -71,6 +75,7 @@ const startServer = async (): Promise => { stopIssueReminderJob(); stopChecklistReminderJob(); stopBuchhaltungRecurringJob(); + stopScheduledMessagesJob(); server.close(async () => { logger.info('HTTP server closed'); diff --git a/backend/src/services/issue.service.ts b/backend/src/services/issue.service.ts index 062685a..d618809 100644 --- a/backend/src/services/issue.service.ts +++ b/backend/src/services/issue.service.ts @@ -378,16 +378,16 @@ async function updateType( } } -async function deactivateType(id: number) { +async function deleteType(id: number) { try { const result = await pool.query( - `UPDATE issue_typen SET aktiv = false WHERE id = $1 RETURNING *`, + `DELETE FROM issue_typen WHERE id = $1 RETURNING *`, [id] ); return result.rows[0] || null; } catch (error) { - logger.error('IssueService.deactivateType failed', { error, id }); - throw new Error('Issue-Typ konnte nicht deaktiviert werden'); + logger.error('IssueService.deleteType failed', { error, id }); + throw new Error('Issue-Typ konnte nicht gelöscht werden'); } } @@ -514,13 +514,13 @@ async function updateIssueStatus(id: number, data: { async function deleteIssueStatus(id: number) { try { const result = await pool.query( - `UPDATE issue_statuses SET aktiv = false WHERE id = $1 RETURNING *`, + `DELETE FROM issue_statuses WHERE id = $1 RETURNING *`, [id] ); return result.rows[0] || null; } catch (error) { logger.error('IssueService.deleteIssueStatus failed', { error, id }); - throw new Error('Issue-Status konnte nicht deaktiviert werden'); + throw new Error('Issue-Status konnte nicht gelöscht werden'); } } @@ -591,13 +591,13 @@ async function updateIssuePriority(id: number, data: { async function deleteIssuePriority(id: number) { try { const result = await pool.query( - `UPDATE issue_prioritaeten SET aktiv = false WHERE id = $1 RETURNING *`, + `DELETE FROM issue_prioritaeten WHERE id = $1 RETURNING *`, [id] ); return result.rows[0] || null; } catch (error) { logger.error('IssueService.deleteIssuePriority failed', { error, id }); - throw new Error('Priorität konnte nicht deaktiviert werden'); + throw new Error('Priorität konnte nicht gelöscht werden'); } } @@ -699,7 +699,7 @@ export default { getTypes, createType, updateType, - deactivateType, + deleteType, getAssignableMembers, getIssueCounts, getIssueStatuses, diff --git a/backend/src/services/scheduledMessages.service.ts b/backend/src/services/scheduledMessages.service.ts new file mode 100644 index 0000000..81cd1f4 --- /dev/null +++ b/backend/src/services/scheduledMessages.service.ts @@ -0,0 +1,540 @@ +import pool from '../config/database'; +import settingsService from './settings.service'; +import nextcloudService from './nextcloud.service'; +import logger from '../utils/logger'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +interface ScheduledMessageRule { + id: string; + name: string; + message_type: string; + trigger_mode: string; + day_of_week: number | null; + send_time: string | null; + days_before_month_start: number | null; + window_mode: string | null; + window_days: number | null; + target_room_token: string; + target_room_name: string | null; + template: string; + extra_config: Record | null; + subscribable: boolean; + allowed_groups: string[]; + last_sent_at: string | null; + active: boolean; + created_at: string; + created_by: string | null; + subscriber_count?: number; + is_subscribed?: boolean; +} + +interface RoomInfo { + token: string; + displayName: string; + type: number; +} + +interface RoomsResult { + configured: boolean; + data?: RoomInfo[]; +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +async function getBotCredentials(): Promise<{ username: string; appPassword: string } | null> { + const usernameRow = await settingsService.get('nextcloud_bot_username'); + const appPasswordRow = await settingsService.get('nextcloud_bot_app_password'); + const username = typeof usernameRow?.value === 'string' ? usernameRow.value : null; + const appPassword = typeof appPasswordRow?.value === 'string' ? appPasswordRow.value : null; + if (!username || !appPassword) return null; + return { username, appPassword }; +} + +function computeWindow(rule: ScheduledMessageRule): { startDate: Date; endDate: Date } { + const now = new Date(); + if (rule.window_mode === 'rolling') { + const endDate = new Date(now.getTime() + (rule.window_days ?? 7) * 86400000); + return { startDate: now, endDate }; + } + // calendar_month = next full calendar month + const startDate = new Date(now.getFullYear(), now.getMonth() + 1, 1); + const endDate = new Date(now.getFullYear(), now.getMonth() + 2, 0); // last day of next month + return { startDate, endDate }; +} + +function renderTemplate(template: string, vars: Record): string { + return Object.entries(vars).reduce( + (acc, [key, val]) => acc.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), val), + template, + ); +} + +function formatDate(d: Date): string { + const day = String(d.getDate()).padStart(2, '0'); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const year = d.getFullYear(); + return `${day}.${month}.${year}`; +} + +function formatMonth(d: Date): string { + const months = [ + 'Jänner', 'Februar', 'März', 'April', 'Mai', 'Juni', + 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember', + ]; + return `${months[d.getMonth()]} ${d.getFullYear()}`; +} + +// ── Content Builders ───────────────────────────────────────────────────────── + +async function buildEventSummary(startDate: Date, endDate: Date): Promise<{ items: string; count: string }> { + const result = await pool.query( + `SELECT titel, datum_von, datum_bis + FROM veranstaltungen + WHERE status != 'abgesagt' + AND datum_von <= $2 + AND datum_bis >= $1 + ORDER BY datum_von ASC`, + [startDate.toISOString(), endDate.toISOString()], + ); + + if (result.rows.length === 0) { + return { items: 'Keine Veranstaltungen im Zeitraum.', count: '0' }; + } + + const lines = result.rows.map((r: Record) => { + const von = formatDate(new Date(r.datum_von as string)); + const bis = formatDate(new Date(r.datum_bis as string)); + return von === bis + ? `- ${r.titel as string} (${von})` + : `- ${r.titel as string} (${von} – ${bis})`; + }); + + return { items: lines.join('\n'), count: String(result.rows.length) }; +} + +async function buildBirthdayList(startDate: Date, endDate: Date): Promise<{ items: string; count: string }> { + // Find members whose birthday (month+day) falls within the window + const result = await pool.query( + `SELECT vorname, nachname, geburtsdatum + FROM mitglieder_profile + WHERE status IN ('aktiv', 'kind', 'jugend', 'reserve') + AND geburtsdatum IS NOT NULL + AND ( + (EXTRACT(MONTH FROM geburtsdatum), EXTRACT(DAY FROM geburtsdatum)) + IN ( + SELECT EXTRACT(MONTH FROM d::date), EXTRACT(DAY FROM d::date) + FROM generate_series($1::date, $2::date, '1 day'::interval) AS d + ) + ) + ORDER BY EXTRACT(MONTH FROM geburtsdatum), EXTRACT(DAY FROM geburtsdatum)`, + [startDate.toISOString().slice(0, 10), endDate.toISOString().slice(0, 10)], + ); + + if (result.rows.length === 0) { + return { items: 'Keine Geburtstage im Zeitraum.', count: '0' }; + } + + const lines = result.rows.map((r: Record) => { + const geb = new Date(r.geburtsdatum as string); + const day = String(geb.getDate()).padStart(2, '0'); + const month = String(geb.getMonth() + 1).padStart(2, '0'); + return `- ${r.vorname as string} ${r.nachname as string} (${day}.${month}.)`; + }); + + return { items: lines.join('\n'), count: String(result.rows.length) }; +} + +async function buildDienstjubilaeen(startDate: Date, endDate: Date): Promise<{ items: string; count: string }> { + // Members hitting a 5-year milestone in the window + const result = await pool.query( + `SELECT vorname, nachname, eintrittsdatum + FROM mitglieder_profile + WHERE status IN ('aktiv', 'reserve') + AND eintrittsdatum IS NOT NULL`, + ); + + const jubilare: Array<{ name: string; years: number; date: string }> = []; + + for (const r of result.rows) { + const eintritt = new Date(r.eintrittsdatum as string); + // Check each year the anniversary date falls in the window + for (let y = startDate.getFullYear(); y <= endDate.getFullYear(); y++) { + const anniversary = new Date(y, eintritt.getMonth(), eintritt.getDate()); + if (anniversary >= startDate && anniversary <= endDate) { + const years = y - eintritt.getFullYear(); + if (years > 0 && years % 5 === 0) { + jubilare.push({ + name: `${r.vorname as string} ${r.nachname as string}`, + years, + date: formatDate(anniversary), + }); + } + } + } + } + + if (jubilare.length === 0) { + return { items: 'Keine Dienstjubiläen im Zeitraum.', count: '0' }; + } + + jubilare.sort((a, b) => a.years - b.years); + const lines = jubilare.map(j => `- ${j.name}: ${j.years} Jahre (${j.date})`); + + return { items: lines.join('\n'), count: String(jubilare.length) }; +} + +async function buildFahrzeugStatus(): Promise<{ items: string; count: string }> { + const result = await pool.query( + `SELECT bezeichnung, kurzname, status, status_bemerkung + FROM fahrzeuge + WHERE status != 'einsatzbereit' + ORDER BY bezeichnung`, + ); + + if (result.rows.length === 0) { + return { items: 'Alle Fahrzeuge einsatzbereit.', count: '0' }; + } + + const lines = result.rows.map((r: Record) => { + const name = (r.kurzname as string) || (r.bezeichnung as string); + const remark = r.status_bemerkung ? ` – ${r.status_bemerkung as string}` : ''; + return `- ${name} (${r.status as string})${remark}`; + }); + + return { items: lines.join('\n'), count: String(result.rows.length) }; +} + +async function buildBestellungen(minDaysOverdue: number): Promise<{ items: string; count: string }> { + const result = await pool.query( + `SELECT bezeichnung, status, erstellt_am + FROM bestellungen + WHERE status IN ('erstellt', 'bestellt', 'teillieferung') + AND erstellt_am < NOW() - ($1 || ' days')::interval + ORDER BY erstellt_am ASC`, + [minDaysOverdue], + ); + + if (result.rows.length === 0) { + return { items: 'Keine offenen Bestellungen.', count: '0' }; + } + + const lines = result.rows.map((r: Record) => { + const created = formatDate(new Date(r.erstellt_am as string)); + return `- ${r.bezeichnung as string} (${r.status as string}, erstellt ${created})`; + }); + + return { items: lines.join('\n'), count: String(result.rows.length) }; +} + +// ── CRUD ───────────────────────────────────────────────────────────────────── + +async function getAll(): Promise { + const result = await pool.query( + 'SELECT * FROM scheduled_message_rules ORDER BY created_at DESC', + ); + return result.rows; +} + +async function getById(id: string, userId?: string): Promise { + const result = await pool.query( + `SELECT r.*, + (SELECT COUNT(*)::int FROM scheduled_message_subscriptions WHERE rule_id = $1) AS subscriber_count, + (SELECT EXISTS(SELECT 1 FROM scheduled_message_subscriptions WHERE rule_id = $1 AND user_id = $2)) AS is_subscribed + FROM scheduled_message_rules r + WHERE r.id = $1`, + [id, userId ?? null], + ); + return result.rows[0] ?? null; +} + +async function create( + data: Omit, + userId: string, +): Promise { + const result = await pool.query( + `INSERT INTO scheduled_message_rules + (name, message_type, trigger_mode, day_of_week, send_time, + days_before_month_start, window_mode, window_days, + target_room_token, target_room_name, template, extra_config, + subscribable, allowed_groups, active, created_by) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) + RETURNING *`, + [ + data.name, data.message_type, data.trigger_mode, data.day_of_week, data.send_time, + data.days_before_month_start, data.window_mode, data.window_days, + data.target_room_token, data.target_room_name, data.template, + data.extra_config ? JSON.stringify(data.extra_config) : null, + data.subscribable, data.allowed_groups, data.active, userId, + ], + ); + return result.rows[0]; +} + +async function update( + id: string, + data: Partial>, +): Promise { + const keys = Object.keys(data) as Array; + if (keys.length === 0) return getById(id); + + const setClauses: string[] = []; + const values: unknown[] = []; + let paramIndex = 1; + + for (const key of keys) { + setClauses.push(`${key} = $${paramIndex}`); + const val = data[key]; + if (key === 'extra_config' && val !== null && val !== undefined) { + values.push(JSON.stringify(val)); + } else { + values.push(val); + } + paramIndex++; + } + + values.push(id); + const result = await pool.query( + `UPDATE scheduled_message_rules SET ${setClauses.join(', ')} WHERE id = $${paramIndex} RETURNING *`, + values, + ); + return result.rows[0] ?? null; +} + +async function remove(id: string): Promise { + const result = await pool.query('DELETE FROM scheduled_message_rules WHERE id = $1', [id]); + return (result.rowCount ?? 0) > 0; +} + +// ── Rooms ──────────────────────────────────────────────────────────────────── + +async function getRooms(): Promise { + const creds = await getBotCredentials(); + if (!creds) { + return { configured: false }; + } + try { + const { conversations } = await nextcloudService.getConversations(creds.username, creds.appPassword); + return { + configured: true, + data: conversations.map(c => ({ + token: c.token, + displayName: c.displayName, + type: c.type, + })), + }; + } catch (error) { + logger.error('scheduledMessages.getRooms failed', { + error: error instanceof Error ? error.message : String(error), + }); + return { configured: false }; + } +} + +// ── Subscriptions ──────────────────────────────────────────────────────────── + +async function subscribe(ruleId: string, userId: string, roomToken: string): Promise { + await pool.query( + `INSERT INTO scheduled_message_subscriptions (rule_id, user_id, room_token) + VALUES ($1, $2, $3) + ON CONFLICT (rule_id, user_id) DO NOTHING`, + [ruleId, userId, roomToken], + ); +} + +async function unsubscribe(ruleId: string, userId: string): Promise { + await pool.query( + 'DELETE FROM scheduled_message_subscriptions WHERE rule_id = $1 AND user_id = $2', + [ruleId, userId], + ); +} + +async function getSubscriptionsForUser(userId: string): Promise> { + const result = await pool.query( + `SELECT r.*, s.room_token AS subscription_room_token + FROM scheduled_message_rules r + INNER JOIN scheduled_message_subscriptions s ON s.rule_id = r.id + WHERE s.user_id = $1 + ORDER BY r.name`, + [userId], + ); + return result.rows; +} + +// ── Build & Send ───────────────────────────────────────────────────────────── + +async function buildAndSend(rule: ScheduledMessageRule): Promise { + const creds = await getBotCredentials(); + if (!creds) { + logger.warn('scheduledMessages.buildAndSend: no bot credentials configured'); + return; + } + + let vars: Record; + + if (rule.message_type === 'fahrzeug_status') { + vars = await buildFahrzeugStatus(); + } else if (rule.message_type === 'bestellungen') { + const minDays = (rule.extra_config?.min_days_overdue as number) ?? 14; + vars = await buildBestellungen(minDays); + } else { + const { startDate, endDate } = computeWindow(rule); + const windowVars: Record = { + date: formatDate(new Date()), + window: `${formatDate(startDate)} – ${formatDate(endDate)}`, + month: formatMonth(startDate), + }; + + switch (rule.message_type) { + case 'event_summary': { + const data = await buildEventSummary(startDate, endDate); + vars = { ...windowVars, ...data }; + break; + } + case 'birthday_list': { + const data = await buildBirthdayList(startDate, endDate); + vars = { ...windowVars, ...data }; + break; + } + case 'dienstjubilaeen': { + const data = await buildDienstjubilaeen(startDate, endDate); + vars = { ...windowVars, ...data }; + break; + } + default: + logger.warn(`scheduledMessages: unknown message_type "${rule.message_type}"`); + return; + } + } + + // Add common vars + vars.date = vars.date ?? formatDate(new Date()); + + const message = renderTemplate(rule.template, vars); + + // Send to target room + try { + await nextcloudService.sendMessage(rule.target_room_token, message, creds.username, creds.appPassword); + } catch (error) { + logger.error('scheduledMessages: failed to send to target room', { + ruleId: rule.id, + roomToken: rule.target_room_token, + error: error instanceof Error ? error.message : String(error), + }); + } + + // DM subscribers + const subs = await pool.query( + 'SELECT user_id, room_token FROM scheduled_message_subscriptions WHERE rule_id = $1', + [rule.id], + ); + + for (const sub of subs.rows) { + try { + await nextcloudService.sendMessage( + sub.room_token as string, + message, + creds.username, + creds.appPassword, + ); + } catch (error) { + logger.error('scheduledMessages: failed to DM subscriber', { + ruleId: rule.id, + userId: sub.user_id, + error: error instanceof Error ? error.message : String(error), + }); + } + } +} + +// ── Vehicle Event (fire-and-forget) ────────────────────────────────────────── + +async function sendVehicleEvent(vehicleId: string): Promise { + try { + const creds = await getBotCredentials(); + if (!creds) return; + + // Fetch vehicle info + const vResult = await pool.query( + 'SELECT bezeichnung, kurzname, status, status_bemerkung FROM fahrzeuge WHERE id = $1', + [vehicleId], + ); + if (vResult.rows.length === 0) return; + + const vehicle = vResult.rows[0]; + const name = (vehicle.kurzname as string) || (vehicle.bezeichnung as string); + const remark = vehicle.status_bemerkung ? ` – ${vehicle.status_bemerkung as string}` : ''; + + // Find active fahrzeug_event rules + const rulesResult = await pool.query( + `SELECT * FROM scheduled_message_rules + WHERE message_type = 'fahrzeug_event' + AND trigger_mode = 'event' + AND active = true`, + ); + + for (const rule of rulesResult.rows as ScheduledMessageRule[]) { + const vars: Record = { + items: `${name} (${vehicle.status as string})${remark}`, + count: '1', + date: formatDate(new Date()), + vehicle_name: name, + vehicle_status: vehicle.status as string, + vehicle_remark: (vehicle.status_bemerkung as string) || '', + }; + + const message = renderTemplate(rule.template, vars); + + try { + await nextcloudService.sendMessage(rule.target_room_token, message, creds.username, creds.appPassword); + } catch (error) { + logger.error('scheduledMessages: fahrzeug_event send failed', { + ruleId: rule.id, + vehicleId, + error: error instanceof Error ? error.message : String(error), + }); + } + + // DM subscribers + const subs = await pool.query( + 'SELECT user_id, room_token FROM scheduled_message_subscriptions WHERE rule_id = $1', + [rule.id], + ); + for (const sub of subs.rows) { + try { + await nextcloudService.sendMessage(sub.room_token as string, message, creds.username, creds.appPassword); + } catch (error) { + logger.error('scheduledMessages: fahrzeug_event DM failed', { + ruleId: rule.id, + userId: sub.user_id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + } catch (error) { + logger.error('scheduledMessages.sendVehicleEvent failed', { + vehicleId, + error: error instanceof Error ? error.message : String(error), + }); + } +} + +// ── Export ──────────────────────────────────────────────────────────────────── + +const scheduledMessagesService = { + getAll, + getById, + create, + update, + delete: remove, + getRooms, + subscribe, + unsubscribe, + getSubscriptionsForUser, + buildAndSend, + sendVehicleEvent, +}; + +export type { ScheduledMessageRule, RoomInfo, RoomsResult }; +export default scheduledMessagesService; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 271729d..653589c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -56,6 +56,10 @@ import Issues from './pages/Issues'; import IssueDetail from './pages/IssueDetail'; import IssueNeu from './pages/IssueNeu'; import Chat from './pages/Chat'; +import GeplanteMachrichten from './pages/GeplanteMachrichten'; +import GeplanteMachrichtenDetail from './pages/GeplanteMachrichtenDetail'; +import GeplanteMachrichtenNeu from './pages/GeplanteMachrichtenNeu'; +import GeplanteMachrichtenBearbeiten from './pages/GeplanteMachrichtenBearbeiten'; import AdminDashboard from './pages/AdminDashboard'; import AdminSettings from './pages/AdminSettings'; import NotFound from './pages/NotFound'; @@ -510,6 +514,38 @@ function App() { } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> issuesApi.deleteStatus(id), - onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-statuses'] }); showSuccess('Status deaktiviert'); }, - onError: () => showError('Fehler beim Deaktivieren'), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-statuses'] }); showSuccess('Status gelöscht'); }, + onError: () => showError('Fehler beim Löschen'), }); // ── Priority mutations ── @@ -153,8 +153,8 @@ export default function ModuleSettingsIssues() { }); const deletePrioMut = useMutation({ mutationFn: (id: number) => issuesApi.deletePriority(id), - onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-priorities'] }); showSuccess('Priorität deaktiviert'); }, - onError: () => showError('Fehler beim Deaktivieren'), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-priorities'] }); showSuccess('Priorität gelöscht'); }, + onError: () => showError('Fehler beim Löschen'), }); // ── Type mutations ── @@ -170,8 +170,8 @@ export default function ModuleSettingsIssues() { }); const deleteTypeMut = useMutation({ mutationFn: (id: number) => issuesApi.deleteType(id), - onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-types'] }); showSuccess('Kategorie deaktiviert'); }, - onError: () => showError('Fehler beim Deaktivieren'), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-types'] }); showSuccess('Kategorie gelöscht'); }, + onError: () => showError('Fehler beim Löschen'), }); const flatTypes = useMemo(() => { diff --git a/frontend/src/components/admin/ToolSettingsNextcloud.tsx b/frontend/src/components/admin/ToolSettingsNextcloud.tsx index 56606e0..11f1dc5 100644 --- a/frontend/src/components/admin/ToolSettingsNextcloud.tsx +++ b/frontend/src/components/admin/ToolSettingsNextcloud.tsx @@ -16,6 +16,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { usePermissionContext } from '../../contexts/PermissionContext'; import { useNotification } from '../../contexts/NotificationContext'; import { toolConfigApi } from '../../services/toolConfig'; +import type { ToolConfig } from '../../types/toolConfig.types'; +import { scheduledMessagesApi } from '../../services/scheduledMessages'; export default function ToolSettingsNextcloud() { const queryClient = useQueryClient(); @@ -28,11 +30,16 @@ export default function ToolSettingsNextcloud() { }); const [url, setUrl] = useState(''); + const [botUsername, setBotUsername] = useState(''); + const [botAppPassword, setBotAppPassword] = useState(''); const [testResult, setTestResult] = useState<{ success: boolean; message: string; latencyMs: number } | null>(null); useEffect(() => { if (data) { setUrl(data.url ?? ''); + const d = data as unknown as Record; + setBotUsername((d.bot_username as string) ?? ''); + setBotAppPassword((d.bot_app_password as string) ?? ''); } }, [data]); @@ -45,6 +52,19 @@ export default function ToolSettingsNextcloud() { onError: () => showError('Fehler beim Speichern der Konfiguration'), }); + const saveBotMutation = useMutation({ + mutationFn: () => + toolConfigApi.update('nextcloud', { + bot_username: botUsername, + bot_app_password: botAppPassword, + } as unknown as Partial), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['tool-config', 'nextcloud'] }); + showSuccess('Bot-Konfiguration gespeichert'); + }, + onError: () => showError('Fehler beim Speichern der Bot-Konfiguration'), + }); + const testMutation = useMutation({ mutationFn: () => toolConfigApi.test('nextcloud', { url: url || undefined }), @@ -61,58 +81,108 @@ export default function ToolSettingsNextcloud() { } return ( - - - Nextcloud - + + + + Nextcloud + - {!isFeatureEnabled('nextcloud') && ( - - Dieses Werkzeug befindet sich im Wartungsmodus. - - )} + {!isFeatureEnabled('nextcloud') && ( + + Dieses Werkzeug befindet sich im Wartungsmodus. + + )} - - setUrl(e.target.value)} - size="small" - fullWidth - placeholder="https://nextcloud.example.com" - /> - - - - - {testResult && ( - + + + + + {testResult && ( + + )} + + + + + Bot-Konto + + setBotUsername(e.target.value)} + fullWidth + size="small" + /> + setBotAppPassword(e.target.value)} + fullWidth + size="small" + /> + + + + + + + + ); } diff --git a/frontend/src/components/shared/Sidebar.tsx b/frontend/src/components/shared/Sidebar.tsx index b4e0e36..fa1e630 100644 --- a/frontend/src/components/shared/Sidebar.tsx +++ b/frontend/src/components/shared/Sidebar.tsx @@ -29,6 +29,7 @@ import { AssignmentTurnedIn, AccountBalance as AccountBalanceIcon, Checkroom as CheckroomIcon, + Schedule, } from '@mui/icons-material'; import { useNavigate, useLocation } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; @@ -134,6 +135,12 @@ const baseNavigationItems: NavigationItem[] = [ path: '/issues', permission: 'issues:view_own', }, + { + text: 'Geplante Nachrichten', + icon: , + path: '/geplante-nachrichten', + permission: 'scheduled_messages:view', + }, ]; const adminItem: NavigationItem = { diff --git a/frontend/src/constants/widgets.ts b/frontend/src/constants/widgets.ts index e40cb01..fa5415e 100644 --- a/frontend/src/constants/widgets.ts +++ b/frontend/src/constants/widgets.ts @@ -3,7 +3,6 @@ export const WIDGETS = [ { key: 'equipment', label: 'Ausrüstung', defaultVisible: true }, { key: 'atemschutz', label: 'Atemschutz', defaultVisible: true }, { key: 'events', label: 'Termine', defaultVisible: true }, - { key: 'nextcloudTalk', label: 'Nextcloud Talk', defaultVisible: true }, { key: 'bookstackRecent', label: 'Wissen — Neueste', defaultVisible: true }, { key: 'bookstackSearch', label: 'Wissen — Suche', defaultVisible: true }, { key: 'vikunjaTasks', label: 'Vikunja Aufgaben', defaultVisible: true }, diff --git a/frontend/src/pages/GeplanteMachrichten.tsx b/frontend/src/pages/GeplanteMachrichten.tsx new file mode 100644 index 0000000..ccb7bcf --- /dev/null +++ b/frontend/src/pages/GeplanteMachrichten.tsx @@ -0,0 +1,217 @@ +import { useState } from 'react'; +import { + Box, + Typography, + Button, + Chip, + Switch, + Alert, + Skeleton, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + IconButton, +} from '@mui/material'; +import { Add as AddIcon, Edit as EditIcon } from '@mui/icons-material'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { usePermissionContext } from '../contexts/PermissionContext'; +import { scheduledMessagesApi } from '../services/scheduledMessages'; +import type { MessageType, ScheduledMessageRule } from '../types/scheduledMessages.types'; + +const MESSAGE_TYPE_LABELS: Record = { + event_summary: 'Terminübersicht', + birthday_list: 'Geburtstagsliste', + dienstjubilaeen: 'Dienstjubiläen', + fahrzeug_status: 'Fahrzeugstatus', + fahrzeug_event: 'Fahrzeug außer Dienst', + bestellungen: 'Offene Bestellungen', +}; + +const TRIGGER_LABELS: Record = { + day_of_week: 'Wochentag', + days_before_month_start: 'Vor Monatsbeginn', + event: 'Ereignis', +}; + +const WEEKDAY_LABELS = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']; + +function triggerSummary(rule: ScheduledMessageRule): string { + if (rule.trigger_mode === 'event') return 'Bei Ereignis'; + if (rule.trigger_mode === 'day_of_week') { + const day = rule.day_of_week != null ? WEEKDAY_LABELS[rule.day_of_week] : '?'; + const time = rule.send_time ?? ''; + return `${day} ${time}`.trim(); + } + if (rule.trigger_mode === 'days_before_month_start') { + return `${rule.days_before_month_start ?? '?'} Tage vor Monatsbeginn`; + } + return TRIGGER_LABELS[rule.trigger_mode] ?? rule.trigger_mode; +} + +export default function GeplanteMachrichten() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { hasPermission } = usePermissionContext(); + const canEdit = hasPermission('scheduled_messages:edit'); + + const { data, isLoading, isError } = useQuery({ + queryKey: ['scheduled-messages'], + queryFn: scheduledMessagesApi.getAll, + }); + + const rules = data?.data ?? []; + + const [deleteConfirmId, setDeleteConfirmId] = useState(null); + + const handleToggleActive = async (rule: ScheduledMessageRule) => { + await scheduledMessagesApi.update(rule.id, { active: !rule.active }); + queryClient.invalidateQueries({ queryKey: ['scheduled-messages'] }); + }; + + const handleDelete = async (id: string) => { + await scheduledMessagesApi.delete(id); + setDeleteConfirmId(null); + queryClient.invalidateQueries({ queryKey: ['scheduled-messages'] }); + }; + + return ( + + + + Geplante Nachrichten + {canEdit && ( + + )} + + + {isError && ( + + Fehler beim Laden der Regeln. + + )} + + {isLoading ? ( + + + + + Name + Typ + Trigger + Zielraum + Abonnierbar + Aktiv + + + + + {[1, 2, 3].map((i) => ( + + {[1, 2, 3, 4, 5, 6, 7].map((j) => ( + + ))} + + ))} + +
+
+ ) : rules.length === 0 ? ( + + Keine Regeln konfiguriert + + ) : ( + + + + + Name + Typ + Trigger + Zielraum + Abonnierbar + Aktiv + + + + + {rules.map((rule) => ( + navigate(`/geplante-nachrichten/${rule.id}`)} + > + {rule.name} + + + + {triggerSummary(rule)} + {rule.target_room_name ?? rule.target_room_token} + {rule.subscribable ? 'Ja' : 'Nein'} + + e.stopPropagation()} + onChange={() => handleToggleActive(rule)} + size="small" + disabled={!canEdit} + /> + + + e.stopPropagation()}> + {canEdit && ( + navigate(`/geplante-nachrichten/${rule.id}/bearbeiten`)} + > + + + )} + {canEdit && ( + deleteConfirmId === rule.id ? ( + + ) : ( + + ) + )} + + + + ))} + +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/GeplanteMachrichtenBearbeiten.tsx b/frontend/src/pages/GeplanteMachrichtenBearbeiten.tsx new file mode 100644 index 0000000..96c6475 --- /dev/null +++ b/frontend/src/pages/GeplanteMachrichtenBearbeiten.tsx @@ -0,0 +1,7 @@ +import { useParams } from 'react-router-dom'; +import GeplanteMachrichtenForm from './GeplanteMachrichtenForm'; + +export default function GeplanteMachrichtenBearbeiten() { + const { id } = useParams<{ id: string }>(); + return ; +} diff --git a/frontend/src/pages/GeplanteMachrichtenDetail.tsx b/frontend/src/pages/GeplanteMachrichtenDetail.tsx new file mode 100644 index 0000000..85d545b --- /dev/null +++ b/frontend/src/pages/GeplanteMachrichtenDetail.tsx @@ -0,0 +1,252 @@ +import { + Box, + Typography, + Paper, + Button, + Chip, + Alert, + CircularProgress, + Divider, +} from '@mui/material'; +import { ArrowBack, Edit as EditIcon, Send as SendIcon } from '@mui/icons-material'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useParams, useNavigate } from 'react-router-dom'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { usePermissionContext } from '../contexts/PermissionContext'; +import { useAuth } from '../contexts/AuthContext'; +import { useNotification } from '../contexts/NotificationContext'; +import { scheduledMessagesApi } from '../services/scheduledMessages'; +import type { MessageType } from '../types/scheduledMessages.types'; + +const MESSAGE_TYPE_LABELS: Record = { + event_summary: 'Terminübersicht', + birthday_list: 'Geburtstagsliste', + dienstjubilaeen: 'Dienstjubiläen', + fahrzeug_status: 'Fahrzeugstatus', + fahrzeug_event: 'Fahrzeug außer Dienst', + bestellungen: 'Offene Bestellungen', +}; + +const TRIGGER_LABELS: Record = { + day_of_week: 'Wochentag + Uhrzeit', + days_before_month_start: 'N Tage vor Monatsbeginn', + event: 'Ereignis', +}; + +const WEEKDAY_LABELS = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']; + +const WINDOW_LABELS: Record = { + rolling: 'Rollierend', + calendar_month: 'Nächster Kalendermonat', +}; + +export default function GeplanteMachrichtenDetail() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { hasPermission } = usePermissionContext(); + const { user } = useAuth(); + const { showSuccess, showError } = useNotification(); + const canEdit = hasPermission('scheduled_messages:edit'); + const canSubscribe = hasPermission('scheduled_messages:subscribe'); + + const { data, isLoading, isError } = useQuery({ + queryKey: ['scheduled-messages', id], + queryFn: () => scheduledMessagesApi.getById(id!), + enabled: !!id, + }); + + const rule = data?.data; + + const triggerMutation = useMutation({ + mutationFn: () => scheduledMessagesApi.trigger(id!), + onSuccess: () => showSuccess('Nachricht wurde gesendet'), + onError: (err: unknown) => { + const msg = err instanceof Error ? err.message : 'Fehler beim Senden'; + showError(msg); + }, + }); + + const canUserSubscribe = + canSubscribe && + rule?.subscribable && + (rule.allowed_groups.length === 0 || + rule.allowed_groups.some((g) => user?.groups?.includes(g))); + + const handleSubscribe = async () => { + if (!id) return; + await scheduledMessagesApi.subscribe(id, ''); + queryClient.invalidateQueries({ queryKey: ['scheduled-messages', id] }); + }; + + const handleUnsubscribe = async () => { + if (!id) return; + await scheduledMessagesApi.unsubscribe(id); + queryClient.invalidateQueries({ queryKey: ['scheduled-messages', id] }); + }; + + const formatDate = (iso: string | null) => { + if (!iso) return 'Noch nie gesendet'; + return new Date(iso).toLocaleString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + return ( + + + + + {canEdit && rule && ( + + + + + )} + + + {isError && ( + Fehler beim Laden der Regel. + )} + + {isLoading && ( + + + + )} + + {rule && ( + + {rule.name} + + + + Typ + + + Aktiv + + + Trigger + {TRIGGER_LABELS[rule.trigger_mode] ?? rule.trigger_mode} + + {rule.trigger_mode === 'day_of_week' && ( + <> + Wochentag + + {rule.day_of_week != null ? WEEKDAY_LABELS[rule.day_of_week] : '-'} + + Uhrzeit + {rule.send_time ?? '-'} + + )} + + {rule.trigger_mode === 'days_before_month_start' && ( + <> + Tage vor Monatsbeginn + {rule.days_before_month_start ?? '-'} + + )} + + {rule.trigger_mode !== 'event' && ( + <> + Zeitfenster + + {rule.window_mode ? WINDOW_LABELS[rule.window_mode] ?? rule.window_mode : '-'} + {rule.window_mode === 'rolling' && rule.window_days != null + ? ` (${rule.window_days} Tage)` + : ''} + + + )} + + Zielraum + {rule.target_room_name ?? rule.target_room_token} + + Abonnierbar + {rule.subscribable ? 'Ja' : 'Nein'} + + {rule.allowed_groups.length > 0 && ( + <> + Gruppen + + {rule.allowed_groups.map((g) => ( + + ))} + + + )} + + Zuletzt gesendet + {formatDate(rule.last_sent_at)} + + {canEdit && rule.subscriber_count != null && ( + <> + Abonnenten + {rule.subscriber_count} + + )} + + + + Vorlage + + {rule.template} + + + {canUserSubscribe && ( + + {rule.is_subscribed ? ( + + ) : ( + + )} + + )} + + )} + + + ); +} diff --git a/frontend/src/pages/GeplanteMachrichtenForm.tsx b/frontend/src/pages/GeplanteMachrichtenForm.tsx new file mode 100644 index 0000000..5cf8502 --- /dev/null +++ b/frontend/src/pages/GeplanteMachrichtenForm.tsx @@ -0,0 +1,426 @@ +import { useState, useEffect, useRef } from 'react'; +import { + Box, + Typography, + TextField, + Button, + Select, + MenuItem, + FormControl, + FormLabel, + RadioGroup, + FormControlLabel, + Radio, + Switch, + Chip, + Paper, + Alert, + CircularProgress, + Autocomplete, +} from '@mui/material'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import DashboardLayout from '../components/dashboard/DashboardLayout'; +import { useNotification } from '../contexts/NotificationContext'; +import { scheduledMessagesApi } from '../services/scheduledMessages'; +import type { MessageType, TriggerMode, WindowMode } from '../types/scheduledMessages.types'; + +const MESSAGE_TYPE_LABELS: Record = { + event_summary: 'Terminübersicht', + birthday_list: 'Geburtstagsliste', + dienstjubilaeen: 'Dienstjubiläen', + fahrzeug_status: 'Fahrzeugstatus', + fahrzeug_event: 'Fahrzeug außer Dienst', + bestellungen: 'Offene Bestellungen', +}; + +const MESSAGE_TYPE_OPTIONS: MessageType[] = [ + 'event_summary', + 'birthday_list', + 'dienstjubilaeen', + 'fahrzeug_status', + 'fahrzeug_event', + 'bestellungen', +]; + +const WEEKDAY_OPTIONS = [ + { value: 0, label: 'Sonntag' }, + { value: 1, label: 'Montag' }, + { value: 2, label: 'Dienstag' }, + { value: 3, label: 'Mittwoch' }, + { value: 4, label: 'Donnerstag' }, + { value: 5, label: 'Freitag' }, + { value: 6, label: 'Samstag' }, +]; + +const PLACEHOLDER_MAP: Record = { + event_summary: ['{{items}}', '{{count}}', '{{date_range}}'], + birthday_list: ['{{items}}', '{{count}}'], + dienstjubilaeen: ['{{items}}', '{{count}}'], + fahrzeug_status: ['{{items}}', '{{count}}'], + fahrzeug_event: ['{{vehicle}}', '{{event}}', '{{date}}'], + bestellungen: ['{{items}}', '{{count}}'], +}; + +interface GeplanteMachrichtenFormProps { + ruleId?: string; +} + +export default function GeplanteMachrichtenForm({ ruleId }: GeplanteMachrichtenFormProps) { + const navigate = useNavigate(); + const { showSuccess, showError } = useNotification(); + + const [name, setName] = useState(''); + const [messageType, setMessageType] = useState('event_summary'); + const [triggerMode, setTriggerMode] = useState('day_of_week'); + const [dayOfWeek, setDayOfWeek] = useState(1); + const [sendTime, setSendTime] = useState('08:00'); + const [daysBeforeMonthStart, setDaysBeforeMonthStart] = useState(3); + const [windowMode, setWindowMode] = useState('rolling'); + const [windowDays, setWindowDays] = useState(7); + const [targetRoomToken, setTargetRoomToken] = useState(''); + const [template, setTemplate] = useState(''); + const [minDaysOverdue, setMinDaysOverdue] = useState(14); + const [subscribable, setSubscribable] = useState(false); + const [allowedGroups, setAllowedGroups] = useState([]); + const [active, setActive] = useState(true); + + const templateRef = useRef(null); + + const { data: existingRule, isLoading: ruleLoading } = useQuery({ + queryKey: ['scheduled-messages', ruleId], + queryFn: () => scheduledMessagesApi.getById(ruleId!), + enabled: !!ruleId, + }); + + const { data: roomsData, isLoading: roomsLoading } = useQuery({ + queryKey: ['scheduled-messages-rooms'], + queryFn: scheduledMessagesApi.getRooms, + }); + + useEffect(() => { + if (existingRule?.data) { + const r = existingRule.data; + setName(r.name); + setMessageType(r.message_type); + setTriggerMode(r.trigger_mode); + setDayOfWeek(r.day_of_week ?? 1); + setSendTime(r.send_time ?? '08:00'); + setDaysBeforeMonthStart(r.days_before_month_start ?? 3); + setWindowMode(r.window_mode ?? 'rolling'); + setWindowDays(r.window_days ?? 7); + setTargetRoomToken(r.target_room_token); + setTemplate(r.template); + setMinDaysOverdue((r.extra_config?.min_days_overdue as number) ?? 14); + setSubscribable(r.subscribable); + setAllowedGroups(r.allowed_groups); + setActive(r.active); + } + }, [existingRule]); + + const insertPlaceholder = (placeholder: string) => { + const el = templateRef.current?.querySelector('textarea') as HTMLTextAreaElement | null; + if (!el) return; + const start = el.selectionStart ?? template.length; + const end = el.selectionEnd ?? template.length; + const newVal = template.slice(0, start) + placeholder + template.slice(end); + setTemplate(newVal); + setTimeout(() => { + el.setSelectionRange(start + placeholder.length, start + placeholder.length); + el.focus(); + }, 0); + }; + + const saveMutation = useMutation({ + mutationFn: () => { + const payload: Record = { + name, + message_type: messageType, + trigger_mode: messageType === 'fahrzeug_event' ? 'event' : triggerMode, + day_of_week: triggerMode === 'day_of_week' && messageType !== 'fahrzeug_event' ? dayOfWeek : null, + send_time: triggerMode === 'day_of_week' && messageType !== 'fahrzeug_event' ? sendTime : null, + days_before_month_start: + triggerMode === 'days_before_month_start' && messageType !== 'fahrzeug_event' + ? daysBeforeMonthStart + : null, + window_mode: messageType === 'fahrzeug_event' ? null : windowMode, + window_days: messageType === 'fahrzeug_event' ? null : windowMode === 'rolling' ? windowDays : null, + target_room_token: targetRoomToken, + template, + extra_config: messageType === 'bestellungen' ? { min_days_overdue: minDaysOverdue } : null, + subscribable, + allowed_groups: allowedGroups, + active, + }; + if (ruleId) { + return scheduledMessagesApi.update(ruleId, payload); + } + return scheduledMessagesApi.create(payload); + }, + onSuccess: (result) => { + showSuccess(ruleId ? 'Regel aktualisiert' : 'Regel erstellt'); + navigate(`/geplante-nachrichten/${result.data.id}`); + }, + onError: () => showError('Fehler beim Speichern'), + }); + + const isEventType = messageType === 'fahrzeug_event'; + const rooms = roomsData?.data ?? []; + const roomsConfigured = roomsData?.configured ?? false; + + if (ruleId && ruleLoading) { + return ( + + + + + + ); + } + + return ( + + + + {ruleId ? 'Regel bearbeiten' : 'Neue Regel'} + + + + {/* Name */} + + setName(e.target.value)} + fullWidth + size="small" + required + /> + + + {/* Message Type */} + + + Nachrichtentyp + + + + + {/* Trigger */} + {isEventType ? ( + + Dieser Nachrichtentyp wird automatisch bei Ereignissen ausgelöst. + + ) : ( + + + Auslöser + setTriggerMode(e.target.value as TriggerMode)} + > + } label="Wochentag + Uhrzeit" /> + } label="N Tage vor Monatsbeginn" /> + + + + {triggerMode === 'day_of_week' && ( + + + + + setSendTime(e.target.value)} + size="small" + InputLabelProps={{ shrink: true }} + /> + + )} + + {triggerMode === 'days_before_month_start' && ( + + setDaysBeforeMonthStart(Number(e.target.value))} + size="small" + inputProps={{ min: 0, max: 28 }} + /> + + )} + + )} + + {/* Window */} + {!isEventType && ( + + + Zeitfenster + setWindowMode(e.target.value as WindowMode)} + > + } label="Rollierend (N Tage)" /> + } label="Nächster Kalendermonat" /> + + + + {windowMode === 'rolling' && ( + + setWindowDays(Number(e.target.value))} + size="small" + inputProps={{ min: 1, max: 365 }} + /> + + )} + + )} + + {/* Target Room */} + + + Zielraum + {roomsLoading ? ( + + ) : !roomsConfigured ? ( + + Bot-Konto nicht konfiguriert. Bitte in den Admin-Einstellungen unter Nextcloud konfigurieren. + + ) : ( + + )} + + + + {/* Template */} + + Vorlage + setTemplate(e.target.value)} + multiline + minRows={4} + maxRows={12} + fullWidth + size="small" + /> + + {(PLACEHOLDER_MAP[messageType] ?? []).map((ph) => ( + insertPlaceholder(ph)} + sx={{ cursor: 'pointer' }} + /> + ))} + + + + {/* Min days overdue (bestellungen only) */} + {messageType === 'bestellungen' && ( + + setMinDaysOverdue(Number(e.target.value))} + size="small" + inputProps={{ min: 0 }} + /> + + )} + + {/* Subscribable */} + + setSubscribable(e.target.checked)} /> + } + label="Abonnierbar" + /> + {subscribable && ( + + Erlaubte Gruppen (leer = alle) + setAllowedGroups(val)} + renderTags={(value, getTagProps) => + value.map((option, index) => { + const { key, ...tagProps } = getTagProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + /> + + )} + + + {/* Active */} + + setActive(e.target.checked)} />} + label="Aktiv" + /> + + + {/* Actions */} + + + + + + + + ); +} diff --git a/frontend/src/pages/GeplanteMachrichtenNeu.tsx b/frontend/src/pages/GeplanteMachrichtenNeu.tsx new file mode 100644 index 0000000..7bf85c4 --- /dev/null +++ b/frontend/src/pages/GeplanteMachrichtenNeu.tsx @@ -0,0 +1,5 @@ +import GeplanteMachrichtenForm from './GeplanteMachrichtenForm'; + +export default function GeplanteMachrichtenNeu() { + return ; +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 4d27226..ec05c8e 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -21,6 +21,9 @@ import { ListItemText, } from '@mui/material'; import { Settings as SettingsIcon, Notifications, Palette, Language, SettingsBrightness, LightMode, DarkMode, Widgets, Cloud, LinkOff, Forum, Person, OpenInNew, Sort, Restore, DragIndicator } from '@mui/icons-material'; +import { + Schedule as ScheduleIcon, +} from '@mui/icons-material'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { @@ -43,9 +46,20 @@ import { useThemeMode } from '../contexts/ThemeContext'; import { preferencesApi } from '../services/settings'; import { WIDGETS, WidgetKey } from '../constants/widgets'; import { nextcloudApi } from '../services/nextcloud'; +import { scheduledMessagesApi } from '../services/scheduledMessages'; import { useNotification } from '../contexts/NotificationContext'; import { useAuth } from '../contexts/AuthContext'; import { usePermissionContext } from '../contexts/PermissionContext'; +import type { MessageType } from '../types/scheduledMessages.types'; + +const MESSAGE_TYPE_LABELS: Record = { + event_summary: 'Terminübersicht', + birthday_list: 'Geburtstagsliste', + dienstjubilaeen: 'Dienstjubiläen', + fahrzeug_status: 'Fahrzeugstatus', + fahrzeug_event: 'Fahrzeug außer Dienst', + bestellungen: 'Offene Bestellungen', +}; const POLL_INTERVAL = 2000; const POLL_TIMEOUT = 5 * 60 * 1000; @@ -58,12 +72,14 @@ const ORDERABLE_NAV_ITEMS = [ { text: 'Fahrzeugbuchungen', path: '/fahrzeugbuchungen', permission: 'fahrzeugbuchungen:view' }, { text: 'Fahrzeuge', path: '/fahrzeuge', permission: 'fahrzeuge:view' }, { text: 'Ausrüstung', path: '/ausruestung', permission: 'ausruestung:view' }, + { text: 'Pers. Ausrüstung', path: '/persoenliche-ausruestung', permission: 'persoenliche_ausruestung:view' }, { text: 'Mitglieder', path: '/mitglieder', permission: 'mitglieder:view_own' }, { text: 'Atemschutz', path: '/atemschutz', permission: 'atemschutz:view' }, { text: 'Wissen', path: '/wissen', permission: 'wissen:view' }, { text: 'Bestellungen', path: '/bestellungen', permission: 'bestellungen:view' }, { text: 'Interne Bestellungen', path: '/ausruestungsanfrage', permission: 'ausruestungsanfrage:view' }, { text: 'Checklisten', path: '/checklisten', permission: 'checklisten:view' }, + { text: 'Buchhaltung', path: '/buchhaltung', permission: 'buchhaltung:view' }, { text: 'Issues', path: '/issues', permission: 'issues:view_own' }, ]; @@ -94,6 +110,78 @@ function SortableNavItem({ id, text }: { id: string; text: string }) { ); } +function SubscriptionsCard() { + const queryClient = useQueryClient(); + const { user } = useAuth(); + + const { data, isLoading } = useQuery({ + queryKey: ['scheduled-messages'], + queryFn: scheduledMessagesApi.getAll, + }); + + const rules = (data?.data ?? []).filter( + (rule) => + rule.subscribable && + (rule.allowed_groups.length === 0 || + rule.allowed_groups.some((g) => user?.groups?.includes(g))), + ); + + const handleToggle = async (ruleId: string, isSubscribed: boolean) => { + if (isSubscribed) { + await scheduledMessagesApi.unsubscribe(ruleId); + } else { + await scheduledMessagesApi.subscribe(ruleId, ''); + } + queryClient.invalidateQueries({ queryKey: ['scheduled-messages'] }); + }; + + return ( + + + + + + Nachrichten-Abonnements + + + {isLoading ? ( + + + + ) : rules.length === 0 ? ( + + Keine abonnierbaren Nachrichten verfügbar. + + ) : ( + + {rules.map((rule) => ( + + handleToggle(rule.id, !!rule.is_subscribed)} + size="small" + /> + } + label={rule.name} + sx={{ flex: 1 }} + /> + + + ))} + + )} + + + + ); +} + function Settings() { const { themeMode, setThemeMode } = useThemeMode(); const queryClient = useQueryClient(); @@ -551,6 +639,11 @@ function Settings() {
+ + {/* Nachrichten-Abonnements */} + {hasPermission('scheduled_messages:subscribe') && ( + + )} diff --git a/frontend/src/services/scheduledMessages.ts b/frontend/src/services/scheduledMessages.ts new file mode 100644 index 0000000..e30f62b --- /dev/null +++ b/frontend/src/services/scheduledMessages.ts @@ -0,0 +1,36 @@ +import { api } from './api'; +import type { + ScheduledMessageRule, + ScheduledMessagesListResponse, + ScheduledMessageDetailResponse, + RoomsResponse, +} from '../types/scheduledMessages.types'; + +export const scheduledMessagesApi = { + getAll: () => + api.get('/scheduled-messages').then(r => r.data), + + getById: (id: string) => + api.get(`/scheduled-messages/${id}`).then(r => r.data), + + getRooms: () => + api.get('/scheduled-messages/rooms').then(r => r.data), + + create: (data: Partial) => + api.post('/scheduled-messages', data).then(r => r.data), + + update: (id: string, data: Partial) => + api.patch(`/scheduled-messages/${id}`, data).then(r => r.data), + + delete: (id: string) => + api.delete(`/scheduled-messages/${id}`).then(r => r.data), + + subscribe: (id: string, roomToken: string) => + api.post(`/scheduled-messages/${id}/subscribe`, { room_token: roomToken }).then(r => r.data), + + unsubscribe: (id: string) => + api.delete(`/scheduled-messages/${id}/subscribe`).then(r => r.data), + + trigger: (id: string) => + api.post(`/scheduled-messages/${id}/trigger`).then(r => r.data), +}; diff --git a/frontend/src/types/scheduledMessages.types.ts b/frontend/src/types/scheduledMessages.types.ts new file mode 100644 index 0000000..012a816 --- /dev/null +++ b/frontend/src/types/scheduledMessages.types.ts @@ -0,0 +1,52 @@ +export type MessageType = + | 'event_summary' + | 'birthday_list' + | 'dienstjubilaeen' + | 'fahrzeug_status' + | 'fahrzeug_event' + | 'bestellungen'; + +export type TriggerMode = 'day_of_week' | 'days_before_month_start' | 'event'; +export type WindowMode = 'rolling' | 'calendar_month'; + +export interface ScheduledMessageRule { + id: string; + name: string; + message_type: MessageType; + trigger_mode: TriggerMode; + day_of_week: number | null; + send_time: string | null; + days_before_month_start: number | null; + window_mode: WindowMode | null; + window_days: number | null; + target_room_token: string; + target_room_name: string | null; + template: string; + extra_config: Record | null; + subscribable: boolean; + allowed_groups: string[]; + last_sent_at: string | null; + active: boolean; + created_at: string; + subscriber_count?: number; + is_subscribed?: boolean; +} + +export interface NextcloudRoom { + token: string; + displayName: string; + type: number; +} + +export interface ScheduledMessagesListResponse { + data: ScheduledMessageRule[]; +} + +export interface ScheduledMessageDetailResponse { + data: ScheduledMessageRule; +} + +export interface RoomsResponse { + configured: boolean; + data?: NextcloudRoom[]; +}