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

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

View File

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

View 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;