From d8afcc1f63f0ec5b51dc6a8529ee454198952855 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Fri, 17 Apr 2026 12:33:48 +0200 Subject: [PATCH] fix(geplante-nachrichten): add /api prefix to all API paths, fix subscribe room token, unmask empty bot credentials, add Einzelnachrichten tab --- .../scheduledMessages.controller.ts | 27 +- .../src/controllers/toolConfig.controller.ts | 2 +- .../src/routes/scheduledMessages.routes.ts | 9 +- .../src/services/scheduledMessages.service.ts | 33 +- frontend/src/pages/GeplanteMachrichten.tsx | 452 +++++++++++++----- .../src/pages/GeplanteMachrichtenDetail.tsx | 9 +- frontend/src/pages/Settings.tsx | 8 +- frontend/src/services/scheduledMessages.ts | 27 +- 8 files changed, 429 insertions(+), 138 deletions(-) diff --git a/backend/src/controllers/scheduledMessages.controller.ts b/backend/src/controllers/scheduledMessages.controller.ts index 11fbca4..6038d91 100644 --- a/backend/src/controllers/scheduledMessages.controller.ts +++ b/backend/src/controllers/scheduledMessages.controller.ts @@ -88,12 +88,12 @@ class ScheduledMessagesController { 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' }); + const { room_token } = req.body as Record; + if (!room_token) { + res.status(400).json({ success: false, message: 'room_token ist erforderlich' }); return; } - await scheduledMessagesService.subscribe(id, userId, roomToken); + await scheduledMessagesService.subscribe(id, userId, room_token); res.json({ success: true }); } catch (error) { logger.error('ScheduledMessagesController.subscribe error', { error, id: req.params.id }); @@ -101,6 +101,25 @@ class ScheduledMessagesController { } } + async getMyBotRoom(req: Request, res: Response): Promise { + try { + const userId = req.user!.id; + const result = await scheduledMessagesService.getOrCreateBotRoom(userId); + if (result === null) { + res.status(400).json({ success: false, configured: false, message: 'Bot nicht konfiguriert' }); + return; + } + if (result === 'not_connected') { + res.status(400).json({ success: false, connected: false, message: 'Nextcloud-Konto nicht verbunden' }); + return; + } + res.json({ success: true, data: { room_token: result } }); + } catch (error) { + logger.error('ScheduledMessagesController.getMyBotRoom error', { error }); + res.status(500).json({ success: false, message: 'Bot-Raum konnte nicht ermittelt werden' }); + } + } + async unsubscribe(req: Request, res: Response): Promise { try { const { id } = req.params as Record; diff --git a/backend/src/controllers/toolConfig.controller.ts b/backend/src/controllers/toolConfig.controller.ts index 43be871..8f4836c 100644 --- a/backend/src/controllers/toolConfig.controller.ts +++ b/backend/src/controllers/toolConfig.controller.ts @@ -20,7 +20,7 @@ function maskValue(value: string): string { function maskConfig(config: Record): Record { const result: Record = {}; for (const [k, v] of Object.entries(config)) { - result[k] = MASKED_FIELDS.includes(k) ? maskValue(v) : v; + result[k] = (MASKED_FIELDS.includes(k) && v) ? maskValue(v) : v; } return result; } diff --git a/backend/src/routes/scheduledMessages.routes.ts b/backend/src/routes/scheduledMessages.routes.ts index e97ef06..9e5af6a 100644 --- a/backend/src/routes/scheduledMessages.routes.ts +++ b/backend/src/routes/scheduledMessages.routes.ts @@ -5,7 +5,7 @@ import { requirePermission } from '../middleware/rbac.middleware'; const router = Router(); -// /rooms and /one-time MUST come before /:id to avoid being captured as an id param +// /rooms, /my-bot-room and /one-time MUST come before /:id to avoid being captured as an id param router.get( '/rooms', authenticate, @@ -13,6 +13,13 @@ router.get( scheduledMessagesController.getRooms.bind(scheduledMessagesController), ); +router.get( + '/my-bot-room', + authenticate, + requirePermission('scheduled_messages:subscribe'), + scheduledMessagesController.getMyBotRoom.bind(scheduledMessagesController), +); + router.get( '/one-time', authenticate, diff --git a/backend/src/services/scheduledMessages.service.ts b/backend/src/services/scheduledMessages.service.ts index f684dd7..3bb36a8 100644 --- a/backend/src/services/scheduledMessages.service.ts +++ b/backend/src/services/scheduledMessages.service.ts @@ -521,7 +521,37 @@ async function sendVehicleEvent(vehicleId: string): Promise { } } -// ── One-Time Messages ───────────────────────────────────────────────────────── +// ── Bot Room ────────────────────────────────────────────────────────────────── + +/** + * Finds or creates a 1:1 Nextcloud Talk room between the bot and the given user. + * Returns the room token, null if bot isn't configured, or 'not_connected' if the + * user hasn't linked their Nextcloud account. + */ +async function getOrCreateBotRoom(userId: string): Promise { + const creds = await getBotCredentials(); + if (!creds) return null; + + // Look up the user's Nextcloud login name + const userResult = await pool.query( + 'SELECT nextcloud_login_name FROM users WHERE id = $1', + [userId], + ); + const nextcloudLoginName = userResult.rows[0]?.nextcloud_login_name as string | null; + if (!nextcloudLoginName) return 'not_connected'; + + // Use createRoom(type=1) — Nextcloud returns existing 1:1 if it already exists + const { token } = await nextcloudService.createRoom( + 1, + nextcloudLoginName, + undefined, + creds.username, + creds.appPassword, + ); + return token; +} + + interface OneTimeMessage { id: string; @@ -598,6 +628,7 @@ const scheduledMessagesService = { getSubscriptionsForUser, buildAndSend, sendVehicleEvent, + getOrCreateBotRoom, getOneTimeMessages, createOneTimeMessage, deleteOneTimeMessage, diff --git a/frontend/src/pages/GeplanteMachrichten.tsx b/frontend/src/pages/GeplanteMachrichten.tsx index ccb7bcf..0ebbb1f 100644 --- a/frontend/src/pages/GeplanteMachrichten.tsx +++ b/frontend/src/pages/GeplanteMachrichten.tsx @@ -15,12 +15,22 @@ import { TableRow, Paper, IconButton, + Tab, + Tabs, + TextField, + Select, + MenuItem, + FormControl, + FormLabel, + CircularProgress, + Divider, } from '@mui/material'; -import { Add as AddIcon, Edit as EditIcon } from '@mui/icons-material'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon, Send as SendIcon } from '@mui/icons-material'; +import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import { usePermissionContext } from '../contexts/PermissionContext'; +import { useNotification } from '../contexts/NotificationContext'; import { scheduledMessagesApi } from '../services/scheduledMessages'; import type { MessageType, ScheduledMessageRule } from '../types/scheduledMessages.types'; @@ -54,12 +64,211 @@ function triggerSummary(rule: ScheduledMessageRule): string { return TRIGGER_LABELS[rule.trigger_mode] ?? rule.trigger_mode; } +function formatSendAt(iso: string): string { + return new Date(iso).toLocaleString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function EinzelNachrichtenTab() { + const queryClient = useQueryClient(); + const { showSuccess, showError } = useNotification(); + + const { data: messagesData, isLoading: messagesLoading, isError: messagesError } = useQuery({ + queryKey: ['scheduled-one-time-messages'], + queryFn: scheduledMessagesApi.getOneTimeMessages, + }); + + const { data: roomsData, isLoading: roomsLoading } = useQuery({ + queryKey: ['scheduled-messages-rooms'], + queryFn: scheduledMessagesApi.getRooms, + }); + + const [message, setMessage] = useState(''); + const [roomToken, setRoomToken] = useState(''); + const [sendAt, setSendAt] = useState(''); + + const rooms = roomsData?.data ?? []; + const roomsConfigured = roomsData?.configured ?? false; + const roomsErr = roomsData?.error; + + const createMutation = useMutation({ + mutationFn: () => { + const room = rooms.find(r => r.token === roomToken); + return scheduledMessagesApi.createOneTimeMessage({ + message, + target_room_token: roomToken, + target_room_name: room?.displayName, + send_at: new Date(sendAt).toISOString(), + }); + }, + onSuccess: () => { + showSuccess('Einzelnachricht geplant'); + setMessage(''); + setRoomToken(''); + setSendAt(''); + queryClient.invalidateQueries({ queryKey: ['scheduled-one-time-messages'] }); + }, + onError: () => showError('Fehler beim Speichern'), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => scheduledMessagesApi.deleteOneTimeMessage(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['scheduled-one-time-messages'] }); + }, + onError: () => showError('Fehler beim Löschen'), + }); + + const canCreate = !!message.trim() && !!roomToken && !!sendAt; + + return ( + + + Neue Einzelnachricht + + + {/* Room picker */} + + + Zielraum + {roomsLoading ? ( + + ) : !roomsConfigured ? ( + + Bot-Konto nicht konfiguriert. Bitte in den Admin-Einstellungen unter Nextcloud konfigurieren. + + ) : roomsErr ? ( + + Nextcloud nicht erreichbar — Raumliste kann nicht geladen werden. + + ) : ( + + )} + + + + {/* Send time */} + + setSendAt(e.target.value)} + size="small" + InputLabelProps={{ shrink: true }} + /> + + + {/* Message */} + + setMessage(e.target.value)} + multiline + minRows={3} + maxRows={8} + fullWidth + size="small" + /> + + + + + + {/* Pending messages list */} + Ausstehende Einzelnachrichten + + {messagesError && ( + Fehler beim Laden der Nachrichten. + )} + + {messagesLoading ? ( + + + + {[1, 2].map((i) => ( + + {[1, 2, 3, 4].map((j) => )} + + ))} + +
+
+ ) : !messagesData?.data?.length ? ( + + Keine ausstehenden Einzelnachrichten. + + ) : ( + + + + + Nachricht + Zielraum + Sendezeitpunkt + + + + + {messagesData.data.map((msg) => ( + + + {msg.message} + + {msg.target_room_name ?? msg.target_room_token} + {formatSendAt(msg.send_at)} + + deleteMutation.mutate(msg.id)} + disabled={deleteMutation.isPending} + > + + + + + ))} + +
+
+ )} +
+ ); +} + export default function GeplanteMachrichten() { const navigate = useNavigate(); const queryClient = useQueryClient(); const { hasPermission } = usePermissionContext(); const canEdit = hasPermission('scheduled_messages:edit'); + const [tab, setTab] = useState(0); + const { data, isLoading, isError } = useQuery({ queryKey: ['scheduled-messages'], queryFn: scheduledMessagesApi.getAll, @@ -85,7 +294,7 @@ export default function GeplanteMachrichten() { Geplante Nachrichten - {canEdit && ( + {canEdit && tab === 0 && (