adding chat features, admin features and bug fixes

This commit is contained in:
Matthias Hochmeister
2026-03-12 08:16:34 +01:00
parent 7b14e3d5ba
commit 31f1414e06
43 changed files with 2610 additions and 16 deletions

View File

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

View File

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

View 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();