feat(geplante-nachrichten): scheduled message rule engine with bot delivery, admin UI, and manual trigger

This commit is contained in:
Matthias Hochmeister
2026-04-17 09:10:57 +02:00
parent 6614fbaa68
commit 8a0c4200ff
24 changed files with 2208 additions and 69 deletions

View File

@@ -136,8 +136,8 @@ export default function ModuleSettingsIssues() {
});
const deleteStatusMut = useMutation({
mutationFn: (id: number) => 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(() => {

View File

@@ -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<string, unknown>;
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<ToolConfig>),
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 (
<Card>
<CardContent>
<Typography variant="h6" sx={{ mb: 1 }}>Nextcloud</Typography>
<Divider sx={{ mb: 2 }} />
<Stack spacing={3}>
<Card>
<CardContent>
<Typography variant="h6" sx={{ mb: 1 }}>Nextcloud</Typography>
<Divider sx={{ mb: 2 }} />
{!isFeatureEnabled('nextcloud') && (
<Alert severity="warning" sx={{ mb: 2 }}>
Dieses Werkzeug befindet sich im Wartungsmodus.
</Alert>
)}
{!isFeatureEnabled('nextcloud') && (
<Alert severity="warning" sx={{ mb: 2 }}>
Dieses Werkzeug befindet sich im Wartungsmodus.
</Alert>
)}
<Stack spacing={2}>
<TextField
label="URL"
value={url}
onChange={(e) => setUrl(e.target.value)}
size="small"
fullWidth
placeholder="https://nextcloud.example.com"
/>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
<Button
variant="contained"
<Stack spacing={2}>
<TextField
label="URL"
value={url}
onChange={(e) => setUrl(e.target.value)}
size="small"
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
>
Speichern
</Button>
<Button
variant="outlined"
size="small"
onClick={() => {
setTestResult(null);
testMutation.mutate();
}}
disabled={testMutation.isPending}
>
{testMutation.isPending ? <CircularProgress size={18} sx={{ mr: 1 }} /> : null}
Verbindung testen
</Button>
{testResult && (
<Chip
label={testResult.success ? `Verbunden (${testResult.latencyMs}ms)` : `Fehler: ${testResult.message}`}
color={testResult.success ? 'success' : 'error'}
fullWidth
placeholder="https://nextcloud.example.com"
/>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
<Button
variant="contained"
size="small"
/>
)}
</Box>
</Stack>
</CardContent>
</Card>
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
>
Speichern
</Button>
<Button
variant="outlined"
size="small"
onClick={() => {
setTestResult(null);
testMutation.mutate();
}}
disabled={testMutation.isPending}
>
{testMutation.isPending ? <CircularProgress size={18} sx={{ mr: 1 }} /> : null}
Verbindung testen
</Button>
{testResult && (
<Chip
label={testResult.success ? `Verbunden (${testResult.latencyMs}ms)` : `Fehler: ${testResult.message}`}
color={testResult.success ? 'success' : 'error'}
size="small"
/>
)}
</Box>
</Stack>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" gutterBottom>Bot-Konto</Typography>
<Stack spacing={2}>
<TextField
label="Bot-Benutzername"
value={botUsername}
onChange={(e) => setBotUsername(e.target.value)}
fullWidth
size="small"
/>
<TextField
label="Bot-App-Passwort"
type="password"
value={botAppPassword}
onChange={(e) => setBotAppPassword(e.target.value)}
fullWidth
size="small"
/>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<Button
variant="contained"
size="small"
onClick={() => saveBotMutation.mutate()}
disabled={saveBotMutation.isPending}
>
Bot speichern
</Button>
<Button
variant="outlined"
size="small"
onClick={async () => {
try {
const result = await scheduledMessagesApi.getRooms();
if (result.configured) {
showSuccess(`Verbindung erfolgreich (${result.data?.length ?? 0} Räume)`);
} else {
showError('Bot nicht konfiguriert');
}
} catch {
showError('Verbindungstest fehlgeschlagen');
}
}}
>
Verbindung testen
</Button>
</Box>
</Stack>
</CardContent>
</Card>
</Stack>
);
}

View File

@@ -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: <Schedule />,
path: '/geplante-nachrichten',
permission: 'scheduled_messages:view',
},
];
const adminItem: NavigationItem = {