add: add feature to schedule messages
This commit is contained in:
@@ -133,6 +133,49 @@ class ScheduledMessagesController {
|
|||||||
res.status(500).json({ success: false, message: 'Nachricht konnte nicht gesendet werden' });
|
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();
|
export default new ScheduledMessagesController();
|
||||||
|
|||||||
16
backend/src/database/migrations/096_one_time_messages.sql
Normal file
16
backend/src/database/migrations/096_one_time_messages.sql
Normal 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);
|
||||||
@@ -81,6 +81,9 @@ async function runScheduledMessagesCheck(): Promise<void> {
|
|||||||
if (processed > 0) {
|
if (processed > 0) {
|
||||||
logger.info(`ScheduledMessagesJob: processed ${processed} rules`);
|
logger.info(`ScheduledMessagesJob: processed ${processed} rules`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check one-time messages
|
||||||
|
await scheduledMessagesService.sendDueOneTimeMessages();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('ScheduledMessagesJob: unexpected error', {
|
logger.error('ScheduledMessagesJob: unexpected error', {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { requirePermission } from '../middleware/rbac.middleware';
|
|||||||
|
|
||||||
const router = Router();
|
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(
|
router.get(
|
||||||
'/rooms',
|
'/rooms',
|
||||||
authenticate,
|
authenticate,
|
||||||
@@ -13,6 +13,27 @@ router.get(
|
|||||||
scheduledMessagesController.getRooms.bind(scheduledMessagesController),
|
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(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
authenticate,
|
authenticate,
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────────────────────────────
|
// ── Export ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const scheduledMessagesService = {
|
const scheduledMessagesService = {
|
||||||
@@ -535,7 +598,11 @@ const scheduledMessagesService = {
|
|||||||
getSubscriptionsForUser,
|
getSubscriptionsForUser,
|
||||||
buildAndSend,
|
buildAndSend,
|
||||||
sendVehicleEvent,
|
sendVehicleEvent,
|
||||||
|
getOneTimeMessages,
|
||||||
|
createOneTimeMessage,
|
||||||
|
deleteOneTimeMessage,
|
||||||
|
sendDueOneTimeMessages,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type { ScheduledMessageRule, RoomInfo, RoomsResult };
|
export type { ScheduledMessageRule, RoomInfo, RoomsResult, OneTimeMessage };
|
||||||
export default scheduledMessagesService;
|
export default scheduledMessagesService;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
ScheduledMessagesListResponse,
|
ScheduledMessagesListResponse,
|
||||||
ScheduledMessageDetailResponse,
|
ScheduledMessageDetailResponse,
|
||||||
RoomsResponse,
|
RoomsResponse,
|
||||||
|
OneTimeMessage,
|
||||||
} from '../types/scheduledMessages.types';
|
} from '../types/scheduledMessages.types';
|
||||||
|
|
||||||
export const scheduledMessagesApi = {
|
export const scheduledMessagesApi = {
|
||||||
@@ -33,4 +34,18 @@ export const scheduledMessagesApi = {
|
|||||||
|
|
||||||
trigger: (id: string) =>
|
trigger: (id: string) =>
|
||||||
api.post(`/scheduled-messages/${id}/trigger`).then(r => r.data),
|
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),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,3 +51,13 @@ export interface RoomsResponse {
|
|||||||
data?: NextcloudRoom[];
|
data?: NextcloudRoom[];
|
||||||
error?: string;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user