From cfb70e62c77e8c664b09e1eeacad935e6a68587e Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Fri, 13 Mar 2026 13:06:27 +0100 Subject: [PATCH] update --- .../src/jobs/notification-generation.job.ts | 36 ++++++++++++++++-- frontend/src/contexts/ChatContext.tsx | 22 ++++++++++- sync/src/scraper.ts | 37 +++++++++++-------- 3 files changed, 74 insertions(+), 21 deletions(-) diff --git a/backend/src/jobs/notification-generation.job.ts b/backend/src/jobs/notification-generation.job.ts index baf9b7e..bcf01ba 100644 --- a/backend/src/jobs/notification-generation.job.ts +++ b/backend/src/jobs/notification-generation.job.ts @@ -17,10 +17,13 @@ import nextcloudService from '../services/nextcloud.service'; import logger from '../utils/logger'; const INTERVAL_MS = 15 * 60 * 1000; // 15 minutes +const NEXTCLOUD_INTERVAL_MS = 2 * 60 * 1000; // 2 minutes const ATEMSCHUTZ_THRESHOLD = 60; // days let jobInterval: ReturnType | null = null; +let nextcloudInterval: ReturnType | null = null; let isRunning = false; +let isNextcloudRunning = false; // --------------------------------------------------------------------------- // Core generation function @@ -36,7 +39,6 @@ export async function runNotificationGeneration(): Promise { await generateAtemschutzNotifications(); await generateVehicleNotifications(); await generateEquipmentNotifications(); - await generateNextcloudTalkNotifications(); await notificationService.deleteOldRead(); } catch (error) { logger.error('NotificationGenerationJob: unexpected error', { @@ -47,6 +49,23 @@ export async function runNotificationGeneration(): Promise { } } +async function runNextcloudNotificationGeneration(): Promise { + if (isNextcloudRunning) { + logger.warn('NotificationGenerationJob: Nextcloud run still in progress — skipping'); + return; + } + isNextcloudRunning = true; + try { + await generateNextcloudTalkNotifications(); + } catch (error) { + logger.error('NotificationGenerationJob: Nextcloud unexpected error', { + error: error instanceof Error ? error.message : String(error), + }); + } finally { + isNextcloudRunning = false; + } +} + // --------------------------------------------------------------------------- // 1. Atemschutz personal warnings // --------------------------------------------------------------------------- @@ -299,20 +318,29 @@ export function startNotificationJob(): void { return; } - // Run once on startup, then repeat. + // Run both once on startup, then repeat on separate intervals. runNotificationGeneration(); + runNextcloudNotificationGeneration(); jobInterval = setInterval(() => { runNotificationGeneration(); }, INTERVAL_MS); - logger.info('Notification generation job scheduled (setInterval, 15min interval)'); + nextcloudInterval = setInterval(() => { + runNextcloudNotificationGeneration(); + }, NEXTCLOUD_INTERVAL_MS); + + logger.info('Notification generation jobs scheduled (main: 15min, Nextcloud Talk: 2min)'); } export function stopNotificationJob(): void { if (jobInterval !== null) { clearInterval(jobInterval); jobInterval = null; - logger.info('Notification generation job stopped'); } + if (nextcloudInterval !== null) { + clearInterval(nextcloudInterval); + nextcloudInterval = null; + } + logger.info('Notification generation jobs stopped'); } diff --git a/frontend/src/contexts/ChatContext.tsx b/frontend/src/contexts/ChatContext.tsx index 75973c7..33b4fb8 100644 --- a/frontend/src/contexts/ChatContext.tsx +++ b/frontend/src/contexts/ChatContext.tsx @@ -2,6 +2,7 @@ import React, { createContext, useContext, useState, useCallback, useEffect, use import { useQuery, useQueryClient } from '@tanstack/react-query'; import { nextcloudApi } from '../services/nextcloud'; import { useLayout } from './LayoutContext'; +import { useNotification } from './NotificationContext'; import type { NextcloudConversation } from '../types/nextcloud.types'; interface ChatContextType { @@ -21,8 +22,10 @@ interface ChatProviderProps { export const ChatProvider: React.FC = ({ children }) => { const [selectedRoomToken, setSelectedRoomToken] = useState(null); const { chatPanelOpen } = useLayout(); + const { showNotificationToast } = useNotification(); const queryClient = useQueryClient(); const prevPanelOpenRef = useRef(chatPanelOpen); + const prevUnreadRef = useRef>(new Map()); // Invalidate rooms/connection when panel opens so data is fresh immediately useEffect(() => { @@ -36,7 +39,7 @@ export const ChatProvider: React.FC = ({ children }) => { const { data: connData } = useQuery({ queryKey: ['nextcloud', 'connection'], queryFn: () => nextcloudApi.getConversations(), - refetchInterval: chatPanelOpen ? 5000 : 60000, + refetchInterval: chatPanelOpen ? 5000 : 15000, retry: false, }); @@ -45,7 +48,7 @@ export const ChatProvider: React.FC = ({ children }) => { const { data } = useQuery({ queryKey: ['nextcloud', 'rooms'], queryFn: () => nextcloudApi.getRooms(), - refetchInterval: chatPanelOpen ? 5000 : 60000, + refetchInterval: chatPanelOpen ? 5000 : 15000, enabled: isConnected, }); @@ -53,6 +56,21 @@ export const ChatProvider: React.FC = ({ children }) => { const connected = data?.connected ?? false; const loginName = data?.loginName ?? null; + // Detect new unread messages while panel is closed and show toast + useEffect(() => { + if (!rooms.length) return; + const prev = prevUnreadRef.current; + const isFirstLoad = prev.size === 0; + + for (const room of rooms) { + const prevCount = prev.get(room.token) ?? 0; + if (!isFirstLoad && !chatPanelOpen && room.unreadMessages > prevCount) { + showNotificationToast(room.displayName, 'info'); + } + prev.set(room.token, room.unreadMessages); + } + }, [rooms, chatPanelOpen, showNotificationToast]); + const selectRoom = useCallback((token: string | null) => { setSelectedRoomToken(token); }, []); diff --git a/sync/src/scraper.ts b/sync/src/scraper.ts index a80a7b1..073f7b2 100644 --- a/sync/src/scraper.ts +++ b/sync/src/scraper.ts @@ -1,4 +1,4 @@ -import { chromium, Page } from '@playwright/test'; +import { chromium, Page, Frame } from '@playwright/test'; import { FdiskMember, FdiskAusbildung } from './types'; const BASE_URL = process.env.FDISK_BASE_URL ?? 'https://app.fdisk.at'; @@ -51,14 +51,22 @@ export async function scrapeAll(username: string, password: string): Promise<{ try { await login(page, username, password); - const members = await scrapeMembers(page); + + // After login, page is on Start.aspx (frameset with top.topFrame etc.). + // The member list page runs alterBreadcrumbs() on load, which accesses top.topFrame. + // Navigating the whole page away from the frameset breaks that check → NoTabsAllowed redirect. + // Instead, navigate the mainFrame (inner frame) so the frameset context stays intact. + const mainFrame = page.frame({ name: 'mainFrame' }); + if (!mainFrame) throw new Error('mainFrame not found in Start.aspx frameset'); + + const members = await scrapeMembers(mainFrame); log(`Found ${members.length} members`); const ausbildungen: FdiskAusbildung[] = []; for (const member of members) { if (!member.detailUrl) continue; try { - const quals = await scrapeMemberAusbildung(page, member); + const quals = await scrapeMemberAusbildung(mainFrame, member); ausbildungen.push(...quals); log(` ${member.vorname} ${member.zuname}: ${quals.length} Ausbildungen`); // polite delay between requests @@ -114,19 +122,19 @@ async function login(page: Page, username: string, password: string): Promise { +async function scrapeMembers(frame: Frame): Promise { log(`Navigating to members list: ${MEMBERS_URL}`); - await page.goto(MEMBERS_URL, { waitUntil: 'domcontentloaded' }); - await page.waitForLoadState('networkidle'); + await frame.goto(MEMBERS_URL, { waitUntil: 'domcontentloaded' }); + await frame.waitForLoadState('networkidle'); // The member table uses class FdcLayList - await page.waitForSelector('table.FdcLayList', { timeout: 20000 }); + await frame.waitForSelector('table.FdcLayList', { timeout: 20000 }); // Column layout (0-indexed td): 0=icon, 1=Status, 2=St.-Nr., 3=Dienstgrad, // 4=Vorname, 5=Zuname, 6=Geburtsdatum, 7=SVNR, 8=Eintrittsdatum, 9=Abmeldedatum, 10=icon // Each contains an — the title is the clean cell text. // The href on each is the member detail URL (same link repeated across all cells in a row). - const rows = await page.$$eval('table.FdcLayList tbody tr', (trs) => + const rows = await frame.$$eval('table.FdcLayList tbody tr', (trs) => trs.map((tr) => { const cells = Array.from(tr.querySelectorAll('td')); const val = (i: number) => { @@ -171,29 +179,28 @@ async function scrapeMembers(page: Page): Promise { return members; } -async function scrapeMemberAusbildung(page: Page, member: FdiskMember): Promise { +async function scrapeMemberAusbildung(frame: Frame, member: FdiskMember): Promise { if (!member.detailUrl) return []; - await page.goto(member.detailUrl, { waitUntil: 'networkidle' }); + await frame.goto(member.detailUrl, { waitUntil: 'networkidle' }); // Look for Ausbildungsliste section — it's likely a table or list - // Try to find it by heading text - const ausbildungSection = page.locator('text=Ausbildung, text=Ausbildungsliste').first(); + const ausbildungSection = frame.locator('text=Ausbildung, text=Ausbildungsliste').first(); const hasSec = await ausbildungSection.isVisible().catch(() => false); if (!hasSec) { // Try navigating to an Ausbildung tab/link if present - const ausbildungLink = page.locator('a:has-text("Ausbildung")').first(); + const ausbildungLink = frame.locator('a:has-text("Ausbildung")').first(); const hasLink = await ausbildungLink.isVisible().catch(() => false); if (hasLink) { await ausbildungLink.click(); - await page.waitForLoadState('networkidle').catch(() => {}); + await frame.waitForLoadState('networkidle').catch(() => {}); } } // Parse the qualification table // Expected columns: Kursname, Datum, Ablaufdatum, Ort, Bemerkung (may vary) - const tables = await page.$$('table'); + const tables = await frame.$$('table'); const ausbildungen: FdiskAusbildung[] = []; for (const table of tables) {