From b91cf888128355f327d0c98b4637e8a39d1ea815 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Fri, 17 Apr 2026 10:41:00 +0200 Subject: [PATCH] add: add feature to schedule messages --- .../scheduledMessages.controller.ts | 43 ++++++++++++ .../migrations/096_one_time_messages.sql | 16 +++++ backend/src/jobs/scheduled-messages.job.ts | 3 + .../src/routes/scheduledMessages.routes.ts | 23 ++++++- .../src/services/scheduledMessages.service.ts | 69 ++++++++++++++++++- frontend/src/services/scheduledMessages.ts | 15 ++++ frontend/src/types/scheduledMessages.types.ts | 10 +++ 7 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 backend/src/database/migrations/096_one_time_messages.sql diff --git a/backend/src/controllers/scheduledMessages.controller.ts b/backend/src/controllers/scheduledMessages.controller.ts index aaf443c..11fbca4 100644 --- a/backend/src/controllers/scheduledMessages.controller.ts +++ b/backend/src/controllers/scheduledMessages.controller.ts @@ -133,6 +133,49 @@ class ScheduledMessagesController { res.status(500).json({ success: false, message: 'Nachricht konnte nicht gesendet werden' }); } } + + async getOneTimeMessages(_req: Request, res: Response): Promise { + try { + const messages = await scheduledMessagesService.getOneTimeMessages(); + res.json({ success: true, data: messages }); + } catch (error) { + logger.error('ScheduledMessagesController.getOneTimeMessages error', { error }); + res.status(500).json({ success: false, message: 'Einzelnachrichten konnten nicht geladen werden' }); + } + } + + async createOneTimeMessage(req: Request, res: Response): Promise { + try { + const { message, target_room_token, target_room_name, send_at } = req.body as Record; + if (!message || !target_room_token || !send_at) { + res.status(400).json({ success: false, message: 'message, target_room_token und send_at sind erforderlich' }); + return; + } + const msg = await scheduledMessagesService.createOneTimeMessage( + { message, target_room_token, target_room_name: target_room_name ?? null, send_at }, + req.user!.id, + ); + res.status(201).json({ success: true, data: msg }); + } catch (error) { + logger.error('ScheduledMessagesController.createOneTimeMessage error', { error }); + res.status(500).json({ success: false, message: 'Einzelnachricht konnte nicht erstellt werden' }); + } + } + + async deleteOneTimeMessage(req: Request, res: Response): Promise { + try { + const { id } = req.params as Record; + const deleted = await scheduledMessagesService.deleteOneTimeMessage(id); + if (!deleted) { + res.status(404).json({ success: false, message: 'Einzelnachricht nicht gefunden' }); + return; + } + res.status(204).send(); + } catch (error) { + logger.error('ScheduledMessagesController.deleteOneTimeMessage error', { error, id: req.params.id }); + res.status(500).json({ success: false, message: 'Einzelnachricht konnte nicht gelöscht werden' }); + } + } } export default new ScheduledMessagesController(); diff --git a/backend/src/database/migrations/096_one_time_messages.sql b/backend/src/database/migrations/096_one_time_messages.sql new file mode 100644 index 0000000..8f3c642 --- /dev/null +++ b/backend/src/database/migrations/096_one_time_messages.sql @@ -0,0 +1,16 @@ +-- Migration 096: One-time scheduled messages +-- Lightweight table for single-delivery messages at a specific datetime. +-- Rows are deleted automatically by the job after successful send. + +CREATE TABLE IF NOT EXISTS scheduled_one_time_messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + message TEXT NOT NULL, + target_room_token TEXT NOT NULL, + target_room_name TEXT, + send_at TIMESTAMPTZ NOT NULL, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_sotm_send_at + ON scheduled_one_time_messages(send_at); diff --git a/backend/src/jobs/scheduled-messages.job.ts b/backend/src/jobs/scheduled-messages.job.ts index 1cbb603..67082db 100644 --- a/backend/src/jobs/scheduled-messages.job.ts +++ b/backend/src/jobs/scheduled-messages.job.ts @@ -81,6 +81,9 @@ async function runScheduledMessagesCheck(): Promise { if (processed > 0) { logger.info(`ScheduledMessagesJob: processed ${processed} rules`); } + + // Check one-time messages + await scheduledMessagesService.sendDueOneTimeMessages(); } catch (error) { logger.error('ScheduledMessagesJob: unexpected error', { error: error instanceof Error ? error.message : String(error), diff --git a/backend/src/routes/scheduledMessages.routes.ts b/backend/src/routes/scheduledMessages.routes.ts index d3ea8d0..e97ef06 100644 --- a/backend/src/routes/scheduledMessages.routes.ts +++ b/backend/src/routes/scheduledMessages.routes.ts @@ -5,7 +5,7 @@ import { requirePermission } from '../middleware/rbac.middleware'; const router = Router(); -// /rooms MUST come before /:id to avoid being captured as an id param +// /rooms and /one-time MUST come before /:id to avoid being captured as an id param router.get( '/rooms', authenticate, @@ -13,6 +13,27 @@ router.get( scheduledMessagesController.getRooms.bind(scheduledMessagesController), ); +router.get( + '/one-time', + authenticate, + requirePermission('scheduled_messages:edit'), + scheduledMessagesController.getOneTimeMessages.bind(scheduledMessagesController), +); + +router.post( + '/one-time', + authenticate, + requirePermission('scheduled_messages:edit'), + scheduledMessagesController.createOneTimeMessage.bind(scheduledMessagesController), +); + +router.delete( + '/one-time/:id', + authenticate, + requirePermission('scheduled_messages:edit'), + scheduledMessagesController.deleteOneTimeMessage.bind(scheduledMessagesController), +); + router.get( '/', authenticate, diff --git a/backend/src/services/scheduledMessages.service.ts b/backend/src/services/scheduledMessages.service.ts index 3ef31a0..f684dd7 100644 --- a/backend/src/services/scheduledMessages.service.ts +++ b/backend/src/services/scheduledMessages.service.ts @@ -521,6 +521,69 @@ async function sendVehicleEvent(vehicleId: string): Promise { } } +// ── One-Time Messages ───────────────────────────────────────────────────────── + +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 = { @@ -535,7 +598,11 @@ const scheduledMessagesService = { getSubscriptionsForUser, buildAndSend, sendVehicleEvent, + getOneTimeMessages, + createOneTimeMessage, + deleteOneTimeMessage, + sendDueOneTimeMessages, }; -export type { ScheduledMessageRule, RoomInfo, RoomsResult }; +export type { ScheduledMessageRule, RoomInfo, RoomsResult, OneTimeMessage }; export default scheduledMessagesService; diff --git a/frontend/src/services/scheduledMessages.ts b/frontend/src/services/scheduledMessages.ts index e30f62b..adb9836 100644 --- a/frontend/src/services/scheduledMessages.ts +++ b/frontend/src/services/scheduledMessages.ts @@ -4,6 +4,7 @@ import type { ScheduledMessagesListResponse, ScheduledMessageDetailResponse, RoomsResponse, + OneTimeMessage, } from '../types/scheduledMessages.types'; export const scheduledMessagesApi = { @@ -33,4 +34,18 @@ export const scheduledMessagesApi = { trigger: (id: string) => api.post(`/scheduled-messages/${id}/trigger`).then(r => r.data), + + getOneTimeMessages: () => + api.get<{ data: OneTimeMessage[] }>('/scheduled-messages/one-time').then(r => r.data), + + createOneTimeMessage: (data: { + message: string; + target_room_token: string; + target_room_name?: string; + send_at: string; + }) => + api.post<{ data: OneTimeMessage }>('/scheduled-messages/one-time', data).then(r => r.data), + + deleteOneTimeMessage: (id: string) => + api.delete(`/scheduled-messages/one-time/${id}`).then(r => r.data), }; diff --git a/frontend/src/types/scheduledMessages.types.ts b/frontend/src/types/scheduledMessages.types.ts index 2db2a89..3d68c03 100644 --- a/frontend/src/types/scheduledMessages.types.ts +++ b/frontend/src/types/scheduledMessages.types.ts @@ -51,3 +51,13 @@ export interface RoomsResponse { data?: NextcloudRoom[]; error?: string; } + +export 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; +}