Files
dashboard/backend/src/services/scheduledMessages.service.ts
2026-04-17 14:28:02 +02:00

650 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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[];
error?: string;
}
// ── Helpers ──────────────────────────────────────────────────────────────────
async function getBotCredentials(): Promise<{ username: string; appPassword: string } | null> {
const setting = await settingsService.get('tool_config_nextcloud');
if (!setting?.value || typeof setting.value !== 'object') return null;
const cfg = setting.value as Record<string, unknown>;
const username = typeof cfg.bot_username === 'string' ? cfg.bot_username : null;
const appPassword = typeof cfg.bot_app_password === 'string' ? cfg.bot_app_password : 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 abgesagt = FALSE
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 u.given_name AS vorname, u.family_name AS nachname, mp.geburtsdatum
FROM mitglieder_profile mp
JOIN users u ON u.id = mp.user_id
WHERE mp.status IN ('aktiv', 'jugendfeuerwehr', 'ehrenmitglied')
AND mp.geburtsdatum IS NOT NULL
AND (
(EXTRACT(MONTH FROM mp.geburtsdatum), EXTRACT(DAY FROM mp.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 mp.geburtsdatum), EXTRACT(DAY FROM mp.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');
const age = startDate.getFullYear() - geb.getFullYear();
return `- ${r.vorname as string} ${r.nachname as string} (${day}.${month}., wird ${age})`;
});
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 u.given_name AS vorname, u.family_name AS nachname, mp.eintrittsdatum
FROM mitglieder_profile mp
JOIN users u ON u.id = mp.user_id
WHERE mp.status IN ('aktiv', 'ehrenmitglied')
AND mp.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 ────────────────────────────────────────────────────────────────────
let roomsCache: { result: RoomsResult; expiresAt: number } | null = null;
async function getRooms(): Promise<RoomsResult> {
if (roomsCache && Date.now() < roomsCache.expiresAt) {
return roomsCache.result;
}
const creds = await getBotCredentials();
if (!creds) {
return { configured: false };
}
try {
const conversations = await nextcloudService.getAllConversations(creds.username, creds.appPassword);
const result: RoomsResult = {
configured: true,
data: conversations.map(c => ({
token: c.token,
displayName: c.displayName,
type: c.type,
})),
};
roomsCache = { result, expiresAt: Date.now() + 60_000 };
return result;
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
logger.error('scheduledMessages.getRooms failed', { error: msg });
return { configured: true, data: [], error: 'Verbindung zu Nextcloud fehlgeschlagen' };
}
}
// ── 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)}`,
date_range: `${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),
});
}
}
// ── Bot Room ──────────────────────────────────────────────────────────────────
/**
* Finds or creates a 1:1 Nextcloud Talk room between the bot and the given user.
* Returns the room token, null if bot isn't configured, or 'not_connected' if the
* user hasn't linked their Nextcloud account.
*/
async function getOrCreateBotRoom(userId: string): Promise<string | null | 'not_connected'> {
const creds = await getBotCredentials();
if (!creds) return null;
// Look up the user's Nextcloud login name
const userResult = await pool.query(
'SELECT nextcloud_login_name FROM users WHERE id = $1',
[userId],
);
const nextcloudLoginName = userResult.rows[0]?.nextcloud_login_name as string | null;
if (!nextcloudLoginName) return 'not_connected';
// Use createRoom(type=1) — Nextcloud returns existing 1:1 if it already exists
const { token } = await nextcloudService.createRoom(
1,
nextcloudLoginName,
undefined,
creds.username,
creds.appPassword,
);
return token;
}
interface OneTimeMessage {
id: string;
message: string;
target_room_token: string;
target_room_name: string | null;
send_at: string;
created_by: string | null;
created_at: string;
}
async function getOneTimeMessages(): Promise<OneTimeMessage[]> {
const result = await pool.query(
'SELECT * FROM scheduled_one_time_messages ORDER BY send_at ASC',
);
return result.rows;
}
async function createOneTimeMessage(
data: Pick<OneTimeMessage, 'message' | 'target_room_token' | 'target_room_name' | 'send_at'>,
userId: string,
): Promise<OneTimeMessage> {
const result = await pool.query(
`INSERT INTO scheduled_one_time_messages
(message, target_room_token, target_room_name, send_at, created_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[data.message, data.target_room_token, data.target_room_name ?? null, data.send_at, userId],
);
return result.rows[0];
}
async function deleteOneTimeMessage(id: string): Promise<boolean> {
const result = await pool.query(
'DELETE FROM scheduled_one_time_messages WHERE id = $1',
[id],
);
return (result.rowCount ?? 0) > 0;
}
async function sendDueOneTimeMessages(): Promise<void> {
const creds = await getBotCredentials();
if (!creds) return;
const result = await pool.query(
`SELECT * FROM scheduled_one_time_messages WHERE send_at <= NOW() ORDER BY send_at ASC`,
);
for (const row of result.rows as OneTimeMessage[]) {
try {
await nextcloudService.sendMessage(row.target_room_token, row.message, creds.username, creds.appPassword);
await pool.query('DELETE FROM scheduled_one_time_messages WHERE id = $1', [row.id]);
logger.info('scheduledMessages: sent one-time message', { id: row.id, room: row.target_room_token });
} catch (error) {
logger.error('scheduledMessages: failed to send one-time message', {
id: row.id,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
// ── Export ────────────────────────────────────────────────────────────────────
const scheduledMessagesService = {
getAll,
getById,
create,
update,
delete: remove,
getRooms,
subscribe,
unsubscribe,
getSubscriptionsForUser,
buildAndSend,
sendVehicleEvent,
getOrCreateBotRoom,
getOneTimeMessages,
createOneTimeMessage,
deleteOneTimeMessage,
sendDueOneTimeMessages,
};
export type { ScheduledMessageRule, RoomInfo, RoomsResult, OneTimeMessage };
export default scheduledMessagesService;