feat(geplante-nachrichten): scheduled message rule engine with bot delivery, admin UI, and manual trigger
This commit is contained in:
@@ -110,6 +110,7 @@ import ausruestungTypRoutes from './routes/ausruestungTyp.routes';
|
|||||||
import buchhaltungRoutes from './routes/buchhaltung.routes';
|
import buchhaltungRoutes from './routes/buchhaltung.routes';
|
||||||
import personalEquipmentRoutes from './routes/personalEquipment.routes';
|
import personalEquipmentRoutes from './routes/personalEquipment.routes';
|
||||||
import toolConfigRoutes from './routes/toolConfig.routes';
|
import toolConfigRoutes from './routes/toolConfig.routes';
|
||||||
|
import scheduledMessagesRoutes from './routes/scheduledMessages.routes';
|
||||||
|
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/user', userRoutes);
|
app.use('/api/user', userRoutes);
|
||||||
@@ -142,6 +143,7 @@ app.use('/api/ausruestung-typen', ausruestungTypRoutes);
|
|||||||
app.use('/api/buchhaltung', buchhaltungRoutes);
|
app.use('/api/buchhaltung', buchhaltungRoutes);
|
||||||
app.use('/api/persoenliche-ausruestung', personalEquipmentRoutes);
|
app.use('/api/persoenliche-ausruestung', personalEquipmentRoutes);
|
||||||
app.use('/api/admin/tools', toolConfigRoutes);
|
app.use('/api/admin/tools', toolConfigRoutes);
|
||||||
|
app.use('/api/scheduled-messages', scheduledMessagesRoutes);
|
||||||
|
|
||||||
// Static file serving for uploads (authenticated)
|
// Static file serving for uploads (authenticated)
|
||||||
const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads');
|
const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads');
|
||||||
|
|||||||
@@ -382,7 +382,7 @@ class IssueController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const type = await issueService.deactivateType(id);
|
const type = await issueService.deleteType(id);
|
||||||
if (!type) {
|
if (!type) {
|
||||||
res.status(404).json({ success: false, message: 'Issue-Typ nicht gefunden' });
|
res.status(404).json({ success: false, message: 'Issue-Typ nicht gefunden' });
|
||||||
return;
|
return;
|
||||||
@@ -390,7 +390,7 @@ class IssueController {
|
|||||||
res.status(200).json({ success: true, data: type });
|
res.status(200).json({ success: true, data: type });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('IssueController.deleteType error', { 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 });
|
res.status(200).json({ success: true, data: item });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('IssueController.deleteIssueStatus error', { 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 });
|
res.status(200).json({ success: true, data: item });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('IssueController.deleteIssuePriority error', { 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' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
138
backend/src/controllers/scheduledMessages.controller.ts
Normal file
138
backend/src/controllers/scheduledMessages.controller.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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' });
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
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();
|
||||||
@@ -2,6 +2,7 @@ import { Request, Response } from 'express';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import vehicleService from '../services/vehicle.service';
|
import vehicleService from '../services/vehicle.service';
|
||||||
import equipmentService from '../services/equipment.service';
|
import equipmentService from '../services/equipment.service';
|
||||||
|
import scheduledMessagesService from '../services/scheduledMessages.service';
|
||||||
import { FahrzeugStatus } from '../models/vehicle.model';
|
import { FahrzeugStatus } from '../models/vehicle.model';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
@@ -314,6 +315,17 @@ class VehicleController {
|
|||||||
parsed.data.ausserDienstVon ? new Date(parsed.data.ausserDienstVon) : null,
|
parsed.data.ausserDienstVon ? new Date(parsed.data.ausserDienstVon) : null,
|
||||||
parsed.data.ausserDienstBis ? new Date(parsed.data.ausserDienstBis) : 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 });
|
res.status(200).json({ success: true, data: result });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error?.message === 'Vehicle not found') {
|
if (error?.message === 'Vehicle not found') {
|
||||||
|
|||||||
42
backend/src/database/migrations/094_scheduled_messages.sql
Normal file
42
backend/src/database/migrations/094_scheduled_messages.sql
Normal file
@@ -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);
|
||||||
@@ -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;
|
||||||
110
backend/src/jobs/scheduled-messages.job.ts
Normal file
110
backend/src/jobs/scheduled-messages.job.ts
Normal file
@@ -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<typeof setInterval> | 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<void> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
72
backend/src/routes/scheduledMessages.routes.ts
Normal file
72
backend/src/routes/scheduledMessages.routes.ts
Normal file
@@ -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;
|
||||||
@@ -8,6 +8,7 @@ import { startReminderJob, stopReminderJob } from './jobs/reminder.job';
|
|||||||
import { startIssueReminderJob, stopIssueReminderJob } from './jobs/issue-reminder.job';
|
import { startIssueReminderJob, stopIssueReminderJob } from './jobs/issue-reminder.job';
|
||||||
import { startChecklistReminderJob, stopChecklistReminderJob } from './jobs/checklist-reminder.job';
|
import { startChecklistReminderJob, stopChecklistReminderJob } from './jobs/checklist-reminder.job';
|
||||||
import { startBuchhaltungRecurringJob, stopBuchhaltungRecurringJob } from './jobs/buchhaltung-recurring.job';
|
import { startBuchhaltungRecurringJob, stopBuchhaltungRecurringJob } from './jobs/buchhaltung-recurring.job';
|
||||||
|
import { startScheduledMessagesJob, stopScheduledMessagesJob } from './jobs/scheduled-messages.job';
|
||||||
import { permissionService } from './services/permission.service';
|
import { permissionService } from './services/permission.service';
|
||||||
|
|
||||||
const startServer = async (): Promise<void> => {
|
const startServer = async (): Promise<void> => {
|
||||||
@@ -44,6 +45,9 @@ const startServer = async (): Promise<void> => {
|
|||||||
// Start the buchhaltung recurring transaction job
|
// Start the buchhaltung recurring transaction job
|
||||||
startBuchhaltungRecurringJob();
|
startBuchhaltungRecurringJob();
|
||||||
|
|
||||||
|
// Start the scheduled messages job
|
||||||
|
startScheduledMessagesJob();
|
||||||
|
|
||||||
// Start the server
|
// Start the server
|
||||||
const server = app.listen(environment.port, () => {
|
const server = app.listen(environment.port, () => {
|
||||||
logger.info('Server started successfully', {
|
logger.info('Server started successfully', {
|
||||||
@@ -71,6 +75,7 @@ const startServer = async (): Promise<void> => {
|
|||||||
stopIssueReminderJob();
|
stopIssueReminderJob();
|
||||||
stopChecklistReminderJob();
|
stopChecklistReminderJob();
|
||||||
stopBuchhaltungRecurringJob();
|
stopBuchhaltungRecurringJob();
|
||||||
|
stopScheduledMessagesJob();
|
||||||
|
|
||||||
server.close(async () => {
|
server.close(async () => {
|
||||||
logger.info('HTTP server closed');
|
logger.info('HTTP server closed');
|
||||||
|
|||||||
@@ -378,16 +378,16 @@ async function updateType(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deactivateType(id: number) {
|
async function deleteType(id: number) {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`UPDATE issue_typen SET aktiv = false WHERE id = $1 RETURNING *`,
|
`DELETE FROM issue_typen WHERE id = $1 RETURNING *`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
return result.rows[0] || null;
|
return result.rows[0] || null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('IssueService.deactivateType failed', { error, id });
|
logger.error('IssueService.deleteType failed', { error, id });
|
||||||
throw new Error('Issue-Typ konnte nicht deaktiviert werden');
|
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) {
|
async function deleteIssueStatus(id: number) {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`UPDATE issue_statuses SET aktiv = false WHERE id = $1 RETURNING *`,
|
`DELETE FROM issue_statuses WHERE id = $1 RETURNING *`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
return result.rows[0] || null;
|
return result.rows[0] || null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('IssueService.deleteIssueStatus failed', { error, id });
|
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) {
|
async function deleteIssuePriority(id: number) {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`UPDATE issue_prioritaeten SET aktiv = false WHERE id = $1 RETURNING *`,
|
`DELETE FROM issue_prioritaeten WHERE id = $1 RETURNING *`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
return result.rows[0] || null;
|
return result.rows[0] || null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('IssueService.deleteIssuePriority failed', { error, id });
|
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,
|
getTypes,
|
||||||
createType,
|
createType,
|
||||||
updateType,
|
updateType,
|
||||||
deactivateType,
|
deleteType,
|
||||||
getAssignableMembers,
|
getAssignableMembers,
|
||||||
getIssueCounts,
|
getIssueCounts,
|
||||||
getIssueStatuses,
|
getIssueStatuses,
|
||||||
|
|||||||
540
backend/src/services/scheduledMessages.service.ts
Normal file
540
backend/src/services/scheduledMessages.service.ts
Normal file
@@ -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<string, unknown> | 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, string>): 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<string, unknown>) => {
|
||||||
|
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<string, unknown>) => {
|
||||||
|
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<string, unknown>) => {
|
||||||
|
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<string, unknown>) => {
|
||||||
|
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<ScheduledMessageRule[]> {
|
||||||
|
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<ScheduledMessageRule | null> {
|
||||||
|
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<ScheduledMessageRule, 'id' | 'last_sent_at' | 'created_at' | 'created_by' | 'subscriber_count' | 'is_subscribed'>,
|
||||||
|
userId: string,
|
||||||
|
): Promise<ScheduledMessageRule> {
|
||||||
|
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<Omit<ScheduledMessageRule, 'id' | 'created_at' | 'created_by' | 'subscriber_count' | 'is_subscribed'>>,
|
||||||
|
): Promise<ScheduledMessageRule | null> {
|
||||||
|
const keys = Object.keys(data) as Array<keyof typeof data>;
|
||||||
|
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<boolean> {
|
||||||
|
const result = await pool.query('DELETE FROM scheduled_message_rules WHERE id = $1', [id]);
|
||||||
|
return (result.rowCount ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rooms ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function getRooms(): Promise<RoomsResult> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
await pool.query(
|
||||||
|
'DELETE FROM scheduled_message_subscriptions WHERE rule_id = $1 AND user_id = $2',
|
||||||
|
[ruleId, userId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSubscriptionsForUser(userId: string): Promise<Array<ScheduledMessageRule & { subscription_room_token: string }>> {
|
||||||
|
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<void> {
|
||||||
|
const creds = await getBotCredentials();
|
||||||
|
if (!creds) {
|
||||||
|
logger.warn('scheduledMessages.buildAndSend: no bot credentials configured');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let vars: Record<string, string>;
|
||||||
|
|
||||||
|
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<string, string> = {
|
||||||
|
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<void> {
|
||||||
|
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<string, string> = {
|
||||||
|
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;
|
||||||
@@ -56,6 +56,10 @@ import Issues from './pages/Issues';
|
|||||||
import IssueDetail from './pages/IssueDetail';
|
import IssueDetail from './pages/IssueDetail';
|
||||||
import IssueNeu from './pages/IssueNeu';
|
import IssueNeu from './pages/IssueNeu';
|
||||||
import Chat from './pages/Chat';
|
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 AdminDashboard from './pages/AdminDashboard';
|
||||||
import AdminSettings from './pages/AdminSettings';
|
import AdminSettings from './pages/AdminSettings';
|
||||||
import NotFound from './pages/NotFound';
|
import NotFound from './pages/NotFound';
|
||||||
@@ -510,6 +514,38 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/geplante-nachrichten"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<GeplanteMachrichten />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/geplante-nachrichten/neu"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<GeplanteMachrichtenNeu />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/geplante-nachrichten/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<GeplanteMachrichtenDetail />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/geplante-nachrichten/:id/bearbeiten"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<GeplanteMachrichtenBearbeiten />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin"
|
path="/admin"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -136,8 +136,8 @@ export default function ModuleSettingsIssues() {
|
|||||||
});
|
});
|
||||||
const deleteStatusMut = useMutation({
|
const deleteStatusMut = useMutation({
|
||||||
mutationFn: (id: number) => issuesApi.deleteStatus(id),
|
mutationFn: (id: number) => issuesApi.deleteStatus(id),
|
||||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-statuses'] }); showSuccess('Status deaktiviert'); },
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-statuses'] }); showSuccess('Status gelöscht'); },
|
||||||
onError: () => showError('Fehler beim Deaktivieren'),
|
onError: () => showError('Fehler beim Löschen'),
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Priority mutations ──
|
// ── Priority mutations ──
|
||||||
@@ -153,8 +153,8 @@ export default function ModuleSettingsIssues() {
|
|||||||
});
|
});
|
||||||
const deletePrioMut = useMutation({
|
const deletePrioMut = useMutation({
|
||||||
mutationFn: (id: number) => issuesApi.deletePriority(id),
|
mutationFn: (id: number) => issuesApi.deletePriority(id),
|
||||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-priorities'] }); showSuccess('Priorität deaktiviert'); },
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-priorities'] }); showSuccess('Priorität gelöscht'); },
|
||||||
onError: () => showError('Fehler beim Deaktivieren'),
|
onError: () => showError('Fehler beim Löschen'),
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Type mutations ──
|
// ── Type mutations ──
|
||||||
@@ -170,8 +170,8 @@ export default function ModuleSettingsIssues() {
|
|||||||
});
|
});
|
||||||
const deleteTypeMut = useMutation({
|
const deleteTypeMut = useMutation({
|
||||||
mutationFn: (id: number) => issuesApi.deleteType(id),
|
mutationFn: (id: number) => issuesApi.deleteType(id),
|
||||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-types'] }); showSuccess('Kategorie deaktiviert'); },
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['issue-types'] }); showSuccess('Kategorie gelöscht'); },
|
||||||
onError: () => showError('Fehler beim Deaktivieren'),
|
onError: () => showError('Fehler beim Löschen'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const flatTypes = useMemo(() => {
|
const flatTypes = useMemo(() => {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { usePermissionContext } from '../../contexts/PermissionContext';
|
import { usePermissionContext } from '../../contexts/PermissionContext';
|
||||||
import { useNotification } from '../../contexts/NotificationContext';
|
import { useNotification } from '../../contexts/NotificationContext';
|
||||||
import { toolConfigApi } from '../../services/toolConfig';
|
import { toolConfigApi } from '../../services/toolConfig';
|
||||||
|
import type { ToolConfig } from '../../types/toolConfig.types';
|
||||||
|
import { scheduledMessagesApi } from '../../services/scheduledMessages';
|
||||||
|
|
||||||
export default function ToolSettingsNextcloud() {
|
export default function ToolSettingsNextcloud() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -28,11 +30,16 @@ export default function ToolSettingsNextcloud() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
|
const [botUsername, setBotUsername] = useState('');
|
||||||
|
const [botAppPassword, setBotAppPassword] = useState('');
|
||||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string; latencyMs: number } | null>(null);
|
const [testResult, setTestResult] = useState<{ success: boolean; message: string; latencyMs: number } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
setUrl(data.url ?? '');
|
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]);
|
}, [data]);
|
||||||
|
|
||||||
@@ -45,6 +52,19 @@ export default function ToolSettingsNextcloud() {
|
|||||||
onError: () => showError('Fehler beim Speichern der Konfiguration'),
|
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({
|
const testMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
toolConfigApi.test('nextcloud', { url: url || undefined }),
|
toolConfigApi.test('nextcloud', { url: url || undefined }),
|
||||||
@@ -61,6 +81,7 @@ export default function ToolSettingsNextcloud() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Stack spacing={3}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="h6" sx={{ mb: 1 }}>Nextcloud</Typography>
|
<Typography variant="h6" sx={{ mb: 1 }}>Nextcloud</Typography>
|
||||||
@@ -112,7 +133,56 @@ export default function ToolSettingsNextcloud() {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
AssignmentTurnedIn,
|
AssignmentTurnedIn,
|
||||||
AccountBalance as AccountBalanceIcon,
|
AccountBalance as AccountBalanceIcon,
|
||||||
Checkroom as CheckroomIcon,
|
Checkroom as CheckroomIcon,
|
||||||
|
Schedule,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
@@ -134,6 +135,12 @@ const baseNavigationItems: NavigationItem[] = [
|
|||||||
path: '/issues',
|
path: '/issues',
|
||||||
permission: 'issues:view_own',
|
permission: 'issues:view_own',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: 'Geplante Nachrichten',
|
||||||
|
icon: <Schedule />,
|
||||||
|
path: '/geplante-nachrichten',
|
||||||
|
permission: 'scheduled_messages:view',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const adminItem: NavigationItem = {
|
const adminItem: NavigationItem = {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ export const WIDGETS = [
|
|||||||
{ key: 'equipment', label: 'Ausrüstung', defaultVisible: true },
|
{ key: 'equipment', label: 'Ausrüstung', defaultVisible: true },
|
||||||
{ key: 'atemschutz', label: 'Atemschutz', defaultVisible: true },
|
{ key: 'atemschutz', label: 'Atemschutz', defaultVisible: true },
|
||||||
{ key: 'events', label: 'Termine', defaultVisible: true },
|
{ key: 'events', label: 'Termine', defaultVisible: true },
|
||||||
{ key: 'nextcloudTalk', label: 'Nextcloud Talk', defaultVisible: true },
|
|
||||||
{ key: 'bookstackRecent', label: 'Wissen — Neueste', defaultVisible: true },
|
{ key: 'bookstackRecent', label: 'Wissen — Neueste', defaultVisible: true },
|
||||||
{ key: 'bookstackSearch', label: 'Wissen — Suche', defaultVisible: true },
|
{ key: 'bookstackSearch', label: 'Wissen — Suche', defaultVisible: true },
|
||||||
{ key: 'vikunjaTasks', label: 'Vikunja Aufgaben', defaultVisible: true },
|
{ key: 'vikunjaTasks', label: 'Vikunja Aufgaben', defaultVisible: true },
|
||||||
|
|||||||
217
frontend/src/pages/GeplanteMachrichten.tsx
Normal file
217
frontend/src/pages/GeplanteMachrichten.tsx
Normal file
@@ -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<MessageType, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<string | null>(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 (
|
||||||
|
<DashboardLayout>
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Typography variant="h5">Geplante Nachrichten</Typography>
|
||||||
|
{canEdit && (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={() => navigate('/geplante-nachrichten/neu')}
|
||||||
|
>
|
||||||
|
Neue Regel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
frontend/src/pages/GeplanteMachrichtenBearbeiten.tsx
Normal file
7
frontend/src/pages/GeplanteMachrichtenBearbeiten.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import GeplanteMachrichtenForm from './GeplanteMachrichtenForm';
|
||||||
|
|
||||||
|
export default function GeplanteMachrichtenBearbeiten() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
return <GeplanteMachrichtenForm ruleId={id} />;
|
||||||
|
}
|
||||||
252
frontend/src/pages/GeplanteMachrichtenDetail.tsx
Normal file
252
frontend/src/pages/GeplanteMachrichtenDetail.tsx
Normal file
@@ -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<MessageType, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<DashboardLayout>
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Button startIcon={<ArrowBack />} onClick={() => navigate('/geplante-nachrichten')}>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
{canEdit && rule && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={triggerMutation.isPending ? <CircularProgress size={16} /> : <SendIcon />}
|
||||||
|
onClick={() => triggerMutation.mutate()}
|
||||||
|
disabled={triggerMutation.isPending}
|
||||||
|
>
|
||||||
|
Jetzt senden
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<EditIcon />}
|
||||||
|
onClick={() => navigate(`/geplante-nachrichten/${id}/bearbeiten`)}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<Alert severity="error">Fehler beim Laden der Regel.</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rule && (
|
||||||
|
<Paper sx={{ p: 3 }}>
|
||||||
|
<Typography variant="h5" gutterBottom>{rule.name}</Typography>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: '200px 1fr', gap: 1.5, mb: 2 }}>
|
||||||
|
<Typography color="text.secondary">Typ</Typography>
|
||||||
|
<Chip
|
||||||
|
label={MESSAGE_TYPE_LABELS[rule.message_type] ?? rule.message_type}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography color="text.secondary">Aktiv</Typography>
|
||||||
|
<Chip
|
||||||
|
label={rule.active ? 'Ja' : 'Nein'}
|
||||||
|
size="small"
|
||||||
|
color={rule.active ? 'success' : 'default'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography color="text.secondary">Trigger</Typography>
|
||||||
|
<Typography>{TRIGGER_LABELS[rule.trigger_mode] ?? rule.trigger_mode}</Typography>
|
||||||
|
|
||||||
|
{rule.trigger_mode === 'day_of_week' && (
|
||||||
|
<>
|
||||||
|
<Typography color="text.secondary">Wochentag</Typography>
|
||||||
|
<Typography>
|
||||||
|
{rule.day_of_week != null ? WEEKDAY_LABELS[rule.day_of_week] : '-'}
|
||||||
|
</Typography>
|
||||||
|
<Typography color="text.secondary">Uhrzeit</Typography>
|
||||||
|
<Typography>{rule.send_time ?? '-'}</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rule.trigger_mode === 'days_before_month_start' && (
|
||||||
|
<>
|
||||||
|
<Typography color="text.secondary">Tage vor Monatsbeginn</Typography>
|
||||||
|
<Typography>{rule.days_before_month_start ?? '-'}</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rule.trigger_mode !== 'event' && (
|
||||||
|
<>
|
||||||
|
<Typography color="text.secondary">Zeitfenster</Typography>
|
||||||
|
<Typography>
|
||||||
|
{rule.window_mode ? WINDOW_LABELS[rule.window_mode] ?? rule.window_mode : '-'}
|
||||||
|
{rule.window_mode === 'rolling' && rule.window_days != null
|
||||||
|
? ` (${rule.window_days} Tage)`
|
||||||
|
: ''}
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Typography color="text.secondary">Zielraum</Typography>
|
||||||
|
<Typography>{rule.target_room_name ?? rule.target_room_token}</Typography>
|
||||||
|
|
||||||
|
<Typography color="text.secondary">Abonnierbar</Typography>
|
||||||
|
<Typography>{rule.subscribable ? 'Ja' : 'Nein'}</Typography>
|
||||||
|
|
||||||
|
{rule.allowed_groups.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Typography color="text.secondary">Gruppen</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||||
|
{rule.allowed_groups.map((g) => (
|
||||||
|
<Chip key={g} label={g} size="small" variant="outlined" />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Typography color="text.secondary">Zuletzt gesendet</Typography>
|
||||||
|
<Typography>{formatDate(rule.last_sent_at)}</Typography>
|
||||||
|
|
||||||
|
{canEdit && rule.subscriber_count != null && (
|
||||||
|
<>
|
||||||
|
<Typography color="text.secondary">Abonnenten</Typography>
|
||||||
|
<Typography>{rule.subscriber_count}</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
<Typography color="text.secondary" gutterBottom>Vorlage</Typography>
|
||||||
|
<Box
|
||||||
|
component="pre"
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
bgcolor: 'grey.100',
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: 'auto',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rule.template}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{canUserSubscribe && (
|
||||||
|
<Box sx={{ mt: 3 }}>
|
||||||
|
{rule.is_subscribed ? (
|
||||||
|
<Button variant="outlined" color="error" onClick={handleUnsubscribe}>
|
||||||
|
Abbestellen
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="contained" onClick={handleSubscribe}>
|
||||||
|
Abonnieren
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
426
frontend/src/pages/GeplanteMachrichtenForm.tsx
Normal file
426
frontend/src/pages/GeplanteMachrichtenForm.tsx
Normal file
@@ -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<MessageType, string> = {
|
||||||
|
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<MessageType, string[]> = {
|
||||||
|
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<MessageType>('event_summary');
|
||||||
|
const [triggerMode, setTriggerMode] = useState<TriggerMode>('day_of_week');
|
||||||
|
const [dayOfWeek, setDayOfWeek] = useState<number>(1);
|
||||||
|
const [sendTime, setSendTime] = useState('08:00');
|
||||||
|
const [daysBeforeMonthStart, setDaysBeforeMonthStart] = useState<number>(3);
|
||||||
|
const [windowMode, setWindowMode] = useState<WindowMode>('rolling');
|
||||||
|
const [windowDays, setWindowDays] = useState<number>(7);
|
||||||
|
const [targetRoomToken, setTargetRoomToken] = useState('');
|
||||||
|
const [template, setTemplate] = useState('');
|
||||||
|
const [minDaysOverdue, setMinDaysOverdue] = useState<number>(14);
|
||||||
|
const [subscribable, setSubscribable] = useState(false);
|
||||||
|
const [allowedGroups, setAllowedGroups] = useState<string[]>([]);
|
||||||
|
const [active, setActive] = useState(true);
|
||||||
|
|
||||||
|
const templateRef = useRef<HTMLDivElement>(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<string, unknown> = {
|
||||||
|
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 (
|
||||||
|
<DashboardLayout>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<Box sx={{ p: 3, maxWidth: 800 }}>
|
||||||
|
<Typography variant="h5" gutterBottom>
|
||||||
|
{ruleId ? 'Regel bearbeiten' : 'Neue Regel'}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Paper sx={{ p: 3 }}>
|
||||||
|
{/* Name */}
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<TextField
|
||||||
|
label="Name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Message Type */}
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<FormControl fullWidth size="small">
|
||||||
|
<FormLabel sx={{ mb: 0.5 }}>Nachrichtentyp</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={messageType}
|
||||||
|
onChange={(e) => setMessageType(e.target.value as MessageType)}
|
||||||
|
>
|
||||||
|
{MESSAGE_TYPE_OPTIONS.map((t) => (
|
||||||
|
<MenuItem key={t} value={t}>{MESSAGE_TYPE_LABELS[t]}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Trigger */}
|
||||||
|
{isEventType ? (
|
||||||
|
<Alert severity="info" sx={{ mb: 3 }}>
|
||||||
|
Dieser Nachrichtentyp wird automatisch bei Ereignissen ausgelöst.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<FormControl>
|
||||||
|
<FormLabel>Auslöser</FormLabel>
|
||||||
|
<RadioGroup
|
||||||
|
value={triggerMode}
|
||||||
|
onChange={(e) => setTriggerMode(e.target.value as TriggerMode)}
|
||||||
|
>
|
||||||
|
<FormControlLabel value="day_of_week" control={<Radio />} label="Wochentag + Uhrzeit" />
|
||||||
|
<FormControlLabel value="days_before_month_start" control={<Radio />} label="N Tage vor Monatsbeginn" />
|
||||||
|
</RadioGroup>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{triggerMode === 'day_of_week' && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, mt: 1 }}>
|
||||||
|
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||||
|
<Select
|
||||||
|
value={dayOfWeek}
|
||||||
|
onChange={(e) => setDayOfWeek(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{WEEKDAY_OPTIONS.map((w) => (
|
||||||
|
<MenuItem key={w.value} value={w.value}>{w.label}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<TextField
|
||||||
|
label="Uhrzeit"
|
||||||
|
type="time"
|
||||||
|
value={sendTime}
|
||||||
|
onChange={(e) => setSendTime(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{triggerMode === 'days_before_month_start' && (
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label="Tage vor Monatsbeginn"
|
||||||
|
type="number"
|
||||||
|
value={daysBeforeMonthStart}
|
||||||
|
onChange={(e) => setDaysBeforeMonthStart(Number(e.target.value))}
|
||||||
|
size="small"
|
||||||
|
inputProps={{ min: 0, max: 28 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Window */}
|
||||||
|
{!isEventType && (
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<FormControl>
|
||||||
|
<FormLabel>Zeitfenster</FormLabel>
|
||||||
|
<RadioGroup
|
||||||
|
value={windowMode}
|
||||||
|
onChange={(e) => setWindowMode(e.target.value as WindowMode)}
|
||||||
|
>
|
||||||
|
<FormControlLabel value="rolling" control={<Radio />} label="Rollierend (N Tage)" />
|
||||||
|
<FormControlLabel value="calendar_month" control={<Radio />} label="Nächster Kalendermonat" />
|
||||||
|
</RadioGroup>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{windowMode === 'rolling' && (
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label="Tage"
|
||||||
|
type="number"
|
||||||
|
value={windowDays}
|
||||||
|
onChange={(e) => setWindowDays(Number(e.target.value))}
|
||||||
|
size="small"
|
||||||
|
inputProps={{ min: 1, max: 365 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Target Room */}
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={targetRoomToken}
|
||||||
|
onChange={(e) => setTargetRoomToken(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>
|
||||||
|
|
||||||
|
{/* Template */}
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<FormLabel sx={{ mb: 0.5, display: 'block' }}>Vorlage</FormLabel>
|
||||||
|
<TextField
|
||||||
|
ref={templateRef}
|
||||||
|
value={template}
|
||||||
|
onChange={(e) => setTemplate(e.target.value)}
|
||||||
|
multiline
|
||||||
|
minRows={4}
|
||||||
|
maxRows={12}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mt: 1 }}>
|
||||||
|
{(PLACEHOLDER_MAP[messageType] ?? []).map((ph) => (
|
||||||
|
<Chip
|
||||||
|
key={ph}
|
||||||
|
label={ph}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => insertPlaceholder(ph)}
|
||||||
|
sx={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Min days overdue (bestellungen only) */}
|
||||||
|
{messageType === 'bestellungen' && (
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<TextField
|
||||||
|
label="Mindest-Alter (Tage überfällig)"
|
||||||
|
type="number"
|
||||||
|
value={minDaysOverdue}
|
||||||
|
onChange={(e) => setMinDaysOverdue(Number(e.target.value))}
|
||||||
|
size="small"
|
||||||
|
inputProps={{ min: 0 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Subscribable */}
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch checked={subscribable} onChange={(e) => setSubscribable(e.target.checked)} />
|
||||||
|
}
|
||||||
|
label="Abonnierbar"
|
||||||
|
/>
|
||||||
|
{subscribable && (
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
<FormLabel sx={{ mb: 0.5, display: 'block' }}>Erlaubte Gruppen (leer = alle)</FormLabel>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
freeSolo
|
||||||
|
options={[]}
|
||||||
|
value={allowedGroups}
|
||||||
|
onChange={(_e, val) => setAllowedGroups(val)}
|
||||||
|
renderTags={(value, getTagProps) =>
|
||||||
|
value.map((option, index) => {
|
||||||
|
const { key, ...tagProps } = getTagProps({ index });
|
||||||
|
return <Chip key={key} label={option} size="small" {...tagProps} />;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} size="small" placeholder="Gruppe eingeben + Enter" />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Active */}
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch checked={active} onChange={(e) => setActive(e.target.checked)} />}
|
||||||
|
label="Aktiv"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={() => saveMutation.mutate()}
|
||||||
|
disabled={saveMutation.isPending || !name || (!isEventType && !targetRoomToken)}
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? <CircularProgress size={20} sx={{ mr: 1 }} /> : null}
|
||||||
|
{ruleId ? 'Speichern' : 'Erstellen'}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => navigate('/geplante-nachrichten')}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
frontend/src/pages/GeplanteMachrichtenNeu.tsx
Normal file
5
frontend/src/pages/GeplanteMachrichtenNeu.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import GeplanteMachrichtenForm from './GeplanteMachrichtenForm';
|
||||||
|
|
||||||
|
export default function GeplanteMachrichtenNeu() {
|
||||||
|
return <GeplanteMachrichtenForm />;
|
||||||
|
}
|
||||||
@@ -21,6 +21,9 @@ import {
|
|||||||
ListItemText,
|
ListItemText,
|
||||||
} from '@mui/material';
|
} 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 { 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
@@ -43,9 +46,20 @@ import { useThemeMode } from '../contexts/ThemeContext';
|
|||||||
import { preferencesApi } from '../services/settings';
|
import { preferencesApi } from '../services/settings';
|
||||||
import { WIDGETS, WidgetKey } from '../constants/widgets';
|
import { WIDGETS, WidgetKey } from '../constants/widgets';
|
||||||
import { nextcloudApi } from '../services/nextcloud';
|
import { nextcloudApi } from '../services/nextcloud';
|
||||||
|
import { scheduledMessagesApi } from '../services/scheduledMessages';
|
||||||
import { useNotification } from '../contexts/NotificationContext';
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||||
|
import type { MessageType } from '../types/scheduledMessages.types';
|
||||||
|
|
||||||
|
const MESSAGE_TYPE_LABELS: Record<MessageType, string> = {
|
||||||
|
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_INTERVAL = 2000;
|
||||||
const POLL_TIMEOUT = 5 * 60 * 1000;
|
const POLL_TIMEOUT = 5 * 60 * 1000;
|
||||||
@@ -58,12 +72,14 @@ const ORDERABLE_NAV_ITEMS = [
|
|||||||
{ text: 'Fahrzeugbuchungen', path: '/fahrzeugbuchungen', permission: 'fahrzeugbuchungen:view' },
|
{ text: 'Fahrzeugbuchungen', path: '/fahrzeugbuchungen', permission: 'fahrzeugbuchungen:view' },
|
||||||
{ text: 'Fahrzeuge', path: '/fahrzeuge', permission: 'fahrzeuge:view' },
|
{ text: 'Fahrzeuge', path: '/fahrzeuge', permission: 'fahrzeuge:view' },
|
||||||
{ text: 'Ausrüstung', path: '/ausruestung', permission: 'ausruestung: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: 'Mitglieder', path: '/mitglieder', permission: 'mitglieder:view_own' },
|
||||||
{ text: 'Atemschutz', path: '/atemschutz', permission: 'atemschutz:view' },
|
{ text: 'Atemschutz', path: '/atemschutz', permission: 'atemschutz:view' },
|
||||||
{ text: 'Wissen', path: '/wissen', permission: 'wissen:view' },
|
{ text: 'Wissen', path: '/wissen', permission: 'wissen:view' },
|
||||||
{ text: 'Bestellungen', path: '/bestellungen', permission: 'bestellungen:view' },
|
{ text: 'Bestellungen', path: '/bestellungen', permission: 'bestellungen:view' },
|
||||||
{ text: 'Interne Bestellungen', path: '/ausruestungsanfrage', permission: 'ausruestungsanfrage:view' },
|
{ text: 'Interne Bestellungen', path: '/ausruestungsanfrage', permission: 'ausruestungsanfrage:view' },
|
||||||
{ text: 'Checklisten', path: '/checklisten', permission: 'checklisten:view' },
|
{ text: 'Checklisten', path: '/checklisten', permission: 'checklisten:view' },
|
||||||
|
{ text: 'Buchhaltung', path: '/buchhaltung', permission: 'buchhaltung:view' },
|
||||||
{ text: 'Issues', path: '/issues', permission: 'issues:view_own' },
|
{ 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 (
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||||
|
<ScheduleIcon color="primary" sx={{ mr: 2 }} />
|
||||||
|
<Typography variant="h6">Nachrichten-Abonnements</Typography>
|
||||||
|
</Box>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
{isLoading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
|
||||||
|
<CircularProgress size={24} />
|
||||||
|
</Box>
|
||||||
|
) : rules.length === 0 ? (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Keine abonnierbaren Nachrichten verfügbar.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<FormGroup>
|
||||||
|
{rules.map((rule) => (
|
||||||
|
<Box key={rule.id} sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={!!rule.is_subscribed}
|
||||||
|
onChange={() => handleToggle(rule.id, !!rule.is_subscribed)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={rule.name}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label={MESSAGE_TYPE_LABELS[rule.message_type] ?? rule.message_type}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</FormGroup>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Settings() {
|
function Settings() {
|
||||||
const { themeMode, setThemeMode } = useThemeMode();
|
const { themeMode, setThemeMode } = useThemeMode();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -551,6 +639,11 @@ function Settings() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
{/* Nachrichten-Abonnements */}
|
||||||
|
{hasPermission('scheduled_messages:subscribe') && (
|
||||||
|
<SubscriptionsCard />
|
||||||
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
</Container>
|
</Container>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
|
|||||||
36
frontend/src/services/scheduledMessages.ts
Normal file
36
frontend/src/services/scheduledMessages.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { api } from './api';
|
||||||
|
import type {
|
||||||
|
ScheduledMessageRule,
|
||||||
|
ScheduledMessagesListResponse,
|
||||||
|
ScheduledMessageDetailResponse,
|
||||||
|
RoomsResponse,
|
||||||
|
} from '../types/scheduledMessages.types';
|
||||||
|
|
||||||
|
export const scheduledMessagesApi = {
|
||||||
|
getAll: () =>
|
||||||
|
api.get<ScheduledMessagesListResponse>('/scheduled-messages').then(r => r.data),
|
||||||
|
|
||||||
|
getById: (id: string) =>
|
||||||
|
api.get<ScheduledMessageDetailResponse>(`/scheduled-messages/${id}`).then(r => r.data),
|
||||||
|
|
||||||
|
getRooms: () =>
|
||||||
|
api.get<RoomsResponse>('/scheduled-messages/rooms').then(r => r.data),
|
||||||
|
|
||||||
|
create: (data: Partial<ScheduledMessageRule>) =>
|
||||||
|
api.post<ScheduledMessageDetailResponse>('/scheduled-messages', data).then(r => r.data),
|
||||||
|
|
||||||
|
update: (id: string, data: Partial<ScheduledMessageRule>) =>
|
||||||
|
api.patch<ScheduledMessageDetailResponse>(`/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),
|
||||||
|
};
|
||||||
52
frontend/src/types/scheduledMessages.types.ts
Normal file
52
frontend/src/types/scheduledMessages.types.ts
Normal file
@@ -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<string, unknown> | 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[];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user