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 | 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; 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 { 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) => { 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) => { 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) => { 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) => { 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 { 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 { 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, userId: string, ): Promise { 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>, ): Promise { const keys = Object.keys(data) as Array; 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 { 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 { 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 { 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 { await pool.query( 'DELETE FROM scheduled_message_subscriptions WHERE rule_id = $1 AND user_id = $2', [ruleId, userId], ); } async function getSubscriptionsForUser(userId: string): Promise> { 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 { const creds = await getBotCredentials(); if (!creds) { logger.warn('scheduledMessages.buildAndSend: no bot credentials configured'); return; } let vars: Record; 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 = { 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 { 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 = { 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 { 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 { const result = await pool.query( 'SELECT * FROM scheduled_one_time_messages ORDER BY send_at ASC', ); return result.rows; } async function createOneTimeMessage( data: Pick, userId: string, ): Promise { 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 { const result = await pool.query( 'DELETE FROM scheduled_one_time_messages WHERE id = $1', [id], ); return (result.rowCount ?? 0) > 0; } async function sendDueOneTimeMessages(): Promise { 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;