fix(geplante-nachrichten): add /api prefix to all API paths, fix subscribe room token, unmask empty bot credentials, add Einzelnachrichten tab

This commit is contained in:
Matthias Hochmeister
2026-04-17 12:33:48 +02:00
parent fcca04cc39
commit d8afcc1f63
8 changed files with 429 additions and 138 deletions

View File

@@ -88,12 +88,12 @@ class ScheduledMessagesController {
try {
const { id } = req.params as Record<string, string>;
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<string, string>;
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<void> {
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<void> {
try {
const { id } = req.params as Record<string, string>;

View File

@@ -20,7 +20,7 @@ function maskValue(value: string): string {
function maskConfig(config: Record<string, string>): Record<string, string> {
const result: Record<string, string> = {};
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;
}

View File

@@ -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,

View File

@@ -521,7 +521,37 @@ async function sendVehicleEvent(vehicleId: string): Promise<void> {
}
}
// ── 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<string | null | 'not_connected'> {
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,

View File

@@ -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 (
<Box>
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="subtitle1" gutterBottom fontWeight={500}>Neue Einzelnachricht</Typography>
<Divider sx={{ mb: 2 }} />
{/* Room picker */}
<Box sx={{ mb: 2 }}>
<FormControl fullWidth size="small">
<FormLabel sx={{ mb: 0.5 }}>Zielraum</FormLabel>
{roomsLoading ? (
<CircularProgress size={20} />
) : !roomsConfigured ? (
<Alert severity="warning" sx={{ mt: 1 }}>
Bot-Konto nicht konfiguriert. Bitte in den Admin-Einstellungen unter Nextcloud konfigurieren.
</Alert>
) : roomsErr ? (
<Alert severity="error" sx={{ mt: 1 }}>
Nextcloud nicht erreichbar Raumliste kann nicht geladen werden.
</Alert>
) : (
<Select
value={roomToken}
onChange={(e) => setRoomToken(e.target.value)}
displayEmpty
>
<MenuItem value="" disabled>Raum auswählen...</MenuItem>
{rooms.map((room) => (
<MenuItem key={room.token} value={room.token}>
{room.displayName}
</MenuItem>
))}
</Select>
)}
</FormControl>
</Box>
{/* Send time */}
<Box sx={{ mb: 2 }}>
<TextField
label="Sendezeitpunkt"
type="datetime-local"
value={sendAt}
onChange={(e) => setSendAt(e.target.value)}
size="small"
InputLabelProps={{ shrink: true }}
/>
</Box>
{/* Message */}
<Box sx={{ mb: 2 }}>
<TextField
label="Nachricht"
value={message}
onChange={(e) => setMessage(e.target.value)}
multiline
minRows={3}
maxRows={8}
fullWidth
size="small"
/>
</Box>
<Button
variant="contained"
startIcon={createMutation.isPending ? <CircularProgress size={16} /> : <SendIcon />}
onClick={() => createMutation.mutate()}
disabled={!canCreate || createMutation.isPending || !roomsConfigured}
>
Planen
</Button>
</Paper>
{/* Pending messages list */}
<Typography variant="subtitle1" gutterBottom fontWeight={500}>Ausstehende Einzelnachrichten</Typography>
{messagesError && (
<Alert severity="error" sx={{ mb: 2 }}>Fehler beim Laden der Nachrichten.</Alert>
)}
{messagesLoading ? (
<TableContainer component={Paper}>
<Table size="small">
<TableBody>
{[1, 2].map((i) => (
<TableRow key={i}>
{[1, 2, 3, 4].map((j) => <TableCell key={j}><Skeleton /></TableCell>)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
) : !messagesData?.data?.length ? (
<Typography color="text.secondary" sx={{ py: 2 }}>
Keine ausstehenden Einzelnachrichten.
</Typography>
) : (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Nachricht</TableCell>
<TableCell>Zielraum</TableCell>
<TableCell>Sendezeitpunkt</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{messagesData.data.map((msg) => (
<TableRow key={msg.id}>
<TableCell sx={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{msg.message}
</TableCell>
<TableCell>{msg.target_room_name ?? msg.target_room_token}</TableCell>
<TableCell>{formatSendAt(msg.send_at)}</TableCell>
<TableCell padding="checkbox">
<IconButton
size="small"
color="error"
onClick={() => deleteMutation.mutate(msg.id)}
disabled={deleteMutation.isPending}
>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
);
}
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() {
<Box sx={{ p: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h5">Geplante Nachrichten</Typography>
{canEdit && (
{canEdit && tab === 0 && (
<Button
variant="contained"
startIcon={<AddIcon />}
@@ -96,121 +305,132 @@ export default function GeplanteMachrichten() {
)}
</Box>
{isError && (
<Alert severity="error" sx={{ mb: 2 }}>
Fehler beim Laden der Regeln.
</Alert>
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
<Tab label="Regeln" />
{canEdit && <Tab label="Einzelnachrichten" />}
</Tabs>
{tab === 0 && (
<>
{isError && (
<Alert severity="error" sx={{ mb: 2 }}>
Fehler beim Laden der Regeln.
</Alert>
)}
{isLoading ? (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Typ</TableCell>
<TableCell>Trigger</TableCell>
<TableCell>Zielraum</TableCell>
<TableCell>Abonnierbar</TableCell>
<TableCell>Aktiv</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{[1, 2, 3].map((i) => (
<TableRow key={i}>
{[1, 2, 3, 4, 5, 6, 7].map((j) => (
<TableCell key={j}><Skeleton /></TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
) : rules.length === 0 ? (
<Typography sx={{ textAlign: 'center', py: 4 }} color="text.secondary">
Keine Regeln konfiguriert
</Typography>
) : (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Typ</TableCell>
<TableCell>Trigger</TableCell>
<TableCell>Zielraum</TableCell>
<TableCell>Abonnierbar</TableCell>
<TableCell>Aktiv</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{rules.map((rule) => (
<TableRow
key={rule.id}
hover
sx={{ cursor: 'pointer' }}
onClick={() => navigate(`/geplante-nachrichten/${rule.id}`)}
>
<TableCell>{rule.name}</TableCell>
<TableCell>
<Chip
label={MESSAGE_TYPE_LABELS[rule.message_type] ?? rule.message_type}
size="small"
variant="outlined"
/>
</TableCell>
<TableCell>{triggerSummary(rule)}</TableCell>
<TableCell>{rule.target_room_name ?? rule.target_room_token}</TableCell>
<TableCell>{rule.subscribable ? 'Ja' : 'Nein'}</TableCell>
<TableCell>
<Switch
checked={rule.active}
onClick={(e) => e.stopPropagation()}
onChange={() => handleToggleActive(rule)}
size="small"
disabled={!canEdit}
/>
</TableCell>
<TableCell>
<Box sx={{ display: 'flex', gap: 0.5 }} onClick={(e) => e.stopPropagation()}>
{canEdit && (
<IconButton
size="small"
onClick={() => navigate(`/geplante-nachrichten/${rule.id}/bearbeiten`)}
>
<EditIcon fontSize="small" />
</IconButton>
)}
{canEdit && (
deleteConfirmId === rule.id ? (
<Button
size="small"
color="error"
variant="contained"
onClick={() => handleDelete(rule.id)}
>
Löschen?
</Button>
) : (
<Button
size="small"
color="error"
onClick={() => setDeleteConfirmId(rule.id)}
>
Löschen
</Button>
)
)}
</Box>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</>
)}
{isLoading ? (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Typ</TableCell>
<TableCell>Trigger</TableCell>
<TableCell>Zielraum</TableCell>
<TableCell>Abonnierbar</TableCell>
<TableCell>Aktiv</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{[1, 2, 3].map((i) => (
<TableRow key={i}>
{[1, 2, 3, 4, 5, 6, 7].map((j) => (
<TableCell key={j}><Skeleton /></TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
) : rules.length === 0 ? (
<Typography sx={{ textAlign: 'center', py: 4 }} color="text.secondary">
Keine Regeln konfiguriert
</Typography>
) : (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Typ</TableCell>
<TableCell>Trigger</TableCell>
<TableCell>Zielraum</TableCell>
<TableCell>Abonnierbar</TableCell>
<TableCell>Aktiv</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{rules.map((rule) => (
<TableRow
key={rule.id}
hover
sx={{ cursor: 'pointer' }}
onClick={() => navigate(`/geplante-nachrichten/${rule.id}`)}
>
<TableCell>{rule.name}</TableCell>
<TableCell>
<Chip
label={MESSAGE_TYPE_LABELS[rule.message_type] ?? rule.message_type}
size="small"
variant="outlined"
/>
</TableCell>
<TableCell>{triggerSummary(rule)}</TableCell>
<TableCell>{rule.target_room_name ?? rule.target_room_token}</TableCell>
<TableCell>{rule.subscribable ? 'Ja' : 'Nein'}</TableCell>
<TableCell>
<Switch
checked={rule.active}
onClick={(e) => e.stopPropagation()}
onChange={() => handleToggleActive(rule)}
size="small"
disabled={!canEdit}
/>
</TableCell>
<TableCell>
<Box sx={{ display: 'flex', gap: 0.5 }} onClick={(e) => e.stopPropagation()}>
{canEdit && (
<IconButton
size="small"
onClick={() => navigate(`/geplante-nachrichten/${rule.id}/bearbeiten`)}
>
<EditIcon fontSize="small" />
</IconButton>
)}
{canEdit && (
deleteConfirmId === rule.id ? (
<Button
size="small"
color="error"
variant="contained"
onClick={() => handleDelete(rule.id)}
>
Löschen?
</Button>
) : (
<Button
size="small"
color="error"
onClick={() => setDeleteConfirmId(rule.id)}
>
Löschen
</Button>
)
)}
</Box>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
{tab === 1 && canEdit && <EinzelNachrichtenTab />}
</Box>
</DashboardLayout>
);

View File

@@ -75,8 +75,13 @@ export default function GeplanteMachrichtenDetail() {
const handleSubscribe = async () => {
if (!id) return;
await scheduledMessagesApi.subscribe(id, '');
queryClient.invalidateQueries({ queryKey: ['scheduled-messages', id] });
try {
const roomResult = await scheduledMessagesApi.getMyBotRoom();
await scheduledMessagesApi.subscribe(id, roomResult.data.room_token);
queryClient.invalidateQueries({ queryKey: ['scheduled-messages', id] });
} catch {
showError('Abonnieren fehlgeschlagen. Bitte Nextcloud-Konto in den Einstellungen verbinden.');
}
};
const handleUnsubscribe = async () => {

View File

@@ -81,6 +81,7 @@ const ORDERABLE_NAV_ITEMS = [
{ text: 'Checklisten', path: '/checklisten', permission: 'checklisten:view' },
{ text: 'Buchhaltung', path: '/buchhaltung', permission: 'buchhaltung:view' },
{ text: 'Issues', path: '/issues', permission: 'issues:view_own' },
{ text: 'Geplante Nachrichten', path: '/geplante-nachrichten', permission: 'scheduled_messages:view' },
];
function SortableNavItem({ id, text }: { id: string; text: string }) {
@@ -130,7 +131,12 @@ function SubscriptionsCard() {
if (isSubscribed) {
await scheduledMessagesApi.unsubscribe(ruleId);
} else {
await scheduledMessagesApi.subscribe(ruleId, '');
try {
const roomResult = await scheduledMessagesApi.getMyBotRoom();
await scheduledMessagesApi.subscribe(ruleId, roomResult.data.room_token);
} catch {
return; // silently skip — user will see no change in toggle
}
}
queryClient.invalidateQueries({ queryKey: ['scheduled-messages'] });
};

View File

@@ -9,34 +9,37 @@ import type {
export const scheduledMessagesApi = {
getAll: () =>
api.get<ScheduledMessagesListResponse>('/scheduled-messages').then(r => r.data),
api.get<ScheduledMessagesListResponse>('/api/scheduled-messages').then(r => r.data),
getById: (id: string) =>
api.get<ScheduledMessageDetailResponse>(`/scheduled-messages/${id}`).then(r => r.data),
api.get<ScheduledMessageDetailResponse>(`/api/scheduled-messages/${id}`).then(r => r.data),
getRooms: () =>
api.get<RoomsResponse>('/scheduled-messages/rooms').then(r => r.data),
api.get<RoomsResponse>('/api/scheduled-messages/rooms').then(r => r.data),
create: (data: Partial<ScheduledMessageRule>) =>
api.post<ScheduledMessageDetailResponse>('/scheduled-messages', data).then(r => r.data),
api.post<ScheduledMessageDetailResponse>('/api/scheduled-messages', data).then(r => r.data),
update: (id: string, data: Partial<ScheduledMessageRule>) =>
api.patch<ScheduledMessageDetailResponse>(`/scheduled-messages/${id}`, data).then(r => r.data),
api.patch<ScheduledMessageDetailResponse>(`/api/scheduled-messages/${id}`, data).then(r => r.data),
delete: (id: string) =>
api.delete(`/scheduled-messages/${id}`).then(r => r.data),
api.delete(`/api/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),
api.post(`/api/scheduled-messages/${id}/subscribe`, { room_token: roomToken }).then(r => r.data),
unsubscribe: (id: string) =>
api.delete(`/scheduled-messages/${id}/subscribe`).then(r => r.data),
api.delete(`/api/scheduled-messages/${id}/subscribe`).then(r => r.data),
trigger: (id: string) =>
api.post(`/scheduled-messages/${id}/trigger`).then(r => r.data),
api.post(`/api/scheduled-messages/${id}/trigger`).then(r => r.data),
getMyBotRoom: () =>
api.get<{ data: { room_token: string } }>('/api/scheduled-messages/my-bot-room').then(r => r.data),
getOneTimeMessages: () =>
api.get<{ data: OneTimeMessage[] }>('/scheduled-messages/one-time').then(r => r.data),
api.get<{ data: OneTimeMessage[] }>('/api/scheduled-messages/one-time').then(r => r.data),
createOneTimeMessage: (data: {
message: string;
@@ -44,8 +47,8 @@ export const scheduledMessagesApi = {
target_room_name?: string;
send_at: string;
}) =>
api.post<{ data: OneTimeMessage }>('/scheduled-messages/one-time', data).then(r => r.data),
api.post<{ data: OneTimeMessage }>('/api/scheduled-messages/one-time', data).then(r => r.data),
deleteOneTimeMessage: (id: string) =>
api.delete(`/scheduled-messages/one-time/${id}`).then(r => r.data),
api.delete(`/api/scheduled-messages/one-time/${id}`).then(r => r.data),
};