diff --git a/backend/src/services/nextcloud.service.ts b/backend/src/services/nextcloud.service.ts index f7ef775..94c7eb3 100644 --- a/backend/src/services/nextcloud.service.ts +++ b/backend/src/services/nextcloud.service.ts @@ -141,6 +141,7 @@ interface NextcloudChatMessage { messageParameters: Record; reactions: Record; reactionsSelf: string[]; + parent?: NextcloudChatMessage; } async function getAllConversations(loginName: string, appPassword: string): Promise { @@ -259,6 +260,20 @@ async function getMessages(token: string, loginName: string, appPassword: string messageParameters: m.messageParameters ?? {}, reactions: m.reactions ?? {}, reactionsSelf: m.reactionsSelf ?? [], + ...(m.parent ? { parent: { + id: m.parent.id, + token: m.parent.token, + actorType: m.parent.actorType, + actorId: m.parent.actorId, + actorDisplayName: m.parent.actorDisplayName, + message: m.parent.message, + timestamp: m.parent.timestamp, + messageType: m.parent.messageType ?? '', + systemMessage: m.parent.systemMessage ?? '', + messageParameters: m.parent.messageParameters ?? {}, + reactions: m.parent.reactions ?? {}, + reactionsSelf: m.parent.reactionsSelf ?? [], + }} : {}), })); } catch (error) { if (axios.isAxiosError(error) && error.response?.status === 401) { diff --git a/sync/src/scraper.ts b/sync/src/scraper.ts index ce35a19..fb8507d 100644 --- a/sync/src/scraper.ts +++ b/sync/src/scraper.ts @@ -616,9 +616,62 @@ async function scrapeAusbildungenFromDetailPage(frame: Frame, member: FdiskMembe return ausbildungen; } +/** + * Navigate to a sub-section URL and wait for any data table. + * Logs the actual URL and title so wrong-page issues are visible. + * Returns all rows from the first table found, or null if none. + */ +async function navigateAndGetTableRows( + frame: Frame, + url: string, +): Promise | null> { + await frame_goto(frame, url); + + const landed = frame.url(); + const title = await frame.title().catch(() => ''); + log(` → landed: ${landed} | title: "${title}"`); + + // Check for FDISK error pages + if (landed.includes('BLError') || landed.includes('support.aspx') || title.toLowerCase().includes('fehler')) { + log(` → ERROR page, skipping`); + return null; + } + + // Try table.FdcLayList first, then any table with tbody rows + const selectors = ['table.FdcLayList', 'table']; + for (const sel of selectors) { + const exists = await frame.$(sel).then(el => !!el).catch(() => false); + if (!exists) continue; + + const rows = await frame.$$eval(`${sel} tbody tr`, (trs) => + trs.map((tr) => ({ + cells: Array.from(tr.querySelectorAll('td')).map(td => { + const input = td.querySelector('input[type="text"], input:not([type])') as HTMLInputElement | null; + if (input) return input.value?.trim() ?? ''; + const select = td.querySelector('select') as HTMLSelectElement | null; + if (select) { + const opt = select.options[select.selectedIndex]; + return (opt?.text || opt?.value || '').trim(); + } + return td.textContent?.trim() ?? ''; + }), + })) + ).catch(() => [] as Array<{ cells: string[] }>); + + if (rows.length > 0) { + log(` → found ${rows.length} rows via "${sel}"`); + return rows; + } + } + + // No table rows found — page might be empty or structured differently + const bodyText = await frame.evaluate(() => document.body?.textContent?.slice(0, 300) ?? '').catch(() => ''); + log(` → no table rows found. Body preview: ${bodyText.replace(/\s+/g, ' ')}`); + return []; +} + /** * Navigate to the Beförderungen sub-page and scrape all promotions. - * URL is constructed from the mitgliedschaft ID extracted from PersonenForm URL. */ async function scrapeMemberBefoerderungen( frame: Frame, @@ -629,41 +682,25 @@ async function scrapeMemberBefoerderungen( ): Promise { const url = `${BASE_URL}/fdisk/module/mgvw/mitgliedschaften/befoerderungenList.aspx` + `?id_mitgliedschaften=${idMitgliedschaft}&id_instanzen=${idInstanzen}&id_feuerwehren=${idFeuerwehren}`; - await frame_goto(frame, url); + + const rows = await navigateAndGetTableRows(frame, url); + if (!rows) return []; const results: FdiskBefoerderung[] = []; - - try { - await frame.waitForSelector('table.FdcLayList', { timeout: 10000 }); - const rows = await frame.$$eval('table.FdcLayList tbody tr', (trs) => - trs.map((tr) => { - const cells = Array.from(tr.querySelectorAll('td')); - const cell = (i: number) => (cells[i]?.textContent ?? '').trim(); - return { datum: cell(0), dienstgrad: cell(1) }; - }) - ); - - for (const row of rows) { - const dienstgrad = cellText(row.dienstgrad); - if (!dienstgrad) continue; - const datum = parseDate(row.datum); - const syncKey = `${standesbuchNr}::${dienstgrad}::${datum ?? ''}`; - results.push({ standesbuchNr, datum, dienstgrad, syncKey }); - } - log(` Beförderungen for StNr ${standesbuchNr}: ${results.length} rows`); - for (const b of results) { - log(` ${b.datum ?? '—'} ${b.dienstgrad}`); - } - } catch { - log(` WARN: could not parse Beförderungen table for StNr ${standesbuchNr} (url: ${url})`); + for (const row of rows) { + const dienstgrad = cellText(row.cells[1]); + if (!dienstgrad) continue; + const datum = parseDate(row.cells[0]); + const syncKey = `${standesbuchNr}::${dienstgrad}::${datum ?? ''}`; + results.push({ standesbuchNr, datum, dienstgrad, syncKey }); } - + log(` Beförderungen for StNr ${standesbuchNr}: ${results.length} rows`); + for (const b of results) log(` ${b.datum ?? '—'} ${b.dienstgrad}`); return results; } /** * Navigate to the Untersuchungen sub-page and scrape all medical exams. - * Keeps all rows (one per art+datum); DB stores all, queries filter latest per category. */ async function scrapeMemberUntersuchungen( frame: Frame, @@ -674,54 +711,33 @@ async function scrapeMemberUntersuchungen( ): Promise { const url = `${BASE_URL}/fdisk/module/mgvw/mitgliedschaften/UntersuchungenList.aspx` + `?id_mitgliedschaften=${idMitgliedschaft}&id_instanzen=${idInstanzen}&id_feuerwehren=${idFeuerwehren}`; - await frame_goto(frame, url); + + const rows = await navigateAndGetTableRows(frame, url); + if (!rows) return []; const results: FdiskUntersuchung[] = []; - - try { - await frame.waitForSelector('table.FdcLayList', { timeout: 10000 }); - const rows = await frame.$$eval('table.FdcLayList tbody tr', (trs) => - trs.map((tr) => { - const cells = Array.from(tr.querySelectorAll('td')); - const cell = (i: number) => (cells[i]?.textContent ?? '').trim(); - // Columns: 0=Datum, 1=Anmerkungen, 2=Untersuchungsart, 3=Tauglichkeitsstufe - return { - datum: cell(0), - anmerkungen: cell(1), - art: cell(2), - ergebnis: cell(3), - }; - }) - ); - - for (const row of rows) { - const art = cellText(row.art); - if (!art) continue; - const datum = parseDate(row.datum); - const syncKey = `${standesbuchNr}::${art}::${datum ?? ''}`; - results.push({ - standesbuchNr, - datum, - anmerkungen: cellText(row.anmerkungen), - art, - ergebnis: cellText(row.ergebnis), - syncKey, - }); - } - log(` Untersuchungen for StNr ${standesbuchNr}: ${results.length} rows`); - for (const u of results) { - log(` ${u.datum ?? '—'} [${u.art}] ${u.ergebnis ?? '—'} | ${u.anmerkungen ?? ''}`); - } - } catch { - log(` WARN: could not parse Untersuchungen table for StNr ${standesbuchNr} (url: ${url})`); + for (const row of rows) { + // Columns: 0=Datum, 1=Anmerkungen, 2=Untersuchungsart, 3=Tauglichkeitsstufe + const art = cellText(row.cells[2]); + if (!art) continue; + const datum = parseDate(row.cells[0]); + const syncKey = `${standesbuchNr}::${art}::${datum ?? ''}`; + results.push({ + standesbuchNr, + datum, + anmerkungen: cellText(row.cells[1]), + art, + ergebnis: cellText(row.cells[3]), + syncKey, + }); } - + log(` Untersuchungen for StNr ${standesbuchNr}: ${results.length} rows`); + for (const u of results) log(` ${u.datum ?? '—'} [${u.art}] ${u.ergebnis ?? '—'} | ${u.anmerkungen ?? ''}`); return results; } /** * Navigate to the Gesetzliche Fahrgenehmigungen sub-page and scrape all entries. - * This is an inline-edit (ListEdit) page — values are in fields. */ async function scrapeMemberFahrgenehmigungen( frame: Frame, @@ -732,63 +748,29 @@ async function scrapeMemberFahrgenehmigungen( ): Promise { const url = `${BASE_URL}/fdisk/module/mgvw/mitgliedschaften/Ges_fahrgenehmigungenListEdit.aspx` + `?id_mitgliedschaften=${idMitgliedschaft}&id_instanzen=${idInstanzen}&id_feuerwehren=${idFeuerwehren}`; - await frame_goto(frame, url); + + const rows = await navigateAndGetTableRows(frame, url); + if (!rows) return []; const results: FdiskFahrgenehmigung[] = []; - - try { - await frame.waitForSelector('table.FdcLayList', { timeout: 10000 }); - - // ListEdit pages: each data row has inline fields instead of plain text. + for (const row of rows) { // Columns: 0=Ausstellungsdatum, 1=Gültig bis, 2=Behörde, 3=Nummer, 4=Fahrgenehmigungsklasse - const rows = await frame.$$eval('table.FdcLayList tbody tr', (trs) => - trs.map((tr) => { - const cells = Array.from(tr.querySelectorAll('td')); - const cellVal = (i: number): string => { - const cell = cells[i]; - if (!cell) return ''; - const input = cell.querySelector('input[type="text"], input:not([type])') as HTMLInputElement | null; - if (input) return input.value?.trim() ?? ''; - const select = cell.querySelector('select') as HTMLSelectElement | null; - if (select) { - const opt = select.options[select.selectedIndex]; - return (opt?.text || opt?.value || '').trim(); - } - return cell.textContent?.trim() ?? ''; - }; - return { - ausstellungsdatum: cellVal(0), - gueltigBis: cellVal(1), - behoerde: cellVal(2), - nummer: cellVal(3), - klasse: cellVal(4), - }; - }) - ); - - for (const row of rows) { - const klasse = cellText(row.klasse); - if (!klasse) continue; - const ausstellungsdatum = parseDate(row.ausstellungsdatum); - const syncKey = `${standesbuchNr}::${klasse}::${ausstellungsdatum ?? ''}`; - results.push({ - standesbuchNr, - ausstellungsdatum, - gueltigBis: parseDate(row.gueltigBis), - behoerde: cellText(row.behoerde), - nummer: cellText(row.nummer), - klasse, - syncKey, - }); - } - log(` Fahrgenehmigungen for StNr ${standesbuchNr}: ${results.length} rows`); - for (const f of results) { - log(` ${f.ausstellungsdatum ?? '—'} [${f.klasse}] ${f.behoerde ?? ''} ${f.nummer ?? ''}`); - } - } catch { - log(` WARN: could not parse Fahrgenehmigungen table for StNr ${standesbuchNr} (url: ${url})`); + const klasse = cellText(row.cells[4]); + if (!klasse) continue; + const ausstellungsdatum = parseDate(row.cells[0]); + const syncKey = `${standesbuchNr}::${klasse}::${ausstellungsdatum ?? ''}`; + results.push({ + standesbuchNr, + ausstellungsdatum, + gueltigBis: parseDate(row.cells[1]), + behoerde: cellText(row.cells[2]), + nummer: cellText(row.cells[3]), + klasse, + syncKey, + }); } - + log(` Fahrgenehmigungen for StNr ${standesbuchNr}: ${results.length} rows`); + for (const f of results) log(` ${f.ausstellungsdatum ?? '—'} [${f.klasse}] ${f.behoerde ?? ''} ${f.nummer ?? ''}`); return results; }