650 lines
23 KiB
TypeScript
650 lines
23 KiB
TypeScript
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;
|