add: add feature to schedule messages

This commit is contained in:
Matthias Hochmeister
2026-04-17 10:41:00 +02:00
parent 5811ac201e
commit b91cf88812
7 changed files with 177 additions and 2 deletions

View File

@@ -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<void> {
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<void> {
try {
const { message, target_room_token, target_room_name, send_at } = req.body as Record<string, string>;
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<void> {
try {
const { id } = req.params as Record<string, string>;
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();

View File

@@ -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);

View File

@@ -81,6 +81,9 @@ async function runScheduledMessagesCheck(): Promise<void> {
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),

View File

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

View File

@@ -521,6 +521,69 @@ async function sendVehicleEvent(vehicleId: string): Promise<void> {
}
}
// ── 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<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 = {
@@ -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;

View File

@@ -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),
};

View File

@@ -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;
}