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 personalEquipmentRoutes from './routes/personalEquipment.routes';
|
||||
import toolConfigRoutes from './routes/toolConfig.routes';
|
||||
import scheduledMessagesRoutes from './routes/scheduledMessages.routes';
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/user', userRoutes);
|
||||
@@ -142,6 +143,7 @@ app.use('/api/ausruestung-typen', ausruestungTypRoutes);
|
||||
app.use('/api/buchhaltung', buchhaltungRoutes);
|
||||
app.use('/api/persoenliche-ausruestung', personalEquipmentRoutes);
|
||||
app.use('/api/admin/tools', toolConfigRoutes);
|
||||
app.use('/api/scheduled-messages', scheduledMessagesRoutes);
|
||||
|
||||
// Static file serving for uploads (authenticated)
|
||||
const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads');
|
||||
|
||||
@@ -382,7 +382,7 @@ class IssueController {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const type = await issueService.deactivateType(id);
|
||||
const type = await issueService.deleteType(id);
|
||||
if (!type) {
|
||||
res.status(404).json({ success: false, message: 'Issue-Typ nicht gefunden' });
|
||||
return;
|
||||
@@ -390,7 +390,7 @@ class IssueController {
|
||||
res.status(200).json({ success: true, data: type });
|
||||
} catch (error) {
|
||||
logger.error('IssueController.deleteType error', { error });
|
||||
res.status(500).json({ success: false, message: 'Issue-Typ konnte nicht deaktiviert werden' });
|
||||
res.status(500).json({ success: false, message: 'Issue-Typ konnte nicht gelöscht werden' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,7 +461,7 @@ class IssueController {
|
||||
res.status(200).json({ success: true, data: item });
|
||||
} catch (error) {
|
||||
logger.error('IssueController.deleteIssueStatus error', { error });
|
||||
res.status(500).json({ success: false, message: 'Issue-Status konnte nicht deaktiviert werden' });
|
||||
res.status(500).json({ success: false, message: 'Issue-Status konnte nicht gelöscht werden' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,7 +512,7 @@ class IssueController {
|
||||
res.status(200).json({ success: true, data: item });
|
||||
} catch (error) {
|
||||
logger.error('IssueController.deleteIssuePriority error', { error });
|
||||
res.status(500).json({ success: false, message: 'Priorität konnte nicht deaktiviert werden' });
|
||||
res.status(500).json({ success: false, message: 'Priorität konnte nicht gelöscht werden' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 vehicleService from '../services/vehicle.service';
|
||||
import equipmentService from '../services/equipment.service';
|
||||
import scheduledMessagesService from '../services/scheduledMessages.service';
|
||||
import { FahrzeugStatus } from '../models/vehicle.model';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
@@ -314,6 +315,17 @@ class VehicleController {
|
||||
parsed.data.ausserDienstVon ? new Date(parsed.data.ausserDienstVon) : null,
|
||||
parsed.data.ausserDienstBis ? new Date(parsed.data.ausserDienstBis) : null,
|
||||
);
|
||||
|
||||
// Fire-and-forget: notify scheduled messages when vehicle goes out of service
|
||||
if (parsed.data.status !== FahrzeugStatus.Einsatzbereit) {
|
||||
scheduledMessagesService.sendVehicleEvent(id).catch(err => {
|
||||
logger.error('Failed to send vehicle event notification', {
|
||||
vehicleId: id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
if (error?.message === 'Vehicle not found') {
|
||||
|
||||
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 { startChecklistReminderJob, stopChecklistReminderJob } from './jobs/checklist-reminder.job';
|
||||
import { startBuchhaltungRecurringJob, stopBuchhaltungRecurringJob } from './jobs/buchhaltung-recurring.job';
|
||||
import { startScheduledMessagesJob, stopScheduledMessagesJob } from './jobs/scheduled-messages.job';
|
||||
import { permissionService } from './services/permission.service';
|
||||
|
||||
const startServer = async (): Promise<void> => {
|
||||
@@ -44,6 +45,9 @@ const startServer = async (): Promise<void> => {
|
||||
// Start the buchhaltung recurring transaction job
|
||||
startBuchhaltungRecurringJob();
|
||||
|
||||
// Start the scheduled messages job
|
||||
startScheduledMessagesJob();
|
||||
|
||||
// Start the server
|
||||
const server = app.listen(environment.port, () => {
|
||||
logger.info('Server started successfully', {
|
||||
@@ -71,6 +75,7 @@ const startServer = async (): Promise<void> => {
|
||||
stopIssueReminderJob();
|
||||
stopChecklistReminderJob();
|
||||
stopBuchhaltungRecurringJob();
|
||||
stopScheduledMessagesJob();
|
||||
|
||||
server.close(async () => {
|
||||
logger.info('HTTP server closed');
|
||||
|
||||
@@ -378,16 +378,16 @@ async function updateType(
|
||||
}
|
||||
}
|
||||
|
||||
async function deactivateType(id: number) {
|
||||
async function deleteType(id: number) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`UPDATE issue_typen SET aktiv = false WHERE id = $1 RETURNING *`,
|
||||
`DELETE FROM issue_typen WHERE id = $1 RETURNING *`,
|
||||
[id]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
} catch (error) {
|
||||
logger.error('IssueService.deactivateType failed', { error, id });
|
||||
throw new Error('Issue-Typ konnte nicht deaktiviert werden');
|
||||
logger.error('IssueService.deleteType failed', { error, id });
|
||||
throw new Error('Issue-Typ konnte nicht gelöscht werden');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,13 +514,13 @@ async function updateIssueStatus(id: number, data: {
|
||||
async function deleteIssueStatus(id: number) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`UPDATE issue_statuses SET aktiv = false WHERE id = $1 RETURNING *`,
|
||||
`DELETE FROM issue_statuses WHERE id = $1 RETURNING *`,
|
||||
[id]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
} catch (error) {
|
||||
logger.error('IssueService.deleteIssueStatus failed', { error, id });
|
||||
throw new Error('Issue-Status konnte nicht deaktiviert werden');
|
||||
throw new Error('Issue-Status konnte nicht gelöscht werden');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -591,13 +591,13 @@ async function updateIssuePriority(id: number, data: {
|
||||
async function deleteIssuePriority(id: number) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`UPDATE issue_prioritaeten SET aktiv = false WHERE id = $1 RETURNING *`,
|
||||
`DELETE FROM issue_prioritaeten WHERE id = $1 RETURNING *`,
|
||||
[id]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
} catch (error) {
|
||||
logger.error('IssueService.deleteIssuePriority failed', { error, id });
|
||||
throw new Error('Priorität konnte nicht deaktiviert werden');
|
||||
throw new Error('Priorität konnte nicht gelöscht werden');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -699,7 +699,7 @@ export default {
|
||||
getTypes,
|
||||
createType,
|
||||
updateType,
|
||||
deactivateType,
|
||||
deleteType,
|
||||
getAssignableMembers,
|
||||
getIssueCounts,
|
||||
getIssueStatuses,
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user