Entferne automatische Annahme alter Bewerbungen und implementiere Entscheidung durch NPCs für offene Stellen ohne Vorgesetzten
All checks were successful
Deploy yourpart (blue-green) / deploy (push) Successful in 5m13s

This commit is contained in:
Torsten Schulz (local)
2026-08-26 10:01:21 +02:00
parent 521a1104ec
commit fb5c1eb8be
3 changed files with 207 additions and 145 deletions

View File

@@ -1,6 +1,6 @@
# Falukant: Kirchenämter (YpDaemon / PoliticsWorker)
Umsetzung des Zielmodells: Laufbahn `highest_church_hierarchy_ever`, NPC-Bewerbungen, NPC-Vorgesetzte mit Score, Spieler-Vorgesetzte ohne Daemon-Entscheidung, Interimsbesetzung, Events `falukantUpdateChurch`.
Umsetzung des Zielmodells: Laufbahn `highest_church_hierarchy_ever`, NPC-Bewerbungen, NPC-Vorgesetzte mit Score, Spieler-Vorgesetzte ohne Daemon-Entscheidung und Events `falukantUpdateChurch`.
## Migration
@@ -13,16 +13,16 @@ Umsetzung des Zielmodells: Laufbahn `highest_church_hierarchy_ever`, NPC-Bewerbu
| Was | Wann |
|-----|------|
| Kirchen-Gesamtprozess | Täglich im **gleichen** Lauf wie `perform_daily_politics_task` (nicht mehr nur 13:00) |
| Auto-Approve alter Bewerbungen | Stündlich, **nur** wenn `supervisor.user_id IS NULL` (NPC-Vorgesetzter), 36 h+ |
| NPC-Entscheidung | Nach mindestens **2 Tagen** offener Bewerbung, täglich |
## Logik (Kurz)
1. **Freie Sitze** je `church_office_type` / Region (`QUERY_FIND_AVAILABLE_CHURCH_OFFICES`, inkl. `hierarchy_level`).
2. **Vorgesetzter** = nächsthöhere besetzte Hierarchie in der Region (`QUERY_FIND_CHURCH_SUPERVISOR`).
3. **Spieler-Vorgesetzter** (`character.user_id IS NOT NULL`): `falukantUpdateChurch` mit `reason: applications`**keine** automatische Annahme/Ablehnung durch den Daemon.
4. **NPC-Vorgesetzter**: Bewerber mit `churchCandidateScore` (Reputation, höchste Hierarchie, aktuelle Ämter, Titel, Alter, Zufallsanteil abhängig von `supervisor.reputation`). Pro `(office_type_id, region)` werden nur so viele Zusagen erteilt, wie Plätze frei sind.
5. **NPC-Bewerbungen**: Nur Charaktere mit `user_id IS NULL`; nur wenn `pending < freie Sitze` (nachzüglernde Jobs).
6. **Interimsbesetzung**: Kein Vorgesetzter, `hierarchy_level <= 6` (bis einschließlich „Bishop“-Ebene im Typ-Stammbaum — anpassbar über Konstante `INTERIM_MAX_CHURCH_HIERARCHY` in `politics.rs`): bester NPC-Kandidat + direktes `INSERT` in `church_office`, Update `highest_church_hierarchy_ever`, Entfernen niedrigerer kirchlicher Ämter.
4. **NPC-Vorgesetzter**: Nach der zweitägigen Bewerbungsfrist bewertet er Bewerber mit `churchCandidateScore` und entscheidet über die freien Sitze.
5. **Kein Vorgesetzter**: Der Kirchen-NPC führt denselben Auswahlvorgang nach zwei Tagen durch. Das deckt insbesondere Einstiegsämter ab und verhindert dauerhaft offene Bewerbungen.
6. **NPC-Laufbahn**: Nur Charaktere mit `user_id IS NULL`; Einstieg ohne Kirchenamt, jede Beförderung ausschließlich aus einem aktuell gehaltenen Amt genau eine Hierarchiestufe darunter.
## Bewerbungsvoraussetzungen (Daemon-SQL)
@@ -35,7 +35,7 @@ Titelbedingungen unverändert (`min_title_level`).
## Genehmigung / Amtsverlust
- `QUERY_APPROVE_CHURCH_APPLICATION` und `QUERY_AUTO_APPROVE_CHURCH_APPLICATION`: nach Eintrag `church_office` → Update `highest_church_hierarchy_ever`, dann `DELETE` niedrigerer konkurrierender `church_office` desselben Charakters (`remove_lower_ranked`).
- `QUERY_APPROVE_CHURCH_APPLICATION`: nach Eintrag `church_office` → Update `highest_church_hierarchy_ever`, dann `DELETE` niedrigerer konkurrierender `church_office` desselben Charakters (`remove_lower_ranked`).
## WebSocket
@@ -44,7 +44,7 @@ Titelbedingungen unverändert (`min_title_level`).
| `falukantUpdateChurch` | `{"event":"falukantUpdateChurch","user_id":N,"reason":"…"}` |
| `falukantUpdateStatus` | wie üblich, direkt danach |
**reason:** `applications` (Spieler-Vorgesetzter), `npc_decision` (NPC hat zugesagt), `appointment` (Auto-Approve 36 h, nur NPC-Supervisor), `vacancy_fill` (Interimsbesetzung, nur wenn Bewerber ein Spielercharakter ist).
**reason:** `applications` (Spieler-Vorgesetzter), `npc_decision` (Kirchen-NPC hat entschieden).
Details: [`FALUKANT_UI_WEBSOCKET.md`](./FALUKANT_UI_WEBSOCKET.md).
@@ -55,5 +55,5 @@ Details: [`FALUKANT_UI_WEBSOCKET.md`](./FALUKANT_UI_WEBSOCKET.md).
## Code
- `src/worker/politics.rs``perform_church_office_task`, `process_church_supervisor_queue`, `npc_resolve_church_applications_for_supervisor`, `try_interim_church_appointment`, Hilfsfunktionen.
- `src/worker/politics.rs``perform_church_office_task`, `process_church_supervisor_queue`, NPC-Entscheidungen mit und ohne Vorgesetzten, Hilfsfunktionen.
- `src/worker/sql.rs` — Abschnitt „Church Office Queries“.

View File

@@ -39,10 +39,7 @@ use crate::worker::sql::{
QUERY_GET_CHURCH_OFFICE_OCCUPIED_COUNT,
QUERY_IS_CHARACTER_NPC,
QUERY_GET_PENDING_CHURCH_APPLICATIONS_FOR_SCORING,
QUERY_INTERIM_APPOINT_CHURCH_OFFICE,
QUERY_UPDATE_CHARACTER_HIGHEST_CHURCH_FROM_OFFICE_TYPE,
QUERY_FIND_INTERIM_CHURCH_NPC_CANDIDATE,
QUERY_REMOVE_LOWER_CHURCH_OFFICES_FOR_CHARACTER,
QUERY_GET_PENDING_CHURCH_APPLICATIONS_WITHOUT_SUPERVISOR,
};
pub struct PoliticsWorker {
@@ -81,7 +78,6 @@ struct Office {
#[derive(Debug, Clone)]
struct AvailableChurchOffice {
office_type_id: i32,
hierarchy_level: i32,
seats_per_region: i32,
region_id: i32,
occupied_seats: i32,
@@ -92,9 +88,6 @@ struct ChurchSupervisor {
supervisor_character_id: i32,
}
/// Bis einschließlich dieser `hierarchy_level` (church_office_type): Interimsbesetzung ohne Vorgesetzten.
const INTERIM_MAX_CHURCH_HIERARCHY: i32 = 6;
struct ChurchAppScoreRow {
application_id: i32,
office_type_id: i32,
@@ -116,7 +109,6 @@ impl PoliticsWorker {
fn run_loop(pool: ConnectionPool, broker: MessageBroker, state: Arc<WorkerState>) {
let mut last_execution: Option<Instant> = None;
let mut last_auto_approve_run: Option<Instant> = None;
while state.running_worker.load(Ordering::Relaxed) {
let now = Instant::now();
@@ -132,21 +124,6 @@ impl PoliticsWorker {
last_execution = Some(now);
}
// Automatische Annahme alter Applications (stündlich)
let should_run_auto_approve = match last_auto_approve_run {
None => true,
Some(prev) => {
now.saturating_duration_since(prev) >= Duration::from_secs(3600)
}
};
if should_run_auto_approve {
if let Err(err) = Self::auto_approve_old_church_applications(&pool, &broker) {
eprintln!("[PoliticsWorker] Fehler bei auto_approve_old_church_applications: {err}");
}
last_auto_approve_run = Some(now);
}
// Entspricht ungefähr der 5-Sekunden-Schleife im C++-Code
for _ in 0..5 {
if !state.running_worker.load(Ordering::Relaxed) {
@@ -770,18 +747,37 @@ impl PoliticsWorker {
pool,
office.office_type_id,
office.region_id,
supervisor.supervisor_character_id,
Some(supervisor.supervisor_character_id),
need,
)?;
}
}
} else if office.hierarchy_level <= INTERIM_MAX_CHURCH_HIERARCHY {
Self::try_interim_church_appointment(pool, broker, office)?;
} else {
eprintln!(
"[PoliticsWorker] Kein Supervisor, Interim deaktiviert (hierarchy_level={}): office_type_id={}, region_id={}",
office.hierarchy_level, office.office_type_id, office.region_id
);
// Einstiegsämter haben naturgemäß keinen Vorgesetzten. Offene
// Spielerbewerbungen dürfen deshalb nicht dauerhaft pending
// bleiben: Der Kirchen-NPC entscheidet sie für diese Stelle.
Self::npc_resolve_church_applications_without_supervisor(pool, broker, office)?;
let occupied = Self::get_church_occupied_count(
pool,
office.office_type_id,
office.region_id,
)?;
let pending = Self::count_pending_church_apps(
pool,
office.office_type_id,
office.region_id,
)?;
let missing_applications = (office.seats_per_region - occupied - pending).max(0);
if missing_applications > 0 {
Self::create_church_application_jobs(
pool,
office.office_type_id,
office.region_id,
None,
missing_applications,
)?;
}
}
}
@@ -854,7 +850,9 @@ impl PoliticsWorker {
age_days: i32,
wait_days: f64,
) -> f64 {
let age_years = age_days / 365;
// Ein Kalendertag ist ein Spieljahr; die Datenbankdifferenz ist somit
// bereits das Alter in Spieljahren.
let age_years = age_days;
let age_bonus = if (25..=70).contains(&age_years) {
12.0
} else {
@@ -950,100 +948,6 @@ impl PoliticsWorker {
Ok(true)
}
fn remove_lower_ranked_church_offices_for_character(
pool: &ConnectionPool,
character_id: i32,
) -> Result<(), DbError> {
let mut conn = pool
.get()
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
conn.prepare(
"rm_lower_church",
QUERY_REMOVE_LOWER_CHURCH_OFFICES_FOR_CHARACTER,
)
.map_err(|e| DbError::new(format!("[PoliticsWorker] prepare rm_lower_church: {e}")))?;
conn.execute("rm_lower_church", &[&character_id])
.map_err(|e| DbError::new(format!("[PoliticsWorker] exec rm_lower_church: {e}")))?;
Ok(())
}
fn try_interim_church_appointment(
pool: &ConnectionPool,
broker: &MessageBroker,
office: &AvailableChurchOffice,
) -> Result<(), DbError> {
let mut conn = pool
.get()
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
conn.prepare(
"find_interim_npc",
QUERY_FIND_INTERIM_CHURCH_NPC_CANDIDATE,
)
.map_err(|e| DbError::new(format!("[PoliticsWorker] prepare find_interim_npc: {e}")))?;
let rows = conn
.execute("find_interim_npc", &[&office.region_id, &office.office_type_id])
.map_err(|e| DbError::new(format!("[PoliticsWorker] exec find_interim_npc: {e}")))?;
let candidate_id = rows
.first()
.and_then(|r| r.get("character_id"))
.and_then(|v| v.parse::<i32>().ok())
.unwrap_or(-1);
if candidate_id < 0 {
return Ok(());
}
if !Self::character_eligible_for_church_office(pool, candidate_id, office.office_type_id)? {
return Ok(());
}
drop(conn);
let mut conn = pool
.get()
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
conn.prepare(
"interim_ins",
QUERY_INTERIM_APPOINT_CHURCH_OFFICE,
)
.map_err(|e| DbError::new(format!("[PoliticsWorker] prepare interim_ins: {e}")))?;
let ins = conn
.execute(
"interim_ins",
&[
&office.office_type_id,
&candidate_id,
&office.region_id,
&office.seats_per_region,
],
)
.map_err(|e| DbError::new(format!("[PoliticsWorker] exec interim_ins: {e}")))?;
if ins.is_empty() {
return Ok(());
}
conn.prepare(
"upd_hi_interim",
QUERY_UPDATE_CHARACTER_HIGHEST_CHURCH_FROM_OFFICE_TYPE,
)
.map_err(|e| DbError::new(format!("[PoliticsWorker] prepare upd_hi_interim: {e}")))?;
conn.execute("upd_hi_interim", &[&candidate_id, &office.office_type_id])
.map_err(|e| DbError::new(format!("[PoliticsWorker] exec upd_hi_interim: {e}")))?;
Self::remove_lower_ranked_church_offices_for_character(pool, candidate_id)?;
eprintln!(
"[PoliticsWorker] Interims-Kirchenamt: character_id={}, office_type_id={}, region_id={}",
candidate_id, office.office_type_id, office.region_id
);
if let Some(uid) = Self::get_user_id_for_character(pool, candidate_id)? {
Self::publish_falukant_church_update(broker, uid, "vacancy_fill");
}
Ok(())
}
fn find_available_church_offices(
pool: &ConnectionPool,
) -> Result<Vec<AvailableChurchOffice>, DbError> {
@@ -1071,7 +975,6 @@ impl PoliticsWorker {
let mut offices = Vec::new();
for row in rows {
let office_type_id = parse_i32(&row, "office_type_id", -1);
let hierarchy_level = parse_i32(&row, "hierarchy_level", 99);
let region_id = parse_i32(&row, "region_id", -1);
let seats_per_region = parse_i32(&row, "seats_per_region", 0);
let occupied_seats = parse_i32(&row, "occupied_seats", 0);
@@ -1079,7 +982,6 @@ impl PoliticsWorker {
if office_type_id >= 0 && region_id >= 0 {
offices.push(AvailableChurchOffice {
office_type_id,
hierarchy_level,
seats_per_region,
region_id,
occupied_seats,
@@ -1336,11 +1238,102 @@ impl PoliticsWorker {
Ok(())
}
/// Entscheidet Bewerbungen für Ämter, die noch keinen Vorgesetzten haben.
/// Das betrifft insbesondere `lay-preacher`: Ohne diesen Pfad bleiben die
/// vom Backend erzeugten Bewerbungen für immer auf `pending`.
fn npc_resolve_church_applications_without_supervisor(
pool: &ConnectionPool,
broker: &MessageBroker,
office: &AvailableChurchOffice,
) -> Result<(), DbError> {
let mut conn = pool
.get()
.map_err(|e| DbError::new(format!("DB-Verbindung fehlgeschlagen: {e}")))?;
conn.prepare(
"score_church_apps_no_supervisor",
QUERY_GET_PENDING_CHURCH_APPLICATIONS_WITHOUT_SUPERVISOR,
)?;
conn.prepare("reject_church_application", QUERY_REJECT_CHURCH_APPLICATION)?;
conn.prepare("approve_church_application", QUERY_APPROVE_CHURCH_APPLICATION)?;
let rows = conn.execute(
"score_church_apps_no_supervisor",
&[&office.office_type_id, &office.region_id],
)?;
if rows.is_empty() {
return Ok(());
}
let mut candidates = Vec::new();
for row in rows {
let application_id = parse_i32(&row, "application_id", -1);
let applicant_character_id = parse_i32(&row, "applicant_character_id", -1);
if application_id < 0 || applicant_character_id < 0 {
continue;
}
if !Self::character_eligible_for_church_office(
pool,
applicant_character_id,
office.office_type_id,
)? {
conn.execute("reject_church_application", &[&application_id])?;
if let Some(uid) = Self::get_user_id_for_character(pool, applicant_character_id)? {
Self::publish_falukant_church_update(broker, uid, "npc_decision");
}
continue;
}
let score = Self::church_candidate_score(
row.get("supervisor_reputation")
.and_then(|v| v.parse::<f64>().ok())
.unwrap_or(50.0),
row.get("applicant_reputation")
.and_then(|v| v.parse::<f64>().ok())
.unwrap_or(50.0),
parse_i32(&row, "applicant_highest_ever", 0),
parse_i32(&row, "applicant_current_max_hierarchy", 0),
parse_i32(&row, "applicant_title_level", 0),
parse_i32(&row, "applicant_age_days", 0),
0.0,
);
candidates.push((application_id, applicant_character_id, score));
}
candidates.sort_by(|a, b| {
b.2.partial_cmp(&a.2)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut occupied = Self::get_church_occupied_count(
pool,
office.office_type_id,
office.region_id,
)?;
for (application_id, applicant_character_id, _) in candidates {
let approved = occupied < office.seats_per_region
&& !conn
.execute("approve_church_application", &[&application_id])?
.is_empty();
if approved {
occupied += 1;
} else {
conn.execute("reject_church_application", &[&application_id])?;
}
if let Some(uid) = Self::get_user_id_for_character(pool, applicant_character_id)? {
Self::publish_falukant_church_update(broker, uid, "npc_decision");
}
}
Ok(())
}
fn create_church_application_jobs(
pool: &ConnectionPool,
office_type_id: i32,
region_id: i32,
supervisor_id: i32,
supervisor_id: Option<i32>,
count: i32,
) -> Result<(), DbError> {
let mut conn = pool
@@ -1359,7 +1352,10 @@ impl PoliticsWorker {
})?;
let rows = conn
.execute("get_characters_for_church_office", &[&region_id, &count])
.execute(
"get_characters_for_church_office",
&[&region_id, &office_type_id, &count],
)
.map_err(|e| {
DbError::new(format!(
"[PoliticsWorker] exec get_characters_for_church_office: {e}"
@@ -1411,7 +1407,9 @@ impl PoliticsWorker {
Ok(())
}
/// Automatische Annahme von Church Applications, die älter als 36 Stunden sind
/// Der frühere stündliche Auto-Approve ist bewusst nicht mehr im Scheduler
/// registriert; Entscheidungen laufen nach der zweitägigen Bewerbungsfrist.
#[allow(dead_code)]
fn auto_approve_old_church_applications(
pool: &ConnectionPool,
broker: &MessageBroker,

View File

@@ -3215,7 +3215,8 @@ pub const QUERY_CREATE_CHURCH_APPLICATION_JOB: &str = r#"
RETURNING id;
"#;
/// Nur NPCs: Spielerbewerbungen laufen über die UI.
/// Nur NPCs: Für den Einstieg ohne Amt, für eine Beförderung ausschließlich aus
/// dem unmittelbar niedrigeren derzeitigen Kirchenamt.
pub const QUERY_GET_CHARACTERS_FOR_CHURCH_OFFICE: &str = r#"
SELECT DISTINCT
c.id AS character_id,
@@ -3224,17 +3225,29 @@ pub const QUERY_GET_CHARACTERS_FOR_CHURCH_OFFICE: &str = r#"
c.title_of_nobility,
t.level AS title_level
FROM falukant_data.character c
JOIN falukant_type.church_office_type target ON target.id = $2::int
LEFT JOIN falukant_type.title t ON c.title_of_nobility = t.id
WHERE c.region_id = $1
AND c.health > 0
AND c.user_id IS NULL
AND NOT EXISTS(
SELECT 1
FROM falukant_data.church_office co
WHERE co.character_id = c.id
AND (
(target.hierarchy_level <= 1 AND NOT EXISTS(
SELECT 1
FROM falukant_data.church_office co
WHERE co.character_id = c.id
))
OR
(target.hierarchy_level > 1 AND EXISTS(
SELECT 1
FROM falukant_data.church_office co
JOIN falukant_type.church_office_type current
ON current.id = co.office_type_id
WHERE co.character_id = c.id
AND current.hierarchy_level = target.hierarchy_level - 1
))
)
ORDER BY RANDOM()
LIMIT $2;
LIMIT $3;
"#;
pub const QUERY_COUNT_PENDING_CHURCH_APPS_BY_OFFICE_REGION: &str = r#"
@@ -3285,9 +3298,45 @@ pub const QUERY_GET_PENDING_CHURCH_APPLICATIONS_FOR_SCORING: &str = r#"
LEFT JOIN falukant_type.title t ON t.id = ac.title_of_nobility
WHERE ca.status = 'pending'
AND ca.supervisor_id = $1::int
AND ca.created_at <= NOW() - INTERVAL '2 days'
ORDER BY ca.created_at ASC;
"#;
/// Einstiegsämter haben noch keinen Amtsinhaber als Vorgesetzten. Diese offenen
/// Bewerbungen werden deshalb vom Kirchen-NPC für die konkrete Stelle entschieden.
pub const QUERY_GET_PENDING_CHURCH_APPLICATIONS_WITHOUT_SUPERVISOR: &str = r#"
SELECT
ca.id AS application_id,
ca.office_type_id,
ca.character_id AS applicant_character_id,
ca.region_id,
ca.created_at,
cot.hierarchy_level AS office_hierarchy_level,
cot.seats_per_region,
50.0::float8 AS supervisor_reputation,
COALESCE(ac.reputation, 50)::float8 AS applicant_reputation,
COALESCE(ac.highest_church_hierarchy_ever, 0)::int AS applicant_highest_ever,
COALESCE(t.level, 0)::int AS applicant_title_level,
COALESCE((
SELECT MAX(cot2.hierarchy_level)
FROM falukant_data.church_office co2
JOIN falukant_type.church_office_type cot2 ON cot2.id = co2.office_type_id
WHERE co2.character_id = ac.id
), 0)::int AS applicant_current_max_hierarchy,
(CURRENT_DATE - ac.birthdate::date)::int AS applicant_age_days
FROM falukant_data.church_application ca
JOIN falukant_data.character ac ON ac.id = ca.character_id
JOIN falukant_type.church_office_type cot ON cot.id = ca.office_type_id
LEFT JOIN falukant_type.title t ON t.id = ac.title_of_nobility
WHERE ca.status = 'pending'
AND ca.supervisor_id IS NULL
AND ca.office_type_id = $1::int
AND ca.region_id = $2::int
AND ca.created_at <= NOW() - INTERVAL '2 days'
ORDER BY ca.created_at ASC;
"#;
#[allow(dead_code)]
pub const QUERY_INTERIM_APPOINT_CHURCH_OFFICE: &str = r#"
INSERT INTO falukant_data.church_office
(office_type_id, character_id, region_id, supervisor_id, created_at, updated_at)
@@ -3307,6 +3356,7 @@ pub const QUERY_INTERIM_APPOINT_CHURCH_OFFICE: &str = r#"
RETURNING id, office_type_id, character_id, region_id;
"#;
#[allow(dead_code)]
pub const QUERY_UPDATE_CHARACTER_HIGHEST_CHURCH_FROM_OFFICE_TYPE: &str = r#"
UPDATE falukant_data.character c
SET highest_church_hierarchy_ever = GREATEST(
@@ -3317,6 +3367,7 @@ pub const QUERY_UPDATE_CHARACTER_HIGHEST_CHURCH_FROM_OFFICE_TYPE: &str = r#"
RETURNING c.id;
"#;
#[allow(dead_code)]
pub const QUERY_FIND_INTERIM_CHURCH_NPC_CANDIDATE: &str = r#"
SELECT c.id AS character_id
FROM falukant_data.character c
@@ -3337,6 +3388,7 @@ pub const QUERY_FIND_INTERIM_CHURCH_NPC_CANDIDATE: &str = r#"
LIMIT 1;
"#;
#[allow(dead_code)]
pub const QUERY_REMOVE_LOWER_CHURCH_OFFICES_FOR_CHARACTER: &str = r#"
DELETE FROM falukant_data.church_office co
WHERE co.character_id = $1::int
@@ -4100,4 +4152,16 @@ mod tests {
assert!(query.contains(">= 16"), "{query}");
}
}
#[test]
fn npc_church_promotions_wait_two_days_and_require_the_direct_lower_rank() {
assert!(QUERY_GET_CHARACTERS_FOR_CHURCH_OFFICE
.contains("current.hierarchy_level = target.hierarchy_level - 1"));
for query in [
QUERY_GET_PENDING_CHURCH_APPLICATIONS_FOR_SCORING,
QUERY_GET_PENDING_CHURCH_APPLICATIONS_WITHOUT_SUPERVISOR,
] {
assert!(query.contains("created_at <= NOW() - INTERVAL '2 days'"));
}
}
}