update
This commit is contained in:
@@ -17,10 +17,13 @@ import nextcloudService from '../services/nextcloud.service';
|
|||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
const INTERVAL_MS = 15 * 60 * 1000; // 15 minutes
|
const INTERVAL_MS = 15 * 60 * 1000; // 15 minutes
|
||||||
|
const NEXTCLOUD_INTERVAL_MS = 2 * 60 * 1000; // 2 minutes
|
||||||
const ATEMSCHUTZ_THRESHOLD = 60; // days
|
const ATEMSCHUTZ_THRESHOLD = 60; // days
|
||||||
|
|
||||||
let jobInterval: ReturnType<typeof setInterval> | null = null;
|
let jobInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let nextcloudInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
let isRunning = false;
|
let isRunning = false;
|
||||||
|
let isNextcloudRunning = false;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Core generation function
|
// Core generation function
|
||||||
@@ -36,7 +39,6 @@ export async function runNotificationGeneration(): Promise<void> {
|
|||||||
await generateAtemschutzNotifications();
|
await generateAtemschutzNotifications();
|
||||||
await generateVehicleNotifications();
|
await generateVehicleNotifications();
|
||||||
await generateEquipmentNotifications();
|
await generateEquipmentNotifications();
|
||||||
await generateNextcloudTalkNotifications();
|
|
||||||
await notificationService.deleteOldRead();
|
await notificationService.deleteOldRead();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('NotificationGenerationJob: unexpected error', {
|
logger.error('NotificationGenerationJob: unexpected error', {
|
||||||
@@ -47,6 +49,23 @@ export async function runNotificationGeneration(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runNextcloudNotificationGeneration(): Promise<void> {
|
||||||
|
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
|
// 1. Atemschutz personal warnings
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -299,20 +318,29 @@ export function startNotificationJob(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run once on startup, then repeat.
|
// Run both once on startup, then repeat on separate intervals.
|
||||||
runNotificationGeneration();
|
runNotificationGeneration();
|
||||||
|
runNextcloudNotificationGeneration();
|
||||||
|
|
||||||
jobInterval = setInterval(() => {
|
jobInterval = setInterval(() => {
|
||||||
runNotificationGeneration();
|
runNotificationGeneration();
|
||||||
}, INTERVAL_MS);
|
}, 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 {
|
export function stopNotificationJob(): void {
|
||||||
if (jobInterval !== null) {
|
if (jobInterval !== null) {
|
||||||
clearInterval(jobInterval);
|
clearInterval(jobInterval);
|
||||||
jobInterval = null;
|
jobInterval = null;
|
||||||
logger.info('Notification generation job stopped');
|
|
||||||
}
|
}
|
||||||
|
if (nextcloudInterval !== null) {
|
||||||
|
clearInterval(nextcloudInterval);
|
||||||
|
nextcloudInterval = null;
|
||||||
|
}
|
||||||
|
logger.info('Notification generation jobs stopped');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { createContext, useContext, useState, useCallback, useEffect, use
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { nextcloudApi } from '../services/nextcloud';
|
import { nextcloudApi } from '../services/nextcloud';
|
||||||
import { useLayout } from './LayoutContext';
|
import { useLayout } from './LayoutContext';
|
||||||
|
import { useNotification } from './NotificationContext';
|
||||||
import type { NextcloudConversation } from '../types/nextcloud.types';
|
import type { NextcloudConversation } from '../types/nextcloud.types';
|
||||||
|
|
||||||
interface ChatContextType {
|
interface ChatContextType {
|
||||||
@@ -21,8 +22,10 @@ interface ChatProviderProps {
|
|||||||
export const ChatProvider: React.FC<ChatProviderProps> = ({ children }) => {
|
export const ChatProvider: React.FC<ChatProviderProps> = ({ children }) => {
|
||||||
const [selectedRoomToken, setSelectedRoomToken] = useState<string | null>(null);
|
const [selectedRoomToken, setSelectedRoomToken] = useState<string | null>(null);
|
||||||
const { chatPanelOpen } = useLayout();
|
const { chatPanelOpen } = useLayout();
|
||||||
|
const { showNotificationToast } = useNotification();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const prevPanelOpenRef = useRef(chatPanelOpen);
|
const prevPanelOpenRef = useRef(chatPanelOpen);
|
||||||
|
const prevUnreadRef = useRef<Map<string, number>>(new Map());
|
||||||
|
|
||||||
// Invalidate rooms/connection when panel opens so data is fresh immediately
|
// Invalidate rooms/connection when panel opens so data is fresh immediately
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -36,7 +39,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({ children }) => {
|
|||||||
const { data: connData } = useQuery({
|
const { data: connData } = useQuery({
|
||||||
queryKey: ['nextcloud', 'connection'],
|
queryKey: ['nextcloud', 'connection'],
|
||||||
queryFn: () => nextcloudApi.getConversations(),
|
queryFn: () => nextcloudApi.getConversations(),
|
||||||
refetchInterval: chatPanelOpen ? 5000 : 60000,
|
refetchInterval: chatPanelOpen ? 5000 : 15000,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,7 +48,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({ children }) => {
|
|||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ['nextcloud', 'rooms'],
|
queryKey: ['nextcloud', 'rooms'],
|
||||||
queryFn: () => nextcloudApi.getRooms(),
|
queryFn: () => nextcloudApi.getRooms(),
|
||||||
refetchInterval: chatPanelOpen ? 5000 : 60000,
|
refetchInterval: chatPanelOpen ? 5000 : 15000,
|
||||||
enabled: isConnected,
|
enabled: isConnected,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,6 +56,21 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({ children }) => {
|
|||||||
const connected = data?.connected ?? false;
|
const connected = data?.connected ?? false;
|
||||||
const loginName = data?.loginName ?? null;
|
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) => {
|
const selectRoom = useCallback((token: string | null) => {
|
||||||
setSelectedRoomToken(token);
|
setSelectedRoomToken(token);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { chromium, Page } from '@playwright/test';
|
import { chromium, Page, Frame } from '@playwright/test';
|
||||||
import { FdiskMember, FdiskAusbildung } from './types';
|
import { FdiskMember, FdiskAusbildung } from './types';
|
||||||
|
|
||||||
const BASE_URL = process.env.FDISK_BASE_URL ?? 'https://app.fdisk.at';
|
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 {
|
try {
|
||||||
await login(page, username, password);
|
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`);
|
log(`Found ${members.length} members`);
|
||||||
|
|
||||||
const ausbildungen: FdiskAusbildung[] = [];
|
const ausbildungen: FdiskAusbildung[] = [];
|
||||||
for (const member of members) {
|
for (const member of members) {
|
||||||
if (!member.detailUrl) continue;
|
if (!member.detailUrl) continue;
|
||||||
try {
|
try {
|
||||||
const quals = await scrapeMemberAusbildung(page, member);
|
const quals = await scrapeMemberAusbildung(mainFrame, member);
|
||||||
ausbildungen.push(...quals);
|
ausbildungen.push(...quals);
|
||||||
log(` ${member.vorname} ${member.zuname}: ${quals.length} Ausbildungen`);
|
log(` ${member.vorname} ${member.zuname}: ${quals.length} Ausbildungen`);
|
||||||
// polite delay between requests
|
// polite delay between requests
|
||||||
@@ -114,19 +122,19 @@ async function login(page: Page, username: string, password: string): Promise<vo
|
|||||||
log(`Logged in successfully, redirected to: ${currentUrl}`);
|
log(`Logged in successfully, redirected to: ${currentUrl}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scrapeMembers(page: Page): Promise<FdiskMember[]> {
|
async function scrapeMembers(frame: Frame): Promise<FdiskMember[]> {
|
||||||
log(`Navigating to members list: ${MEMBERS_URL}`);
|
log(`Navigating to members list: ${MEMBERS_URL}`);
|
||||||
await page.goto(MEMBERS_URL, { waitUntil: 'domcontentloaded' });
|
await frame.goto(MEMBERS_URL, { waitUntil: 'domcontentloaded' });
|
||||||
await page.waitForLoadState('networkidle');
|
await frame.waitForLoadState('networkidle');
|
||||||
|
|
||||||
// The member table uses class FdcLayList
|
// 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,
|
// 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
|
// 4=Vorname, 5=Zuname, 6=Geburtsdatum, 7=SVNR, 8=Eintrittsdatum, 9=Abmeldedatum, 10=icon
|
||||||
// Each <td> contains an <a title="value"> — the title is the clean cell text.
|
// Each <td> contains an <a title="value"> — the title is the clean cell text.
|
||||||
// The href on each <a> is the member detail URL (same link repeated across all cells in a row).
|
// The href on each <a> 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) => {
|
trs.map((tr) => {
|
||||||
const cells = Array.from(tr.querySelectorAll('td'));
|
const cells = Array.from(tr.querySelectorAll('td'));
|
||||||
const val = (i: number) => {
|
const val = (i: number) => {
|
||||||
@@ -171,29 +179,28 @@ async function scrapeMembers(page: Page): Promise<FdiskMember[]> {
|
|||||||
return members;
|
return members;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scrapeMemberAusbildung(page: Page, member: FdiskMember): Promise<FdiskAusbildung[]> {
|
async function scrapeMemberAusbildung(frame: Frame, member: FdiskMember): Promise<FdiskAusbildung[]> {
|
||||||
if (!member.detailUrl) return [];
|
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
|
// Look for Ausbildungsliste section — it's likely a table or list
|
||||||
// Try to find it by heading text
|
const ausbildungSection = frame.locator('text=Ausbildung, text=Ausbildungsliste').first();
|
||||||
const ausbildungSection = page.locator('text=Ausbildung, text=Ausbildungsliste').first();
|
|
||||||
const hasSec = await ausbildungSection.isVisible().catch(() => false);
|
const hasSec = await ausbildungSection.isVisible().catch(() => false);
|
||||||
|
|
||||||
if (!hasSec) {
|
if (!hasSec) {
|
||||||
// Try navigating to an Ausbildung tab/link if present
|
// 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);
|
const hasLink = await ausbildungLink.isVisible().catch(() => false);
|
||||||
if (hasLink) {
|
if (hasLink) {
|
||||||
await ausbildungLink.click();
|
await ausbildungLink.click();
|
||||||
await page.waitForLoadState('networkidle').catch(() => {});
|
await frame.waitForLoadState('networkidle').catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the qualification table
|
// Parse the qualification table
|
||||||
// Expected columns: Kursname, Datum, Ablaufdatum, Ort, Bemerkung (may vary)
|
// Expected columns: Kursname, Datum, Ablaufdatum, Ort, Bemerkung (may vary)
|
||||||
const tables = await page.$$('table');
|
const tables = await frame.$$('table');
|
||||||
const ausbildungen: FdiskAusbildung[] = [];
|
const ausbildungen: FdiskAusbildung[] = [];
|
||||||
|
|
||||||
for (const table of tables) {
|
for (const table of tables) {
|
||||||
|
|||||||
Reference in New Issue
Block a user