adding chat features, admin features and bug fixes
This commit is contained in:
@@ -83,6 +83,8 @@ import bookingRoutes from './routes/booking.routes';
|
||||
import notificationRoutes from './routes/notification.routes';
|
||||
import bookstackRoutes from './routes/bookstack.routes';
|
||||
import vikunjaRoutes from './routes/vikunja.routes';
|
||||
import configRoutes from './routes/config.routes';
|
||||
import serviceMonitorRoutes from './routes/serviceMonitor.routes';
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/user', userRoutes);
|
||||
@@ -99,6 +101,8 @@ app.use('/api/bookings', bookingRoutes);
|
||||
app.use('/api/notifications', notificationRoutes);
|
||||
app.use('/api/bookstack', bookstackRoutes);
|
||||
app.use('/api/vikunja', vikunjaRoutes);
|
||||
app.use('/api/config', configRoutes);
|
||||
app.use('/api/admin', serviceMonitorRoutes);
|
||||
|
||||
// 404 handler
|
||||
app.use(notFoundHandler);
|
||||
|
||||
@@ -40,6 +40,24 @@ class BookStackController {
|
||||
res.status(500).json({ success: false, message: 'BookStack-Suche fehlgeschlagen' });
|
||||
}
|
||||
}
|
||||
async getPage(req: Request, res: Response): Promise<void> {
|
||||
if (!environment.bookstack.url) {
|
||||
res.status(200).json({ success: true, data: null, configured: false });
|
||||
return;
|
||||
}
|
||||
const id = parseInt(String(req.params.id), 10);
|
||||
if (isNaN(id) || id <= 0) {
|
||||
res.status(400).json({ success: false, message: 'Ungültige Seiten-ID' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const page = await bookstackService.getPageById(id);
|
||||
res.status(200).json({ success: true, data: page, configured: true });
|
||||
} catch (error) {
|
||||
logger.error('BookStackController.getPage error', { error });
|
||||
res.status(500).json({ success: false, message: 'BookStack-Seite konnte nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new BookStackController();
|
||||
|
||||
14
backend/src/controllers/config.controller.ts
Normal file
14
backend/src/controllers/config.controller.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Request, Response } from 'express';
|
||||
import environment from '../config/environment';
|
||||
|
||||
class ConfigController {
|
||||
async getExternalLinks(_req: Request, res: Response): Promise<void> {
|
||||
const links: Record<string, string> = {};
|
||||
if (environment.nextcloudUrl) links.nextcloud = environment.nextcloudUrl;
|
||||
if (environment.bookstack.url) links.bookstack = environment.bookstack.url;
|
||||
if (environment.vikunja.url) links.vikunja = environment.vikunja.url;
|
||||
res.status(200).json({ success: true, data: links });
|
||||
}
|
||||
}
|
||||
|
||||
export default new ConfigController();
|
||||
@@ -80,6 +80,91 @@ class NextcloudController {
|
||||
res.status(500).json({ success: false, message: 'Nextcloud-Trennung fehlgeschlagen' });
|
||||
}
|
||||
}
|
||||
|
||||
async getRooms(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||
if (!credentials) {
|
||||
res.status(200).json({ success: true, data: { connected: false, rooms: [] } });
|
||||
return;
|
||||
}
|
||||
const rooms = await nextcloudService.getAllConversations(credentials.loginName, credentials.appPassword);
|
||||
res.status(200).json({ success: true, data: { connected: true, rooms, loginName: credentials.loginName } });
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
|
||||
await userService.clearNextcloudCredentials(req.user!.id);
|
||||
res.status(200).json({ success: true, data: { connected: false, rooms: [] } });
|
||||
return;
|
||||
}
|
||||
logger.error('getRooms error', { error });
|
||||
res.status(500).json({ success: false, message: 'Nextcloud-Räume konnten nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async getMessages(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||
if (!credentials) {
|
||||
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||
return;
|
||||
}
|
||||
const token = req.params.token as string;
|
||||
if (!token) {
|
||||
res.status(400).json({ success: false, message: 'Room token fehlt' });
|
||||
return;
|
||||
}
|
||||
const messages = await nextcloudService.getMessages(token, credentials.loginName, credentials.appPassword);
|
||||
res.status(200).json({ success: true, data: messages });
|
||||
} catch (error) {
|
||||
logger.error('getMessages error', { error });
|
||||
res.status(500).json({ success: false, message: 'Nachrichten konnten nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||
if (!credentials) {
|
||||
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||
return;
|
||||
}
|
||||
const token = req.params.token as string;
|
||||
const { message } = req.body;
|
||||
if (!token || !message || typeof message !== 'string' || message.trim().length === 0) {
|
||||
res.status(400).json({ success: false, message: 'Token und Nachricht erforderlich' });
|
||||
return;
|
||||
}
|
||||
if (message.length > 32000) {
|
||||
res.status(400).json({ success: false, message: 'Nachricht zu lang' });
|
||||
return;
|
||||
}
|
||||
await nextcloudService.sendMessage(token, message.trim(), credentials.loginName, credentials.appPassword);
|
||||
res.status(200).json({ success: true, data: null });
|
||||
} catch (error) {
|
||||
logger.error('sendMessage error', { error });
|
||||
res.status(500).json({ success: false, message: 'Nachricht konnte nicht gesendet werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async markRoomAsRead(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||
if (!credentials) {
|
||||
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||
return;
|
||||
}
|
||||
const token = req.params.token as string;
|
||||
if (!token) {
|
||||
res.status(400).json({ success: false, message: 'Room token fehlt' });
|
||||
return;
|
||||
}
|
||||
await nextcloudService.markAsRead(token, credentials.loginName, credentials.appPassword);
|
||||
res.status(200).json({ success: true, data: null });
|
||||
} catch (error) {
|
||||
logger.error('markRoomAsRead error', { error });
|
||||
res.status(500).json({ success: false, message: 'Raum konnte nicht als gelesen markiert werden' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new NextcloudController();
|
||||
|
||||
192
backend/src/controllers/serviceMonitor.controller.ts
Normal file
192
backend/src/controllers/serviceMonitor.controller.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import serviceMonitorService from '../services/serviceMonitor.service';
|
||||
import notificationService from '../services/notification.service';
|
||||
import pool from '../config/database';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
const createServiceSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
url: z.string().url().max(500),
|
||||
});
|
||||
|
||||
const updateServiceSchema = z.object({
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
url: z.string().url().max(500).optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const broadcastSchema = z.object({
|
||||
titel: z.string().min(1).max(200),
|
||||
nachricht: z.string().min(1).max(2000),
|
||||
schwere: z.enum(['info', 'warnung', 'fehler']).default('info'),
|
||||
targetGroup: z.string().optional(),
|
||||
});
|
||||
|
||||
class ServiceMonitorController {
|
||||
async getAll(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const services = await serviceMonitorService.getAllServices();
|
||||
res.json({ success: true, data: services });
|
||||
} catch (error) {
|
||||
logger.error('Failed to get services', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to get services' });
|
||||
}
|
||||
}
|
||||
|
||||
async create(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { name, url } = createServiceSchema.parse(req.body);
|
||||
const service = await serviceMonitorService.createService(name, url);
|
||||
res.status(201).json({ success: true, data: service });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({ success: false, message: 'Invalid input', errors: error.issues });
|
||||
return;
|
||||
}
|
||||
logger.error('Failed to create service', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to create service' });
|
||||
}
|
||||
}
|
||||
|
||||
async update(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const data = updateServiceSchema.parse(req.body);
|
||||
const service = await serviceMonitorService.updateService(req.params.id as string, data);
|
||||
res.json({ success: true, data: service });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({ success: false, message: 'Invalid input', errors: error.issues });
|
||||
return;
|
||||
}
|
||||
logger.error('Failed to update service', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to update service' });
|
||||
}
|
||||
}
|
||||
|
||||
async delete(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const deleted = await serviceMonitorService.deleteService(req.params.id as string);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ success: false, message: 'Service not found or is internal' });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete service', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to delete service' });
|
||||
}
|
||||
}
|
||||
|
||||
async pingAll(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const results = await serviceMonitorService.pingAll();
|
||||
res.json({ success: true, data: results });
|
||||
} catch (error) {
|
||||
logger.error('Failed to ping services', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to ping services' });
|
||||
}
|
||||
}
|
||||
|
||||
async getStatusSummary(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const summary = await serviceMonitorService.getStatusSummary();
|
||||
res.json({ success: true, data: summary });
|
||||
} catch (error) {
|
||||
logger.error('Failed to get status summary', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to get status summary' });
|
||||
}
|
||||
}
|
||||
|
||||
async getSystemHealth(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
let dbStatus = false;
|
||||
let dbSize = '0';
|
||||
|
||||
try {
|
||||
await pool.query('SELECT 1');
|
||||
dbStatus = true;
|
||||
const sizeResult = await pool.query('SELECT pg_database_size(current_database()) as size');
|
||||
dbSize = sizeResult.rows[0].size;
|
||||
} catch {
|
||||
// DB is down
|
||||
}
|
||||
|
||||
const mem = process.memoryUsage();
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
nodeVersion: process.version,
|
||||
uptime: process.uptime(),
|
||||
memoryUsage: {
|
||||
heapUsed: mem.heapUsed,
|
||||
heapTotal: mem.heapTotal,
|
||||
rss: mem.rss,
|
||||
external: mem.external,
|
||||
},
|
||||
dbStatus,
|
||||
dbSize,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get system health', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to get system health' });
|
||||
}
|
||||
}
|
||||
|
||||
async getUsers(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT id, email, name, role, authentik_groups as groups, is_active, last_login_at
|
||||
FROM users ORDER BY name`
|
||||
);
|
||||
res.json({ success: true, data: result.rows });
|
||||
} catch (error) {
|
||||
logger.error('Failed to get users', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to get users' });
|
||||
}
|
||||
}
|
||||
|
||||
async broadcastNotification(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { titel, nachricht, schwere, targetGroup } = broadcastSchema.parse(req.body);
|
||||
|
||||
let users;
|
||||
if (targetGroup) {
|
||||
const result = await pool.query(
|
||||
`SELECT id FROM users WHERE is_active = TRUE AND $1 = ANY(authentik_groups)`,
|
||||
[targetGroup]
|
||||
);
|
||||
users = result.rows;
|
||||
} else {
|
||||
const result = await pool.query(
|
||||
`SELECT id FROM users WHERE is_active = TRUE`
|
||||
);
|
||||
users = result.rows;
|
||||
}
|
||||
|
||||
let sent = 0;
|
||||
for (const user of users) {
|
||||
await notificationService.createNotification({
|
||||
user_id: user.id,
|
||||
typ: 'broadcast',
|
||||
titel,
|
||||
nachricht,
|
||||
schwere,
|
||||
});
|
||||
sent++;
|
||||
}
|
||||
|
||||
res.json({ success: true, data: { sent } });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({ success: false, message: 'Invalid input', errors: error.issues });
|
||||
return;
|
||||
}
|
||||
logger.error('Failed to broadcast notification', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to broadcast notification' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ServiceMonitorController();
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS monitored_services (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(200) NOT NULL,
|
||||
url VARCHAR(500) NOT NULL,
|
||||
type VARCHAR(20) NOT NULL DEFAULT 'custom' CHECK (type IN ('internal','custom')),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE OR REPLACE TRIGGER update_monitored_services_updated_at
|
||||
BEFORE UPDATE ON monitored_services
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
@@ -6,5 +6,6 @@ const router = Router();
|
||||
|
||||
router.get('/recent', authenticate, bookstackController.getRecent.bind(bookstackController));
|
||||
router.get('/search', authenticate, bookstackController.search.bind(bookstackController));
|
||||
router.get('/pages/:id', authenticate, bookstackController.getPage.bind(bookstackController));
|
||||
|
||||
export default router;
|
||||
|
||||
8
backend/src/routes/config.routes.ts
Normal file
8
backend/src/routes/config.routes.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Router } from 'express';
|
||||
import configController from '../controllers/config.controller';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
|
||||
const router = Router();
|
||||
router.get('/external-links', authenticate, configController.getExternalLinks.bind(configController));
|
||||
|
||||
export default router;
|
||||
@@ -9,4 +9,9 @@ router.post('/connect', authenticate, nextcloudController.initiateConnect.bind(n
|
||||
router.post('/poll', authenticate, nextcloudController.pollConnect.bind(nextcloudController));
|
||||
router.delete('/connect', authenticate, nextcloudController.disconnect.bind(nextcloudController));
|
||||
|
||||
router.get('/rooms', authenticate, nextcloudController.getRooms.bind(nextcloudController));
|
||||
router.get('/rooms/:token/messages', authenticate, nextcloudController.getMessages.bind(nextcloudController));
|
||||
router.post('/rooms/:token/messages', authenticate, nextcloudController.sendMessage.bind(nextcloudController));
|
||||
router.post('/rooms/:token/read', authenticate, nextcloudController.markRoomAsRead.bind(nextcloudController));
|
||||
|
||||
export default router;
|
||||
|
||||
20
backend/src/routes/serviceMonitor.routes.ts
Normal file
20
backend/src/routes/serviceMonitor.routes.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Router } from 'express';
|
||||
import serviceMonitorController from '../controllers/serviceMonitor.controller';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
import { requirePermission } from '../middleware/rbac.middleware';
|
||||
|
||||
const router = Router();
|
||||
const auth = [authenticate, requirePermission('admin:access')] as const;
|
||||
|
||||
// Static routes first (before parameterized :id routes)
|
||||
router.get('/services/ping', ...auth, serviceMonitorController.pingAll.bind(serviceMonitorController));
|
||||
router.get('/services/status-summary', ...auth, serviceMonitorController.getStatusSummary.bind(serviceMonitorController));
|
||||
router.get('/services', ...auth, serviceMonitorController.getAll.bind(serviceMonitorController));
|
||||
router.post('/services', ...auth, serviceMonitorController.create.bind(serviceMonitorController));
|
||||
router.put('/services/:id', ...auth, serviceMonitorController.update.bind(serviceMonitorController));
|
||||
router.delete('/services/:id', ...auth, serviceMonitorController.delete.bind(serviceMonitorController));
|
||||
router.get('/system/health', ...auth, serviceMonitorController.getSystemHealth.bind(serviceMonitorController));
|
||||
router.get('/users', ...auth, serviceMonitorController.getUsers.bind(serviceMonitorController));
|
||||
router.post('/notifications/broadcast', ...auth, serviceMonitorController.broadcastNotification.bind(serviceMonitorController));
|
||||
|
||||
export default router;
|
||||
@@ -151,4 +151,59 @@ async function searchPages(query: string): Promise<BookStackSearchResult[]> {
|
||||
}
|
||||
}
|
||||
|
||||
export default { getRecentPages, searchPages };
|
||||
export interface BookStackPageDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
book_id: number;
|
||||
book_slug: string;
|
||||
chapter_id: number;
|
||||
html: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
url: string;
|
||||
book?: { name: string };
|
||||
createdBy?: { name: string };
|
||||
updatedBy?: { name: string };
|
||||
}
|
||||
|
||||
async function getPageById(id: number): Promise<BookStackPageDetail> {
|
||||
const { bookstack } = environment;
|
||||
if (!bookstack.url || !isValidServiceUrl(bookstack.url)) {
|
||||
throw new Error('BOOKSTACK_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${bookstack.url}/api/pages/${id}`,
|
||||
{ headers: buildHeaders() },
|
||||
);
|
||||
const page = response.data;
|
||||
return {
|
||||
id: page.id,
|
||||
name: page.name,
|
||||
slug: page.slug,
|
||||
book_id: page.book_id,
|
||||
book_slug: page.book_slug ?? '',
|
||||
chapter_id: page.chapter_id ?? 0,
|
||||
html: page.html ?? '',
|
||||
created_at: page.created_at,
|
||||
updated_at: page.updated_at,
|
||||
url: `${bookstack.url}/books/${page.book_slug}/page/${page.slug}`,
|
||||
book: page.book,
|
||||
createdBy: page.created_by,
|
||||
updatedBy: page.updated_by,
|
||||
};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
logger.error('BookStack getPageById failed', {
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
});
|
||||
}
|
||||
logger.error('BookStackService.getPageById failed', { error });
|
||||
throw new Error('Failed to fetch BookStack page');
|
||||
}
|
||||
}
|
||||
|
||||
export default { getRecentPages, searchPages, getPageById };
|
||||
|
||||
@@ -127,6 +127,143 @@ async function pollLoginFlow(pollEndpoint: string, pollToken: string): Promise<L
|
||||
}
|
||||
}
|
||||
|
||||
interface NextcloudChatMessage {
|
||||
id: number;
|
||||
token: string;
|
||||
actorType: string;
|
||||
actorId: string;
|
||||
actorDisplayName: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
messageType: string;
|
||||
systemMessage: string;
|
||||
}
|
||||
|
||||
async function getAllConversations(loginName: string, appPassword: string): Promise<NextcloudConversation[]> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/room?format=json`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`,
|
||||
'OCS-APIRequest': 'true',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const rooms: any[] = response.data?.ocs?.data ?? [];
|
||||
return rooms
|
||||
.filter((r: any) => r.type !== 4)
|
||||
.sort((a: any, b: any) => (b.lastActivity ?? 0) - (a.lastActivity ?? 0))
|
||||
.map((r: any) => ({
|
||||
token: r.token,
|
||||
displayName: r.displayName,
|
||||
unreadMessages: r.unreadMessages ?? 0,
|
||||
lastActivity: r.lastActivity ?? 0,
|
||||
lastMessage: r.lastMessage
|
||||
? {
|
||||
text: r.lastMessage.message ?? '',
|
||||
author: r.lastMessage.actorDisplayName ?? '',
|
||||
timestamp: r.lastMessage.timestamp ?? 0,
|
||||
}
|
||||
: null,
|
||||
type: r.type,
|
||||
url: `${baseUrl}/call/${r.token}`,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||
const err = new Error('Nextcloud app password is invalid');
|
||||
(err as any).code = 'NEXTCLOUD_AUTH_INVALID';
|
||||
throw err;
|
||||
}
|
||||
if (axios.isAxiosError(error)) {
|
||||
logger.error('Nextcloud getAllConversations failed', {
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
});
|
||||
throw new Error(`Nextcloud API error: ${error.response?.status ?? 'unknown'}`);
|
||||
}
|
||||
logger.error('NextcloudService.getAllConversations failed', { error });
|
||||
throw new Error('Failed to fetch Nextcloud conversations');
|
||||
}
|
||||
}
|
||||
|
||||
async function getMessages(token: string, loginName: string, appPassword: string): Promise<NextcloudChatMessage[]> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
|
||||
const response = await axios.get(
|
||||
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}`,
|
||||
{
|
||||
params: { lookIntoFuture: 0, limit: 50, setReadMarker: 0 },
|
||||
headers: {
|
||||
'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`,
|
||||
'OCS-APIRequest': 'true',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const messages: any[] = response.data?.ocs?.data ?? [];
|
||||
return messages.map((m: any) => ({
|
||||
id: m.id,
|
||||
token: m.token,
|
||||
actorType: m.actorType,
|
||||
actorId: m.actorId,
|
||||
actorDisplayName: m.actorDisplayName,
|
||||
message: m.message,
|
||||
timestamp: m.timestamp,
|
||||
messageType: m.messageType ?? '',
|
||||
systemMessage: m.systemMessage ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
async function sendMessage(token: string, message: string, loginName: string, appPassword: string): Promise<void> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
|
||||
await axios.post(
|
||||
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}`,
|
||||
{ message },
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`,
|
||||
'OCS-APIRequest': 'true',
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function markAsRead(token: string, loginName: string, appPassword: string): Promise<void> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||
}
|
||||
|
||||
await axios.delete(
|
||||
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}/read`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`,
|
||||
'OCS-APIRequest': 'true',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function getConversations(loginName: string, appPassword: string): Promise<ConversationsResult> {
|
||||
const baseUrl = environment.nextcloudUrl;
|
||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||
@@ -193,4 +330,5 @@ async function getConversations(loginName: string, appPassword: string): Promise
|
||||
}
|
||||
}
|
||||
|
||||
export default { initiateLoginFlow, pollLoginFlow, getConversations };
|
||||
export type { NextcloudChatMessage };
|
||||
export default { initiateLoginFlow, pollLoginFlow, getConversations, getAllConversations, getMessages, sendMessage, markAsRead };
|
||||
|
||||
193
backend/src/services/serviceMonitor.service.ts
Normal file
193
backend/src/services/serviceMonitor.service.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import axios from 'axios';
|
||||
import pool from '../config/database';
|
||||
import environment from '../config/environment';
|
||||
|
||||
export interface MonitoredService {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
type: 'internal' | 'custom';
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PingResult {
|
||||
name: string;
|
||||
url: string;
|
||||
status: 'up' | 'down';
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface StatusSummary {
|
||||
up: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
class ServiceMonitorService {
|
||||
async getAllServices(): Promise<MonitoredService[]> {
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM monitored_services ORDER BY created_at'
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async createService(name: string, url: string, type: string = 'custom'): Promise<MonitoredService> {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO monitored_services (name, url, type) VALUES ($1, $2, $3) RETURNING *`,
|
||||
[name, url, type]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async updateService(id: string, data: Partial<Pick<MonitoredService, 'name' | 'url' | 'is_active'>>): Promise<MonitoredService> {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (data.name !== undefined) {
|
||||
fields.push(`name = $${idx++}`);
|
||||
values.push(data.name);
|
||||
}
|
||||
if (data.url !== undefined) {
|
||||
fields.push(`url = $${idx++}`);
|
||||
values.push(data.url);
|
||||
}
|
||||
if (data.is_active !== undefined) {
|
||||
fields.push(`is_active = $${idx++}`);
|
||||
values.push(data.is_active);
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
throw new Error('No fields to update');
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
const result = await pool.query(
|
||||
`UPDATE monitored_services SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||
values
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
throw new Error('Service not found');
|
||||
}
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async deleteService(id: string): Promise<boolean> {
|
||||
const result = await pool.query(
|
||||
`DELETE FROM monitored_services WHERE id = $1 AND type = 'custom'`,
|
||||
[id]
|
||||
);
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
async pingService(url: string, headers?: Record<string, string>): Promise<PingResult> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
await axios.get(url, { timeout: 5000, headers });
|
||||
return {
|
||||
name: '',
|
||||
url,
|
||||
status: 'up',
|
||||
latencyMs: Date.now() - start,
|
||||
};
|
||||
} catch (error) {
|
||||
// Treat any HTTP response (even 4xx) as "service is reachable" only for status.php-type endpoints
|
||||
// For API endpoints requiring auth, a 401/403 still means the service is up
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
const status = error.response.status;
|
||||
// If we got a response, the service is running (auth errors = service is up)
|
||||
if (headers && (status === 401 || status === 403)) {
|
||||
return {
|
||||
name: '',
|
||||
url,
|
||||
status: 'up',
|
||||
latencyMs: Date.now() - start,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: '',
|
||||
url,
|
||||
status: 'down',
|
||||
latencyMs: Date.now() - start,
|
||||
error: axios.isAxiosError(error)
|
||||
? `${error.code ?? 'ERROR'}: ${error.message}`
|
||||
: String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async pingAll(): Promise<PingResult[]> {
|
||||
const services = await this.getAllServices();
|
||||
const activeServices = services.filter((s) => s.is_active);
|
||||
|
||||
const internalServices = this.getInternalServices();
|
||||
|
||||
const allTargets = [
|
||||
...activeServices.map((s) => ({ name: s.name, url: s.url, pingUrl: s.url, headers: undefined as Record<string, string> | undefined })),
|
||||
...internalServices,
|
||||
];
|
||||
|
||||
const results = await Promise.all(
|
||||
allTargets.map(async (target) => {
|
||||
const result = await this.pingService(target.pingUrl, target.headers);
|
||||
result.name = target.name;
|
||||
result.url = target.url;
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async getStatusSummary(): Promise<StatusSummary> {
|
||||
const results = await this.pingAll();
|
||||
return {
|
||||
up: results.filter((r) => r.status === 'up').length,
|
||||
total: results.length,
|
||||
};
|
||||
}
|
||||
|
||||
private getInternalServices(): Array<{ name: string; url: string; pingUrl: string; headers?: Record<string, string> }> {
|
||||
const internal: Array<{ name: string; url: string; pingUrl: string; headers?: Record<string, string> }> = [];
|
||||
|
||||
const { bookstack, vikunja, nextcloudUrl } = environment;
|
||||
|
||||
if (nextcloudUrl) {
|
||||
internal.push({
|
||||
name: 'Nextcloud',
|
||||
url: nextcloudUrl,
|
||||
pingUrl: `${nextcloudUrl}/status.php`,
|
||||
});
|
||||
}
|
||||
|
||||
if (bookstack.url) {
|
||||
internal.push({
|
||||
name: 'BookStack',
|
||||
url: bookstack.url,
|
||||
pingUrl: `${bookstack.url}/api/pages?count=1`,
|
||||
headers: {
|
||||
'Authorization': `Token ${bookstack.tokenId}:${bookstack.tokenSecret}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (vikunja.url) {
|
||||
internal.push({
|
||||
name: 'Vikunja',
|
||||
url: vikunja.url,
|
||||
pingUrl: `${vikunja.url}/api/v1/tasks/all?per_page=1`,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${vikunja.apiToken}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return internal;
|
||||
}
|
||||
}
|
||||
|
||||
export default new ServiceMonitorService();
|
||||
Reference in New Issue
Block a user