Initial commit: Rust YpDaemon

This commit is contained in:
Torsten Schulz (local)
2025-11-21 23:05:34 +01:00
commit d0ec363f09
21 changed files with 8067 additions and 0 deletions

732
src/worker/politics.rs Normal file
View File

@@ -0,0 +1,732 @@
use crate::db::{ConnectionPool, DbError, Row};
use crate::message_broker::MessageBroker;
use std::collections::HashSet;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::{Duration, Instant};
use super::base::{BaseWorker, Worker, WorkerState};
pub struct PoliticsWorker {
base: BaseWorker,
}
#[derive(Debug, Clone)]
struct OfficeCounts {
region_id: i32,
required: i32,
occupied: i32,
}
#[derive(Debug, Clone)]
struct Election {
election_id: i32,
region_id: i32,
posts_to_fill: i32,
}
#[derive(Debug, Clone)]
struct Office {
office_id: i32,
office_type_id: i32,
character_id: i32,
region_id: i32,
}
// --- SQL-Konstanten (1:1 aus politics_worker.h übernommen) ------------------
const QUERY_COUNT_OFFICES_PER_REGION: &str = r#"
WITH
seats_per_region AS (
SELECT
pot.id AS office_type_id,
rt.id AS region_id,
pot.seats_per_region AS seats_total
FROM falukant_type.political_office_type AS pot
JOIN falukant_type.region AS rt
ON pot.region_type = rt.label_tr
),
occupied AS (
SELECT
po.office_type_id,
po.region_id,
COUNT(*) AS occupied_count
FROM falukant_data.political_office AS po
GROUP BY po.office_type_id, po.region_id
),
combined AS (
SELECT
spr.region_id,
spr.seats_total AS required_count,
COALESCE(o.occupied_count, 0) AS occupied_count
FROM seats_per_region AS spr
LEFT JOIN occupied AS o
ON spr.office_type_id = o.office_type_id
AND spr.region_id = o.region_id
)
SELECT
region_id,
SUM(required_count) AS required_count,
SUM(occupied_count) AS occupied_count
FROM combined
GROUP BY region_id;
"#;
const QUERY_SELECT_NEEDED_ELECTIONS: &str = r#"
WITH
target_date AS (
SELECT NOW()::date AS election_date
),
expired_today AS (
DELETE FROM falukant_data.political_office AS po
USING falukant_type.political_office_type AS pot
WHERE po.office_type_id = pot.id
AND (po.created_at + (pot.term_length * INTERVAL '1 day'))::date
= (SELECT election_date FROM target_date)
RETURNING
pot.id AS office_type_id,
po.region_id AS region_id
),
gaps_per_region AS (
SELECT
office_type_id,
region_id,
COUNT(*) AS gaps
FROM expired_today
GROUP BY office_type_id, region_id
),
to_schedule AS (
SELECT
g.office_type_id,
g.region_id,
g.gaps,
td.election_date
FROM gaps_per_region AS g
CROSS JOIN target_date AS td
WHERE NOT EXISTS (
SELECT 1
FROM falukant_data.election AS e
WHERE e.office_type_id = g.office_type_id
AND e.region_id = g.region_id
AND e.date::date = td.election_date
)
),
new_elections AS (
INSERT INTO falukant_data.election
(office_type_id, date, posts_to_fill, created_at, updated_at, region_id)
SELECT
ts.office_type_id,
ts.election_date,
ts.gaps,
NOW(),
NOW(),
ts.region_id
FROM to_schedule AS ts
RETURNING
id AS election_id,
region_id,
posts_to_fill
)
SELECT
ne.election_id,
ne.region_id,
ne.posts_to_fill
FROM new_elections AS ne
ORDER BY ne.region_id, ne.election_id;
"#;
const QUERY_INSERT_CANDIDATES: &str = r#"
INSERT INTO falukant_data.candidate
(election_id, character_id, created_at, updated_at)
SELECT
$1 AS election_id,
sub.id AS character_id,
NOW() AS created_at,
NOW() AS updated_at
FROM (
WITH RECURSIVE region_tree AS (
SELECT r.id
FROM falukant_data.region AS r
WHERE r.id = $2
UNION ALL
SELECT r2.id
FROM falukant_data.region AS r2
JOIN region_tree AS rt
ON r2.parent_id = rt.id
)
SELECT ch.id
FROM falukant_data.character AS ch
JOIN region_tree AS rt2
ON ch.region_id = rt2.id
WHERE ch.user_id IS NULL
AND ch.birthdate <= NOW() - INTERVAL '21 days'
AND ch.title_of_nobility IN (
SELECT id
FROM falukant_type.title
WHERE label_tr != 'noncivil'
)
ORDER BY RANDOM()
LIMIT ($3 * 2)
) AS sub(id);
"#;
const QUERY_PROCESS_EXPIRED_AND_FILL: &str = r#"
WITH
expired_offices AS (
DELETE FROM falukant_data.political_office AS po
USING falukant_type.political_office_type AS pot
WHERE po.office_type_id = pot.id
AND (po.created_at + (pot.term_length * INTERVAL '1 day')) <= NOW()
RETURNING
pot.id AS office_type_id,
po.region_id AS region_id
),
distinct_types AS (
SELECT DISTINCT office_type_id, region_id FROM expired_offices
),
votes_per_candidate AS (
SELECT
dt.office_type_id,
dt.region_id,
c.character_id,
COUNT(v.id) AS vote_count
FROM distinct_types AS dt
JOIN falukant_data.election AS e
ON e.office_type_id = dt.office_type_id
JOIN falukant_data.vote AS v
ON v.election_id = e.id
JOIN falukant_data.candidate AS c
ON c.election_id = e.id
AND c.id = v.candidate_id
WHERE e.date >= (NOW() - INTERVAL '30 days')
GROUP BY dt.office_type_id, dt.region_id, c.character_id
),
ranked_winners AS (
SELECT
vpc.office_type_id,
vpc.region_id,
vpc.character_id,
ROW_NUMBER() OVER (
PARTITION BY vpc.office_type_id, vpc.region_id
ORDER BY vpc.vote_count DESC
) AS rn
FROM votes_per_candidate AS vpc
),
selected_winners AS (
SELECT
rw.office_type_id,
rw.region_id,
rw.character_id
FROM ranked_winners AS rw
JOIN falukant_type.political_office_type AS pot
ON pot.id = rw.office_type_id
WHERE rw.rn <= pot.seats_per_region
),
insert_winners AS (
INSERT INTO falukant_data.political_office
(office_type_id, character_id, created_at, updated_at, region_id)
SELECT
sw.office_type_id,
sw.character_id,
NOW(),
NOW(),
sw.region_id
FROM selected_winners AS sw
RETURNING id AS new_office_id, office_type_id, character_id, region_id
),
count_inserted AS (
SELECT
office_type_id,
region_id,
COUNT(*) AS inserted_count
FROM insert_winners
GROUP BY office_type_id, region_id
),
needed_to_fill AS (
SELECT
dt.office_type_id,
dt.region_id,
(pot.seats_per_region - COALESCE(ci.inserted_count, 0)) AS gaps
FROM distinct_types AS dt
JOIN falukant_type.political_office_type AS pot
ON pot.id = dt.office_type_id
LEFT JOIN count_inserted AS ci
ON ci.office_type_id = dt.office_type_id
AND ci.region_id = dt.region_id
WHERE (pot.seats_per_region - COALESCE(ci.inserted_count, 0)) > 0
),
random_candidates AS (
SELECT
rtf.office_type_id,
rtf.region_id,
ch.id AS character_id,
ROW_NUMBER() OVER (
PARTITION BY rtf.office_type_id, rtf.region_id
ORDER BY RANDOM()
) AS rn
FROM needed_to_fill AS rtf
JOIN falukant_data.character AS ch
ON ch.region_id = rtf.region_id
AND ch.user_id IS NULL
AND ch.birthdate <= NOW() - INTERVAL '21 days'
AND ch.title_of_nobility IN (
SELECT id FROM falukant_type.title WHERE label_tr != 'noncivil'
)
AND NOT EXISTS (
SELECT 1
FROM falukant_data.political_office AS po2
JOIN falukant_type.political_office_type AS pot2
ON pot2.id = po2.office_type_id
WHERE po2.character_id = ch.id
AND (po2.created_at + (pot2.term_length * INTERVAL '1 day')) >
NOW() + INTERVAL '2 days'
)
),
insert_random AS (
INSERT INTO falukant_data.political_office
(office_type_id, character_id, created_at, updated_at, region_id)
SELECT
rc.office_type_id,
rc.character_id,
NOW(),
NOW(),
rc.region_id
FROM random_candidates AS rc
JOIN needed_to_fill AS rtf
ON rtf.office_type_id = rc.office_type_id
AND rtf.region_id = rc.region_id
WHERE rc.rn <= rtf.gaps
RETURNING id AS new_office_id, office_type_id, character_id, region_id
)
SELECT
new_office_id AS office_id,
office_type_id,
character_id,
region_id
FROM insert_winners
UNION ALL
SELECT
new_office_id AS office_id,
office_type_id,
character_id,
region_id
FROM insert_random;
"#;
const QUERY_USERS_IN_CITIES_OF_REGIONS: &str = r#"
WITH RECURSIVE region_tree AS (
SELECT id
FROM falukant_data.region
WHERE id = $1
UNION ALL
SELECT r2.id
FROM falukant_data.region AS r2
JOIN region_tree AS rt
ON r2.parent_id = rt.id
)
SELECT DISTINCT ch.user_id
FROM falukant_data.character AS ch
JOIN region_tree AS rt2
ON ch.region_id = rt2.id
WHERE ch.user_id IS NOT NULL;
"#;
const QUERY_NOTIFY_OFFICE_EXPIRATION: &str = r#"
INSERT INTO falukant_log.notification
(user_id, tr, created_at, updated_at)
SELECT
po.character_id,
'notify_office_expiring',
NOW(),
NOW()
FROM falukant_data.political_office AS po
JOIN falukant_type.political_office_type AS pot
ON po.office_type_id = pot.id
WHERE (po.created_at + (pot.term_length * INTERVAL '1 day'))
BETWEEN (NOW() + INTERVAL '2 days')
AND (NOW() + INTERVAL '2 days' + INTERVAL '1 second');
"#;
const QUERY_NOTIFY_ELECTION_CREATED: &str = r#"
INSERT INTO falukant_log.notification
(user_id, tr, created_at, updated_at)
VALUES
($1, 'notify_election_created', NOW(), NOW());
"#;
const QUERY_NOTIFY_OFFICE_FILLED: &str = r#"
INSERT INTO falukant_log.notification
(user_id, tr, created_at, updated_at)
VALUES
($1, 'notify_office_filled', NOW(), NOW());
"#;
const QUERY_GET_USERS_WITH_EXPIRING_OFFICES: &str = r#"
SELECT DISTINCT ch.user_id
FROM falukant_data.political_office AS po
JOIN falukant_type.political_office_type AS pot
ON po.office_type_id = pot.id
JOIN falukant_data.character AS ch
ON po.character_id = ch.id
WHERE ch.user_id IS NOT NULL
AND (po.created_at + (pot.term_length * INTERVAL '1 day'))
BETWEEN (NOW() + INTERVAL '2 days')
AND (NOW() + INTERVAL '2 days' + INTERVAL '1 second');
"#;
const QUERY_GET_USERS_IN_REGIONS_WITH_ELECTIONS: &str = r#"
SELECT DISTINCT ch.user_id
FROM falukant_data.election AS e
JOIN falukant_data.character AS ch
ON ch.region_id = e.region_id
WHERE ch.user_id IS NOT NULL
AND e.date >= NOW() - INTERVAL '1 day';
"#;
const QUERY_GET_USERS_WITH_FILLED_OFFICES: &str = r#"
SELECT DISTINCT ch.user_id
FROM falukant_data.political_office AS po
JOIN falukant_data.character AS ch
ON po.character_id = ch.id
WHERE ch.user_id IS NOT NULL
AND po.created_at >= NOW() - INTERVAL '1 minute';
"#;
const QUERY_PROCESS_ELECTIONS: &str = r#"
SELECT office_id, office_type_id, character_id, region_id
FROM falukant_data.process_elections();
"#;
impl PoliticsWorker {
pub fn new(pool: ConnectionPool, broker: MessageBroker) -> Self {
Self {
base: BaseWorker::new("PoliticsWorker", pool, broker),
}
}
fn run_loop(pool: ConnectionPool, broker: MessageBroker, state: Arc<WorkerState>) {
let mut last_execution: Option<Instant> = None;
while state.running_worker.load(Ordering::Relaxed) {
let now = Instant::now();
let should_run = match last_execution {
None => true,
Some(prev) => now.saturating_duration_since(prev) >= Duration::from_secs(24 * 3600),
};
if should_run {
if let Err(err) = Self::perform_daily_politics_task(&pool, &broker) {
eprintln!("[PoliticsWorker] Fehler bei performDailyPoliticsTask: {err}");
}
last_execution = Some(now);
}
// Entspricht ungefähr der 5-Sekunden-Schleife im C++-Code
for _ in 0..5 {
if !state.running_worker.load(Ordering::Relaxed) {
break;
}
std::thread::sleep(Duration::from_secs(1));
}
}
}
fn perform_daily_politics_task(
pool: &ConnectionPool,
broker: &MessageBroker,
) -> Result<(), DbError> {
// 1) Optional: Positionen evaluieren (aktuell nur Logging/Struktur)
let _ = Self::evaluate_political_positions(pool)?;
// 2) Ämter, die bald auslaufen, benachrichtigen
Self::notify_office_expirations(pool, broker)?;
// 3) Abgelaufene Ämter verarbeiten und neue besetzen
let new_offices_direct = Self::process_expired_offices_and_fill(pool)?;
if !new_offices_direct.is_empty() {
Self::notify_office_filled(pool, broker, &new_offices_direct)?;
}
// 4) Neue Wahlen planen und Kandidaten eintragen
let elections = Self::schedule_elections(pool)?;
if !elections.is_empty() {
Self::insert_candidates_for_elections(pool, &elections)?;
// Benachrichtige User in betroffenen Regionen
let region_ids: HashSet<i32> =
elections.iter().map(|e| e.region_id).collect();
let user_ids =
Self::get_user_ids_in_cities_of_regions(pool, &region_ids)?;
Self::notify_election_created(pool, broker, &user_ids)?;
}
// 5) Wahlen auswerten und neu besetzte Ämter melden
let new_offices_from_elections = Self::process_elections(pool)?;
if !new_offices_from_elections.is_empty() {
Self::notify_office_filled(pool, broker, &new_offices_from_elections)?;
}
Ok(())
}
fn evaluate_political_positions(
pool: &ConnectionPool,
) -> Result<Vec<OfficeCounts>, DbError> {
let mut conn = pool
.get()
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
conn.prepare(
"count_offices_per_region",
QUERY_COUNT_OFFICES_PER_REGION,
)?;
let rows = conn.execute("count_offices_per_region", &[])?;
let mut result = Vec::with_capacity(rows.len());
for row in rows {
let region_id = parse_i32(&row, "region_id", -1);
let required = parse_i32(&row, "required_count", 0);
let occupied = parse_i32(&row, "occupied_count", 0);
if region_id >= 0 {
result.push(OfficeCounts {
region_id,
required,
occupied,
});
}
}
Ok(result)
}
fn schedule_elections(pool: &ConnectionPool) -> Result<Vec<Election>, DbError> {
let mut conn = pool
.get()
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
conn.prepare("select_needed_elections", QUERY_SELECT_NEEDED_ELECTIONS)?;
let rows = conn.execute("select_needed_elections", &[])?;
let mut elections = Vec::with_capacity(rows.len());
for row in rows {
let election_id = parse_i32(&row, "election_id", -1);
let region_id = parse_i32(&row, "region_id", -1);
let posts_to_fill = parse_i32(&row, "posts_to_fill", 0);
if election_id >= 0 && region_id >= 0 {
elections.push(Election {
election_id,
region_id,
posts_to_fill,
});
}
}
Ok(elections)
}
fn insert_candidates_for_elections(
pool: &ConnectionPool,
elections: &[Election],
) -> Result<(), DbError> {
let mut conn = pool
.get()
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
conn.prepare("insert_candidates", QUERY_INSERT_CANDIDATES)?;
for e in elections {
conn.execute(
"insert_candidates",
&[&e.election_id, &e.region_id, &e.posts_to_fill],
)?;
}
Ok(())
}
fn process_expired_offices_and_fill(
pool: &ConnectionPool,
) -> Result<Vec<Office>, DbError> {
let mut conn = pool
.get()
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
conn.prepare("process_expired_and_fill", QUERY_PROCESS_EXPIRED_AND_FILL)?;
let rows = conn.execute("process_expired_and_fill", &[])?;
Ok(rows
.into_iter()
.filter_map(map_row_to_office)
.collect())
}
fn get_user_ids_in_cities_of_regions(
pool: &ConnectionPool,
region_ids: &HashSet<i32>,
) -> Result<Vec<i32>, DbError> {
if region_ids.is_empty() {
return Ok(Vec::new());
}
let mut conn = pool
.get()
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
conn.prepare("get_users_in_cities", QUERY_USERS_IN_CITIES_OF_REGIONS)?;
let mut user_ids = Vec::new();
for rid in region_ids {
let rows = conn.execute("get_users_in_cities", &[rid])?;
for row in rows {
if let Some(uid) = row.get("user_id").and_then(|v| v.parse::<i32>().ok()) {
user_ids.push(uid);
}
}
}
Ok(user_ids)
}
fn notify_office_expirations(
pool: &ConnectionPool,
broker: &MessageBroker,
) -> Result<(), DbError> {
let mut conn = pool
.get()
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
conn.prepare("notify_office_expiration", QUERY_NOTIFY_OFFICE_EXPIRATION)?;
conn.execute("notify_office_expiration", &[])?;
conn.prepare(
"get_users_with_expiring_offices",
QUERY_GET_USERS_WITH_EXPIRING_OFFICES,
)?;
let rows = conn.execute("get_users_with_expiring_offices", &[])?;
for row in rows {
if let Some(user_id) = row.get("user_id").and_then(|v| v.parse::<i32>().ok()) {
let msg =
format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id);
broker.publish(msg);
}
}
Ok(())
}
fn notify_election_created(
pool: &ConnectionPool,
broker: &MessageBroker,
user_ids: &[i32],
) -> Result<(), DbError> {
let mut conn = pool
.get()
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
conn.prepare("notify_election_created", QUERY_NOTIFY_ELECTION_CREATED)?;
for uid in user_ids {
conn.execute("notify_election_created", &[uid])?;
}
conn.prepare(
"get_users_in_regions_with_elections",
QUERY_GET_USERS_IN_REGIONS_WITH_ELECTIONS,
)?;
let rows = conn.execute("get_users_in_regions_with_elections", &[])?;
for row in rows {
if let Some(user_id) = row.get("user_id").and_then(|v| v.parse::<i32>().ok()) {
let msg =
format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id);
broker.publish(msg);
}
}
Ok(())
}
fn notify_office_filled(
pool: &ConnectionPool,
broker: &MessageBroker,
new_offices: &[Office],
) -> Result<(), DbError> {
let mut conn = pool
.get()
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
conn.prepare("notify_office_filled", QUERY_NOTIFY_OFFICE_FILLED)?;
for office in new_offices {
conn.execute("notify_office_filled", &[&office.character_id])?;
}
conn.prepare(
"get_users_with_filled_offices",
QUERY_GET_USERS_WITH_FILLED_OFFICES,
)?;
let rows = conn.execute("get_users_with_filled_offices", &[])?;
for row in rows {
if let Some(user_id) = row.get("user_id").and_then(|v| v.parse::<i32>().ok()) {
let msg =
format!(r#"{{"event":"falukantUpdateStatus","user_id":{}}}"#, user_id);
broker.publish(msg);
}
}
Ok(())
}
fn process_elections(pool: &ConnectionPool) -> Result<Vec<Office>, DbError> {
let mut conn = pool
.get()
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
conn.prepare("process_elections", QUERY_PROCESS_ELECTIONS)?;
let rows = conn.execute("process_elections", &[])?;
Ok(rows
.into_iter()
.filter_map(map_row_to_office)
.collect())
}
}
impl Worker for PoliticsWorker {
fn start_worker_thread(&mut self) {
let pool = self.base.pool.clone();
let broker = self.base.broker.clone();
self.base
.start_worker_with_loop(move |state: Arc<WorkerState>| {
PoliticsWorker::run_loop(pool.clone(), broker.clone(), state);
});
}
fn stop_worker_thread(&mut self) {
self.base.stop_worker();
}
fn enable_watchdog(&mut self) {
self.base.start_watchdog();
}
}
fn parse_i32(row: &Row, key: &str, default: i32) -> i32 {
row.get(key)
.and_then(|v| v.parse::<i32>().ok())
.unwrap_or(default)
}
fn map_row_to_office(row: Row) -> Option<Office> {
Some(Office {
office_id: row.get("office_id")?.parse().ok()?,
office_type_id: row.get("office_type_id")?.parse().ok()?,
character_id: row.get("character_id")?.parse().ok()?,
region_id: row.get("region_id")?.parse().ok()?,
})
}