feat: bug fixes, layout improvements, and new features
Bug fixes: - Remove non-existent `role` column from admin users SQL query (A1) - Fix Nextcloud Talk chat API path v4 → v1 for messages/send/read (A2) - Fix ServiceModeTab sync: useState → useEffect to reflect DB state (A3) - Guard BookStack book_slug with book_id fallback to avoid broken URLs (A4) Layout & UI: - Chat panel: sticky full-height positioning, main content scrolls independently (B1) - Vehicle booking datetime inputs: explicit text color for dark mode (B2) - AnnouncementBanner moved into grid with full-width span (B3) Features: - Per-user widget visibility preferences stored in users.preferences JSONB (C1) - Link collections: grouped external links in admin UI and dashboard widget (C2) - Admin ping history: migration 026, checked_at timestamps, expandable history rows (C4) - Service mode end date picker with scheduled deactivation display (C5) - Vikunja startup config logging and configured:false warnings (C7) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -106,6 +106,7 @@ app.use('/api/vikunja', vikunjaRoutes);
|
||||
app.use('/api/config', configRoutes);
|
||||
app.use('/api/admin', serviceMonitorRoutes);
|
||||
app.use('/api/admin/settings', settingsRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
app.use('/api/banners', bannerRoutes);
|
||||
|
||||
// 404 handler
|
||||
|
||||
@@ -19,13 +19,14 @@ class ConfigController {
|
||||
if (environment.bookstack.url) envLinks.bookstack = environment.bookstack.url;
|
||||
if (environment.vikunja.url) envLinks.vikunja = environment.vikunja.url;
|
||||
|
||||
const customLinks = await settingsService.getExternalLinks();
|
||||
const linkCollections = await settingsService.getExternalLinks();
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
...envLinks,
|
||||
customLinks,
|
||||
customLinks: linkCollections.flatMap(c => c.links),
|
||||
linkCollections,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ class ServiceMonitorController {
|
||||
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
|
||||
`SELECT id, email, name, authentik_groups as groups, is_active, last_login_at
|
||||
FROM users ORDER BY name`
|
||||
);
|
||||
res.json({ success: true, data: result.rows });
|
||||
@@ -147,6 +147,17 @@ class ServiceMonitorController {
|
||||
}
|
||||
}
|
||||
|
||||
async getPingHistory(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { serviceId } = req.params;
|
||||
const data = await serviceMonitorService.getPingHistory(serviceId as string);
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
logger.error('Failed to get ping history', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to get ping history' });
|
||||
}
|
||||
}
|
||||
|
||||
async broadcastNotification(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { titel, nachricht, schwere, targetGroup } = broadcastSchema.parse(req.body);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import settingsService from '../services/settings.service';
|
||||
import pool from '../config/database';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
const updateSchema = z.object({
|
||||
@@ -8,8 +9,12 @@ const updateSchema = z.object({
|
||||
});
|
||||
|
||||
const externalLinkSchema = z.array(z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).max(200),
|
||||
url: z.string().url().max(500),
|
||||
links: z.array(z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
url: z.string().url().max(500),
|
||||
})),
|
||||
}));
|
||||
|
||||
class SettingsController {
|
||||
@@ -57,6 +62,32 @@ class SettingsController {
|
||||
res.status(500).json({ success: false, message: 'Failed to update setting' });
|
||||
}
|
||||
}
|
||||
async getUserPreferences(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = (req as any).user.id;
|
||||
const result = await pool.query('SELECT preferences FROM users WHERE id = $1', [userId]);
|
||||
const prefs = result.rows[0]?.preferences ?? {};
|
||||
res.json({ success: true, data: prefs });
|
||||
} catch (error) {
|
||||
logger.error('Failed to get user preferences', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to get user preferences' });
|
||||
}
|
||||
}
|
||||
|
||||
async updateUserPreferences(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = (req as any).user.id;
|
||||
const preferences = req.body;
|
||||
await pool.query(
|
||||
'UPDATE users SET preferences = $1 WHERE id = $2',
|
||||
[JSON.stringify(preferences), userId]
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to update user preferences', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to update user preferences' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new SettingsController();
|
||||
|
||||
@@ -7,6 +7,7 @@ import logger from '../utils/logger';
|
||||
class VikunjaController {
|
||||
async getMyTasks(_req: Request, res: Response): Promise<void> {
|
||||
if (!environment.vikunja.url) {
|
||||
logger.warn('Vikunja not configured – VIKUNJA_URL is empty');
|
||||
res.status(200).json({ success: true, data: [], configured: false });
|
||||
return;
|
||||
}
|
||||
@@ -21,6 +22,7 @@ class VikunjaController {
|
||||
|
||||
async getOverdueTasks(req: Request, res: Response): Promise<void> {
|
||||
if (!environment.vikunja.url) {
|
||||
logger.warn('Vikunja not configured – VIKUNJA_URL is empty');
|
||||
res.status(200).json({ success: true, data: [], configured: false });
|
||||
return;
|
||||
}
|
||||
@@ -53,6 +55,7 @@ class VikunjaController {
|
||||
|
||||
async getProjects(_req: Request, res: Response): Promise<void> {
|
||||
if (!environment.vikunja.url) {
|
||||
logger.warn('Vikunja not configured – VIKUNJA_URL is empty');
|
||||
res.status(200).json({ success: true, data: [], configured: false });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS service_ping_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
service_id VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
response_time_ms INTEGER,
|
||||
checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sph_service_checked ON service_ping_history(service_id, checked_at DESC);
|
||||
@@ -9,6 +9,7 @@ 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/:serviceId/ping-history', ...auth, serviceMonitorController.getPingHistory.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));
|
||||
|
||||
@@ -7,6 +7,8 @@ const router = Router();
|
||||
const auth = [authenticate, requirePermission('admin:access')] as const;
|
||||
|
||||
router.get('/', ...auth, settingsController.getAll.bind(settingsController));
|
||||
router.get('/preferences', authenticate, settingsController.getUserPreferences.bind(settingsController));
|
||||
router.put('/preferences', authenticate, settingsController.updateUserPreferences.bind(settingsController));
|
||||
router.get('/:key', ...auth, settingsController.get.bind(settingsController));
|
||||
router.put('/:key', ...auth, settingsController.update.bind(settingsController));
|
||||
|
||||
|
||||
@@ -31,6 +31,13 @@ const startServer = async (): Promise<void> => {
|
||||
environment: environment.nodeEnv,
|
||||
database: dbConnected ? 'connected' : 'disconnected',
|
||||
});
|
||||
|
||||
// Log integration status so operators can see what's configured
|
||||
logger.info('Integration status', {
|
||||
bookstack: !!environment.bookstack.url,
|
||||
nextcloud: !!environment.nextcloudUrl,
|
||||
vikunja: !!environment.vikunja.url,
|
||||
});
|
||||
});
|
||||
|
||||
// Graceful shutdown handling
|
||||
|
||||
@@ -98,7 +98,7 @@ async function getRecentPages(): Promise<BookStackPage[]> {
|
||||
const pages: BookStackPage[] = response.data?.data ?? [];
|
||||
return pages.map((p) => ({
|
||||
...p,
|
||||
url: p.url && p.url.startsWith('http') ? p.url : `${bookstack.url}/books/${p.book_slug}/page/${p.slug}`,
|
||||
url: p.url && p.url.startsWith('http') ? p.url : `${bookstack.url}/books/${p.book_slug || p.book_id}/page/${p.slug}`,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
@@ -134,7 +134,7 @@ async function searchPages(query: string): Promise<BookStackSearchResult[]> {
|
||||
slug: item.slug,
|
||||
book_id: item.book_id ?? 0,
|
||||
book_slug: item.book_slug ?? '',
|
||||
url: item.url || `${bookstack.url}/books/${item.book_slug}/page/${item.slug}`,
|
||||
url: item.url || `${bookstack.url}/books/${item.book_slug || item.book_id}/page/${item.slug}`,
|
||||
preview_html: item.preview_html ?? { content: '' },
|
||||
tags: item.tags ?? [],
|
||||
}));
|
||||
@@ -189,7 +189,7 @@ async function getPageById(id: number): Promise<BookStackPageDetail> {
|
||||
html: page.html ?? '',
|
||||
created_at: page.created_at,
|
||||
updated_at: page.updated_at,
|
||||
url: page.url && page.url.startsWith('http') ? page.url : `${bookstack.url}/books/${page.book_slug}/page/${page.slug}`,
|
||||
url: page.url && page.url.startsWith('http') ? page.url : `${bookstack.url}/books/${page.book_slug || page.book_id}/page/${page.slug}`,
|
||||
book: page.book,
|
||||
createdBy: page.created_by,
|
||||
updatedBy: page.updated_by,
|
||||
|
||||
@@ -202,7 +202,7 @@ async function getMessages(token: string, loginName: string, appPassword: string
|
||||
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}`,
|
||||
`${baseUrl}/ocs/v2.php/apps/spreed/api/v1/chat/${encodeURIComponent(token)}`,
|
||||
{
|
||||
params: { lookIntoFuture: 0, limit: 50, setReadMarker: 0 },
|
||||
headers: {
|
||||
@@ -251,7 +251,7 @@ async function sendMessage(token: string, message: string, loginName: string, ap
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}`,
|
||||
`${baseUrl}/ocs/v2.php/apps/spreed/api/v1/chat/${encodeURIComponent(token)}`,
|
||||
{ message },
|
||||
{
|
||||
headers: {
|
||||
@@ -288,7 +288,7 @@ async function markAsRead(token: string, loginName: string, appPassword: string)
|
||||
|
||||
try {
|
||||
await axios.delete(
|
||||
`${baseUrl}/ocs/v2.php/apps/spreed/api/v4/chat/${encodeURIComponent(token)}/read`,
|
||||
`${baseUrl}/ocs/v2.php/apps/spreed/api/v1/chat/${encodeURIComponent(token)}/read`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`,
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface PingResult {
|
||||
status: 'up' | 'down';
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
checked_at: string;
|
||||
}
|
||||
|
||||
export interface StatusSummary {
|
||||
@@ -92,6 +93,7 @@ class ServiceMonitorService {
|
||||
url,
|
||||
status: 'up',
|
||||
latencyMs: Date.now() - start,
|
||||
checked_at: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
// Treat any HTTP response (even 4xx) as "service is reachable" only for status.php-type endpoints
|
||||
@@ -105,6 +107,7 @@ class ServiceMonitorService {
|
||||
url,
|
||||
status: 'up',
|
||||
latencyMs: Date.now() - start,
|
||||
checked_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -113,6 +116,7 @@ class ServiceMonitorService {
|
||||
url,
|
||||
status: 'down',
|
||||
latencyMs: Date.now() - start,
|
||||
checked_at: new Date().toISOString(),
|
||||
error: axios.isAxiosError(error)
|
||||
? `${error.code ?? 'ERROR'}: ${error.message}`
|
||||
: String(error),
|
||||
@@ -140,9 +144,37 @@ class ServiceMonitorService {
|
||||
})
|
||||
);
|
||||
|
||||
// Store ping results in history (fire-and-forget)
|
||||
this.storePingResults(results).catch(() => {});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async getPingHistory(serviceId: string): Promise<Array<{ id: number; service_id: string; status: string; response_time_ms: number | null; checked_at: string }>> {
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM service_ping_history WHERE service_id = $1 ORDER BY checked_at DESC LIMIT 20',
|
||||
[serviceId]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
private async storePingResults(results: PingResult[]): Promise<void> {
|
||||
for (const r of results) {
|
||||
const serviceId = r.name || r.url;
|
||||
await pool.query(
|
||||
'INSERT INTO service_ping_history (service_id, status, response_time_ms, checked_at) VALUES ($1, $2, $3, $4)',
|
||||
[serviceId, r.status, r.latencyMs, r.checked_at]
|
||||
);
|
||||
// Keep only last 20 per service
|
||||
await pool.query(
|
||||
`DELETE FROM service_ping_history WHERE service_id = $1 AND id NOT IN (
|
||||
SELECT id FROM service_ping_history WHERE service_id = $1 ORDER BY checked_at DESC LIMIT 20
|
||||
)`,
|
||||
[serviceId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getStatusSummary(): Promise<StatusSummary> {
|
||||
const results = await this.pingAll();
|
||||
return {
|
||||
|
||||
@@ -34,9 +34,20 @@ class SettingsService {
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
async getExternalLinks(): Promise<Array<{name: string; url: string}>> {
|
||||
async getExternalLinks(): Promise<Array<{id: string; name: string; links: Array<{name: string; url: string}>}>> {
|
||||
const setting = await this.get('external_links');
|
||||
return Array.isArray(setting?.value) ? setting.value : [];
|
||||
const value = setting?.value;
|
||||
if (!Array.isArray(value)) return [];
|
||||
|
||||
// Already in LinkCollection[] format
|
||||
if (value.length === 0) return [];
|
||||
if (value[0] && 'links' in value[0]) return value;
|
||||
|
||||
// Old flat format: Array<{name, url}> — migrate to single collection
|
||||
const migrated = [{ id: 'default', name: 'Links', links: value }];
|
||||
// Persist migrated format (use system user id)
|
||||
await this.set('external_links', migrated, 'system');
|
||||
return migrated;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user