From 43d9dcc31a185ccb41205900bfde2e8f1b98264d Mon Sep 17 00:00:00 2001 From: Philipp Hofer Date: Thu, 20 Nov 2025 19:21:11 +0100 Subject: [PATCH 1/2] more-robust-ui-tests (#1158) Co-authored-by: Philipp Hofer Reviewed-on: https://git.hofer.link/Ruderverein-Donau-Linz/rowt/pulls/1158 Co-authored-by: Philipp Hofer Co-committed-by: Philipp Hofer --- frontend/tests/cox.spec.ts | 180 +++++++++++++------------------------ frontend/tests/helpers.ts | 29 ++++++ frontend/tests/log.spec.ts | 108 ++-------------------- reset_test_data.sh | 19 ++++ 4 files changed, 116 insertions(+), 220 deletions(-) create mode 100644 frontend/tests/helpers.ts create mode 100755 reset_test_data.sh diff --git a/frontend/tests/cox.spec.ts b/frontend/tests/cox.spec.ts index 89da881..f31c0a1 100644 --- a/frontend/tests/cox.spec.ts +++ b/frontend/tests/cox.spec.ts @@ -1,4 +1,9 @@ -import { test, expect } from "@playwright/test"; +import { test, expect, Page } from "@playwright/test"; +import { resetDatabase, login } from "./helpers"; + +test.beforeEach(async () => { + await resetDatabase(); +}); test("cox can create and delete trip", async ({ page }) => { await page.goto("/auth"); @@ -16,22 +21,13 @@ test("cox can create and delete trip", async ({ page }) => { await page.getByRole("spinbutton").fill("5"); await page.getByRole("button", { name: "Erstellen", exact: true }).click(); await expect(page.locator("body")).toContainText("18:00 Uhr (cox) Details"); - - await page.goto("/planned"); - await page.getByRole('link', { name: 'Details' }).nth(1).click(); - await page.getByRole("link", { name: "Termin löschen" }).click(); - await expect(page.locator("body")).toContainText("Erfolgreich gelöscht!"); }); // TODO: group -> cox can create trips // TODO: cox can help/register at trips/events test.describe("cox can edit trips", () => { - let sharedPage: Page; - - test.beforeAll(async ({ browser }) => { - const page = await browser.newPage(); - + async function createTrip(page: Page) { await page.goto("/auth"); await page.getByPlaceholder("Name").click(); await page.getByPlaceholder("Name").fill("cox"); @@ -46,151 +42,101 @@ test.describe("cox can edit trips", () => { await page.locator("#sidebar #planned_starting_time").press("Tab"); await page.getByRole("spinbutton").fill("5"); await page.getByRole("button", { name: "Erstellen", exact: true }).click(); + } - sharedPage = page; - }); + test("edit remarks", async ({ page }) => { + await createTrip(page); - test("edit remarks", async () => { - await sharedPage.goto("/planned"); - await sharedPage.getByRole('link', { name: 'Details' }).nth(1).click(); - await sharedPage.locator("#sidebar #notes").click(); - await sharedPage.locator("#sidebar #notes").fill("Meine Anmerkung"); - await sharedPage.getByRole("button", { name: "Speichern" }).click(); - await sharedPage.getByRole("link", { name: "Details" }).nth(1).click(); - await expect(sharedPage.locator("#sidebar")).toContainText( + await page.goto("/planned"); + await page.getByRole('link', { name: 'Details' }).nth(1).click(); + await page.locator("#sidebar #notes").click(); + await page.locator("#sidebar #notes").fill("Meine Anmerkung"); + await page.getByRole("button", { name: "Speichern" }).click(); + await page.getByRole("link", { name: "Details" }).nth(1).click(); + await expect(page.locator("#sidebar")).toContainText( "Meine Anmerkung", ); - - await sharedPage - .getByRole("button", { name: "Ausfahrt erstellen schließen" }) - .click(); }); - test("add and remove guest", async () => { - await sharedPage.goto("/planned"); - await sharedPage.getByRole("link", { name: "Details" }).nth(1).click(); - await sharedPage.locator("#sidebar #user_note").click(); - await sharedPage.locator("#sidebar #user_note").fill("Mein Gast"); - await sharedPage.getByRole("button", { name: "Gast hinzufügen" }).click(); - await expect(sharedPage.locator("body")).toContainText( + test("add and remove guest", async ({ page }) => { + await createTrip(page); + + await page.goto("/planned"); + await page.getByRole("link", { name: "Details" }).nth(1).click(); + await page.locator("#sidebar #user_note").click(); + await page.locator("#sidebar #user_note").fill("Mein Gast"); + await page.getByRole("button", { name: "Gast hinzufügen" }).click(); + await expect(page.locator("body")).toContainText( "Erfolgreich angemeldet!", ); - await sharedPage.getByRole("link", { name: "Details" }).nth(1).click(); - await expect(sharedPage.locator("#sidebar")).toContainText( + await page.getByRole("link", { name: "Details" }).nth(1).click(); + await expect(page.locator("#sidebar")).toContainText( "Freie Plätze: 4", ); - await expect(sharedPage.locator("#sidebar")).toContainText( + await expect(page.locator("#sidebar")).toContainText( "Mein Gast (Gast) Abmelden", ); await expect( - sharedPage.getByRole("link", { name: "Termin löschen" }), + page.getByRole("link", { name: "Termin löschen" }), ).not.toBeVisible(); - await sharedPage.getByRole("link", { name: "Abmelden" }).click(); - await expect(sharedPage.locator("body")).toContainText( + await page.getByRole("link", { name: "Abmelden" }).click(); + await expect(page.locator("body")).toContainText( "Erfolgreich abgemeldet!", ); - await sharedPage.getByRole("link", { name: "Details" }).nth(1).click(); - await expect(sharedPage.locator("#sidebar")).toContainText( + await page.getByRole("link", { name: "Details" }).nth(1).click(); + await expect(page.locator("#sidebar")).toContainText( "Freie Plätze: 5", ); - await expect(sharedPage.locator("#sidebar")).toContainText( + await expect(page.locator("#sidebar")).toContainText( "Keine Ruderer angemeldet", ); await expect( - sharedPage.getByRole("link", { name: "Termin löschen" }), + page.getByRole("link", { name: "Termin löschen" }), ).toBeVisible(); - - await sharedPage - .getByRole("button", { name: "Ausfahrt erstellen schließen" }) - .click(); }); - test("change amount rower", async () => { - await sharedPage.goto("/planned"); - await sharedPage.getByRole("link", { name: "Details" }).nth(1).click(); - await expect(sharedPage.locator("#sidebar")).toContainText( + test("change amount rower", async ({ page }) => { + await createTrip(page); + + await page.goto("/planned"); + await page.getByRole("link", { name: "Details" }).nth(1).click(); + await expect(page.locator("#sidebar")).toContainText( "Freie Plätze: 5", ); - await sharedPage.getByRole("spinbutton").click(); - await sharedPage.getByRole("spinbutton").fill("3"); - await sharedPage.getByRole("button", { name: "Speichern" }).click(); - await expect(sharedPage.locator("body")).toContainText( + await page.getByRole("spinbutton").click(); + await page.getByRole("spinbutton").fill("3"); + await page.getByRole("button", { name: "Speichern" }).click(); + await expect(page.locator("body")).toContainText( "Ausfahrt erfolgreich aktualisiert.", ); }); - test("call off trip", async () => { + test("call off trip", async ({ page }) => { + await createTrip(page); + // Someone registers... - await sharedPage.goto("/auth/logout"); - await sharedPage.goto("/auth"); - await sharedPage.getByPlaceholder("Name").click(); - await sharedPage.getByPlaceholder("Name").fill("rower"); - await sharedPage.getByPlaceholder("Name").press("Tab"); - await sharedPage.getByPlaceholder("Passwort").fill("rower"); - await sharedPage.getByPlaceholder("Passwort").press("Enter"); + await page.goto("/auth/logout"); + await page.waitForURL("/auth"); + await login(page, "rower", "rower"); - await sharedPage.goto("/planned"); - await sharedPage.getByRole('link', { name: 'Mitrudern' }).nth(1).click(); + await page.goto("/planned"); + await page.getByRole('link', { name: 'Mitrudern' }).nth(1).click(); - // Login as cox again - await sharedPage.goto("/auth/logout"); - await sharedPage.goto("/auth"); - await sharedPage.getByPlaceholder("Name").click(); - await sharedPage.getByPlaceholder("Name").fill("cox"); - await sharedPage.getByPlaceholder("Name").press("Tab"); - await sharedPage.getByPlaceholder("Passwort").fill("cox"); - await sharedPage.getByPlaceholder("Passwort").press("Enter"); + await page.goto("/auth/logout"); + await page.waitForURL("/auth"); + await login(page, "cox", "cox"); - await sharedPage.goto("/planned"); + await page.goto("/planned"); - - // ... now I can cancel trip - await sharedPage.getByRole("link", { name: "Details" }).nth(1).click(); - await sharedPage.getByRole("button", { name: "Ausfahrt absagen" }).click(); - await expect(sharedPage.locator("body")).toContainText( + // Now cancel the trip + await page.getByRole("link", { name: "Details" }).nth(1).click(); + await page.getByRole("button", { name: "Ausfahrt absagen" }).click(); + await expect(page.locator("body")).toContainText( "Ausfahrt erfolgreich aktualisiert.", ); - await expect(sharedPage.locator("body")).toContainText("(Absage cox)"); - - - // Done with the test -> cancel the cancellation of the trip, otherwise the afterAll function below fails - await sharedPage.getByRole("link", { name: "Details" }).nth(1).click(); - await sharedPage.getByRole("spinbutton").click(); - await sharedPage.getByRole("spinbutton").fill("3"); - await sharedPage.getByRole("button", { name: "Speichern" }).click(); - - - - // deregistering - await sharedPage.goto("/auth/logout"); - await sharedPage.goto("/auth"); - await sharedPage.getByPlaceholder("Name").click(); - await sharedPage.getByPlaceholder("Name").fill("rower"); - await sharedPage.getByPlaceholder("Name").press("Tab"); - await sharedPage.getByPlaceholder("Passwort").fill("rower"); - await sharedPage.getByPlaceholder("Passwort").press("Enter"); - - await sharedPage.goto("/planned"); - await sharedPage.getByRole('link', { name: 'Abmelden' }).click(); - - - // now cox can delete trip again in afterAll - await sharedPage.goto("/auth/logout"); - await sharedPage.goto("/auth"); - await sharedPage.getByPlaceholder("Name").click(); - await sharedPage.getByPlaceholder("Name").fill("cox"); - await sharedPage.getByPlaceholder("Name").press("Tab"); - await sharedPage.getByPlaceholder("Passwort").fill("cox"); - await sharedPage.getByPlaceholder("Passwort").press("Enter"); - }); - - test.afterAll(async () => { - await sharedPage.goto("/planned"); - await sharedPage.getByRole('link', { name: 'Details' }).nth(1).click(); - await sharedPage.getByRole("link", { name: "Termin löschen" }).click(); - await sharedPage.close(); + await expect(page.locator("body")).toContainText("(Absage cox)"); }); // TODO: 'Immer anzeigen' (also verify the functionality), 'Gesperrt' + type diff --git a/frontend/tests/helpers.ts b/frontend/tests/helpers.ts new file mode 100644 index 0000000..ce590d3 --- /dev/null +++ b/frontend/tests/helpers.ts @@ -0,0 +1,29 @@ +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { Page } from '@playwright/test'; + +const execAsync = promisify(exec); + +export async function resetDatabase(): Promise { + await execAsync('cd .. && ./reset_test_data.sh'); +} + +export async function login(page: Page, username: string, password: string): Promise { + // Clear cookies to ensure clean state + await page.context().clearCookies(); + + // Navigate to auth page and wait for it to fully load + await page.goto("/auth", { waitUntil: 'load' }); + await page.waitForLoadState('networkidle'); + + await page.getByPlaceholder("Name").click(); + await page.getByPlaceholder("Name").fill(username); + await page.getByPlaceholder("Passwort").click(); + await page.getByPlaceholder("Passwort").fill(password); + + // Wait for navigation after form submission + await Promise.all([ + page.waitForURL(/\/(planned|log|$)/, { timeout: 10000 }), + page.getByPlaceholder("Passwort").press("Enter") + ]); +} diff --git a/frontend/tests/log.spec.ts b/frontend/tests/log.spec.ts index 626a597..fce7df1 100644 --- a/frontend/tests/log.spec.ts +++ b/frontend/tests/log.spec.ts @@ -1,4 +1,9 @@ import { test, expect } from "@playwright/test"; +import { resetDatabase } from "./helpers"; + +test.beforeEach(async () => { + await resetDatabase(); +}); test("Cox can start and cancel trip", async ({ page }, testInfo) => { await page.goto("/auth"); @@ -34,12 +39,6 @@ test("Cox can start and cancel trip", async ({ page }, testInfo) => { "Ausfahrt erfolgreich hinzugefügt", ); await expect(page.locator("body")).toContainText("Joe"); - - await page.getByRole("link", { name: "Joe" }).click(); - page.once("dialog", (dialog) => { - dialog.accept().catch(() => {}); - }); - await page.getByRole("link", { name: "Löschen" }).click(); }); test("Cox can start and finish trip", async ({ page }, testInfo) => { @@ -102,28 +101,6 @@ test("Cox can start and finish trip", async ({ page }, testInfo) => { await expect(page.locator('body')).toContainText('(cox2)'); await expect(page.locator('body')).toContainText('Ottensheim (25 km)'); await expect(page.locator('body')).toContainText('Ruderer: cox2, rower2'); - - - //Ausloggen... - await page.getByRole('banner').getByRole('link', { name: 'Logbuch' }).click(); - await page.getByRole('link', { name: 'Ausloggen' }).click(); - // Login as admin - await page.getByPlaceholder("Name").click(); - await page.getByPlaceholder("Name").fill("main"); - await page.getByPlaceholder("Name").press("Tab"); - await page.getByPlaceholder("Passwort").fill("admin"); - await page.getByPlaceholder("Passwort").press("Enter"); - - await page.goto("/log/show"); - await page.getByRole('link', { name: 'Joe' }).nth(1).click(); - page.once("dialog", (dialog) => { - dialog.accept().catch(() => {}); - }); - await page.getByRole('link', { name: 'Löschen' }).click(); - - //Ausloggen... - await page.getByRole('banner').getByRole('link', { name: 'Logbuch' }).click(); - await page.getByRole('link', { name: 'Ausloggen' }).click(); }); test("Kiosk can start and cancel trip", async ({ page }, testInfo) => { @@ -151,12 +128,6 @@ test("Kiosk can start and cancel trip", async ({ page }, testInfo) => { "Ausfahrt erfolgreich hinzugefügt", ); await expect(page.locator("body")).toContainText("Joe"); - - await page.getByRole("link", { name: "Joe" }).click(); - page.once("dialog", (dialog) => { - dialog.accept().catch(() => {}); - }); - await page.getByRole("link", { name: "Löschen" }).click(); }); test("Kiosk can start and finish trip", async ({ page }, testInfo) => { @@ -210,29 +181,6 @@ test("Kiosk can start and finish trip", async ({ page }, testInfo) => { await expect(page.locator('body')).toContainText('Joe'); await expect(page.locator('body')).toContainText('Ottensheim (25 km)'); await expect(page.locator('body')).toContainText('Ruderer: cox2, rower2'); - - - - //Ausloggen... - await page.context().clearCookies(); - await page.goto("/auth"); - // Login as admin - await page.getByPlaceholder("Name").click(); - await page.getByPlaceholder("Name").fill("main"); - await page.getByPlaceholder("Name").press("Tab"); - await page.getByPlaceholder("Passwort").fill("admin"); - await page.getByPlaceholder("Passwort").press("Enter"); - - await page.goto("/log/show"); - await page.getByRole('link', { name: 'Joe' }).nth(1).click(); - page.once("dialog", (dialog) => { - dialog.accept().catch(() => {}); - }); - await page.getByRole('link', { name: 'Löschen' }).click(); - - //Ausloggen... - await page.getByRole('banner').getByRole('link', { name: 'Logbuch' }).click(); - await page.getByRole('link', { name: 'Ausloggen' }).click(); }); test("Cox can start and finish trip with cox steering only", async ({ page }, testInfo) => { @@ -286,29 +234,6 @@ test("Cox can start and finish trip with cox steering only", async ({ page }, te await page.goto('/log/show'); await expect(page.locator('body')).toContainText('cox_only_steering_boat'); await expect(page.locator('body')).toContainText('Ottensheim (25 km)'); - - - - //Ausloggen... - await page.getByRole('banner').getByRole('link', { name: 'Logbuch' }).click(); - await page.getByRole('link', { name: 'Ausloggen' }).click(); - // Login as admin - await page.getByPlaceholder("Name").click(); - await page.getByPlaceholder("Name").fill("main"); - await page.getByPlaceholder("Name").press("Tab"); - await page.getByPlaceholder("Passwort").fill("admin"); - await page.getByPlaceholder("Passwort").press("Enter"); - - await page.goto("/log/show"); - await page.getByRole("link", { name: "cox_only_steering_boat" }).click(); - page.once("dialog", (dialog) => { - dialog.accept().catch(() => {}); - }); - await page.getByRole('link', { name: 'Löschen' }).click(); - - //Ausloggen... - await page.getByRole('banner').getByRole('link', { name: 'Logbuch' }).click(); - await page.getByRole('link', { name: 'Ausloggen' }).click(); }); test("Kiosk can start and finish trip in one stop", async ({ page }, testInfo) => { @@ -355,27 +280,4 @@ test("Kiosk can start and finish trip in one stop", async ({ page }, testInfo) = await expect(page.locator('body')).toContainText('(cox2)'); await expect(page.locator('body')).toContainText('a (1 km)'); await expect(page.locator('body')).toContainText('Ruderer: cox2, rower2'); - - - - //Ausloggen... - await page.context().clearCookies(); - await page.goto("/auth"); - // Login as admin - await page.getByPlaceholder("Name").click(); - await page.getByPlaceholder("Name").fill("main"); - await page.getByPlaceholder("Name").press("Tab"); - await page.getByPlaceholder("Passwort").fill("admin"); - await page.getByPlaceholder("Passwort").press("Enter"); - - await page.goto("/log/show"); - await page.getByRole('link', { name: 'Joe' }).nth(1).click(); - page.once("dialog", (dialog) => { - dialog.accept().catch(() => {}); - }); - await page.getByRole('link', { name: 'Löschen' }).click(); - - //Ausloggen... - await page.getByRole('banner').getByRole('link', { name: 'Logbuch' }).click(); - await page.getByRole('link', { name: 'Ausloggen' }).click(); }); diff --git a/reset_test_data.sh b/reset_test_data.sh new file mode 100755 index 0000000..08c8066 --- /dev/null +++ b/reset_test_data.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -e + +DB_FILE="db.sqlite" + +# Clear all data and reseed +sqlite3 "$DB_FILE" << 'EOF' +PRAGMA writable_schema = 1; +DELETE FROM sqlite_sequence; +PRAGMA writable_schema = 0; +PRAGMA foreign_keys = OFF; +EOF + +# Get all tables and delete from them +sqlite3 "$DB_FILE" "SELECT 'DELETE FROM ' || name || ';' FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';" | sqlite3 "$DB_FILE" + +# Re-enable foreign keys and reseed +sqlite3 "$DB_FILE" "PRAGMA foreign_keys = ON;" +sqlite3 "$DB_FILE" < seeds.sql From 3148d744e6000493f74ce1b81ede3e8cec531349 Mon Sep 17 00:00:00 2001 From: Philipp Hofer Date: Fri, 21 Nov 2025 10:32:59 +0100 Subject: [PATCH 2/2] yearly cleanup of roles; fixes #941 --- src/scheduled/mod.rs | 19 +++- src/scheduled/yearly_role_cleanup.rs | 158 +++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 src/scheduled/yearly_role_cleanup.rs diff --git a/src/scheduled/mod.rs b/src/scheduled/mod.rs index acae7df..9f2d4f5 100644 --- a/src/scheduled/mod.rs +++ b/src/scheduled/mod.rs @@ -1,5 +1,6 @@ mod waterlevel; mod weather; +mod yearly_role_cleanup; use std::time::Duration; @@ -13,7 +14,7 @@ pub fn schedule(db: &SqlitePool, config: &Config) { let db = db.clone(); let openweathermap_key = config.openweathermap_key.clone(); - tokio::task::spawn(async { + tokio::task::spawn(async move { if let Err(e) = waterlevel::update(&db).await { log::error!("Water level update error: {e}, trying again next time"); } @@ -24,8 +25,9 @@ pub fn schedule(db: &SqlitePool, config: &Config) { let mut sched = JobScheduler::new(); // Every hour + let db_for_hourly = db.clone(); sched.add(Job::new("0 0 * * * * *".parse().unwrap(), move || { - let db_clone = db.clone(); + let db_clone = db_for_hourly.clone(); // Use block_in_place to run async code in the synchronous function; TODO: Make it // nicer one's rust (stable) support async closures task::block_in_place(|| { @@ -40,6 +42,19 @@ pub fn schedule(db: &SqlitePool, config: &Config) { }); })); + // January 1st at midnight - yearly role cleanup + let db_for_yearly = db.clone(); + sched.add(Job::new("0 0 0 1 1 * *".parse().unwrap(), move || { + let db_clone = db_for_yearly.clone(); + task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + if let Err(e) = yearly_role_cleanup::cleanup_roles(&db_clone).await { + log::error!("Yearly role cleanup error: {e}"); + } + }); + }); + })); + let mut interval = time::interval(Duration::from_secs(60)); loop { sched.tick(); diff --git a/src/scheduled/yearly_role_cleanup.rs b/src/scheduled/yearly_role_cleanup.rs new file mode 100644 index 0000000..cf237bd --- /dev/null +++ b/src/scheduled/yearly_role_cleanup.rs @@ -0,0 +1,158 @@ +use crate::model::{notification::Notification, role::Role}; +use sqlx::SqlitePool; + +pub async fn cleanup_roles(db: &SqlitePool) -> Result<(), String> { + log::info!("Starting yearly role cleanup..."); + + let mut tx = db.begin().await.map_err(|e| e.to_string())?; + + // Find all roles to remove + let paid_role = Role::find_by_name_tx(&mut tx, "paid") + .await + .ok_or("Role 'paid' not found")?; + let schueler_role = Role::find_by_name_tx(&mut tx, "Schüler") + .await + .ok_or("Role 'Schüler' not found")?; + let student_role = Role::find_by_name_tx(&mut tx, "Student") + .await + .ok_or("Role 'Student' not found")?; + let no_einschreibgebuehr_role = Role::find_by_name_tx(&mut tx, "no-einschreibgebuehr") + .await + .ok_or("Role 'no-einschreibgebuehr' not found")?; + let half_rennrudern_role = Role::find_by_name_tx(&mut tx, "half-rennrudern") + .await + .ok_or("Role 'half-rennrudern' not found")?; + let participated_schnupperkurs_role = + Role::find_by_name_tx(&mut tx, "participated_schnupperkurs") + .await + .ok_or("Role 'participated_schnupperkurs' not found")?; + + // Find scheckbuch role (needed to exclude users from "paid" removal -> they have still paid + // for the scheckbuch) + let scheckbuch_role = Role::find_by_name_tx(&mut tx, "scheckbuch") + .await + .ok_or("Role 'scheckbuch' not found")?; + + // Remove "paid" role from all users EXCEPT those with scheckbuch role + let paid_removed = sqlx::query!( + "DELETE FROM user_role + WHERE role_id = ? + AND user_id NOT IN ( + SELECT user_id FROM user_role WHERE role_id = ? + )", + paid_role.id, + scheckbuch_role.id + ) + .execute(&mut *tx) + .await + .map_err(|e| e.to_string())? + .rows_affected(); + + // Remove other roles from all users + let schueler_removed = + sqlx::query!("DELETE FROM user_role WHERE role_id = ?", schueler_role.id) + .execute(&mut *tx) + .await + .map_err(|e| e.to_string())? + .rows_affected(); + + let student_removed = sqlx::query!("DELETE FROM user_role WHERE role_id = ?", student_role.id) + .execute(&mut *tx) + .await + .map_err(|e| e.to_string())? + .rows_affected(); + + let no_einschreibgebuehr_removed = sqlx::query!( + "DELETE FROM user_role WHERE role_id = ?", + no_einschreibgebuehr_role.id + ) + .execute(&mut *tx) + .await + .map_err(|e| e.to_string())? + .rows_affected(); + + let half_rennrudern_removed = sqlx::query!( + "DELETE FROM user_role WHERE role_id = ?", + half_rennrudern_role.id + ) + .execute(&mut *tx) + .await + .map_err(|e| e.to_string())? + .rows_affected(); + + let participated_schnupperkurs_removed = sqlx::query!( + "DELETE FROM user_role WHERE role_id = ?", + participated_schnupperkurs_role.id + ) + .execute(&mut *tx) + .await + .map_err(|e| e.to_string())? + .rows_affected(); + + // Send notifications to admins and Vorstand + let admin_role = Role::find_by_name_tx(&mut tx, "admin") + .await + .ok_or("Role 'admin' not found")?; + let vorstand_role = Role::find_by_name_tx(&mut tx, "Vorstand") + .await + .ok_or("Role 'Vorstand' not found")?; + + let notification_message_admin = format!( + "Jährliche Rollenbereinigung abgeschlossen. Die folgenden Rollen wurden entfernt: \ + paid ({} Benutzer, außer Scheckbuch-Mitglieder), \ + Schüler/Student ({}/{} Benutzer), \ + no-einschreibgebuehr ({} Benutzer), \ + half-rennrudern ({} Benutzer), \ + participated_schnupperkurs ({} Benutzer). \ + Die aktualisierten Gebühren können unter https://app.rudernlinz.at/admin/user/fees eingesehen werden.", + paid_removed, + schueler_removed, + student_removed, + no_einschreibgebuehr_removed, + half_rennrudern_removed, + participated_schnupperkurs_removed + ); + let notification_message_vorstand = format!( + "Jährliche Rollenbereinigung abgeschlossen. \ + Die aktualisierten Gebühren können unter https://app.rudernlinz.at/admin/user/fees eingesehen werden.", + ); + + // Notify admins + Notification::create_for_role_tx( + &mut tx, + &admin_role, + ¬ification_message_admin, + "Systembenachrichtigung", + Some("https://app.rudernlinz.at/admin/user/fees"), + None, + ) + .await; + + // Notify Vorstand + Notification::create_for_role_tx( + &mut tx, + &vorstand_role, + ¬ification_message_vorstand, + "Systembenachrichtigung", + Some("https://app.rudernlinz.at/admin/user/fees"), + None, + ) + .await; + + // Commit transaction + tx.commit().await.map_err(|e| e.to_string())?; + + log::info!( + "Yearly role cleanup completed successfully: \ + paid={}, Schüler={}, Student={}, no-einschreibgebuehr={}, \ + half-rennrudern={}, participated_schnupperkurs={} removals", + paid_removed, + schueler_removed, + student_removed, + no_einschreibgebuehr_removed, + half_rennrudern_removed, + participated_schnupperkurs_removed + ); + + Ok(()) +}