diff --git a/backend/services/entries/entries-service/src/main/resources/db/tenant/V2__domain_hierarchy.sql b/backend/services/entries/entries-service/src/main/resources/db/tenant/V2__domain_hierarchy.sql new file mode 100644 index 00000000..5c4c65a9 --- /dev/null +++ b/backend/services/entries/entries-service/src/main/resources/db/tenant/V2__domain_hierarchy.sql @@ -0,0 +1,79 @@ +-- Domain hierarchy for Events/Tournaments within a tenant schema +-- This migration assumes each tenant schema represents one Veranstaltung (event). + +-- veranstaltungen (singleton per tenant schema) +CREATE TABLE IF NOT EXISTS veranstaltungen ( + id UUID PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL +); + +-- turniere (tournaments) – per Veranstaltung +CREATE TABLE IF NOT EXISTS turniere ( + id UUID PRIMARY KEY, + veranstaltung_id UUID NOT NULL REFERENCES veranstaltungen(id) ON DELETE CASCADE, + oeps_turniernummer VARCHAR(50) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_turniere_oeps_nr ON turniere(oeps_turniernummer); +CREATE INDEX IF NOT EXISTS idx_turniere_veranstaltung_id ON turniere(veranstaltung_id); + +-- bewerbe (competitions) – per Turnier +CREATE TABLE IF NOT EXISTS bewerbe ( + id UUID PRIMARY KEY, + turnier_id UUID NOT NULL REFERENCES turniere(id) ON DELETE CASCADE, + klasse VARCHAR(50) NOT NULL, + hoehe_cm INTEGER NULL, + bezeichnung TEXT NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_bewerbe_turnier_id ON bewerbe(turnier_id); +CREATE INDEX IF NOT EXISTS idx_bewerbe_klasse ON bewerbe(klasse); + +-- abteilungen (sections/heats) – per Bewerb +CREATE TABLE IF NOT EXISTS abteilungen ( + id UUID PRIMARY KEY, + bewerb_id UUID NOT NULL REFERENCES bewerbe(id) ON DELETE CASCADE, + nr INTEGER NOT NULL, + bezeichnung TEXT NOT NULL, + typ VARCHAR(32) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT chk_abteilungen_typ CHECK (typ IN ('SEPARATE_SIEGEREHRUNG', 'ORGANISATORISCH')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_abteilungen_bewerb_nr ON abteilungen(bewerb_id, nr); +CREATE INDEX IF NOT EXISTS idx_abteilungen_bewerb_id ON abteilungen(bewerb_id); +CREATE INDEX IF NOT EXISTS idx_abteilungen_typ ON abteilungen(typ); + +-- teilnehmer_konten – aggregated balances across all tournaments of one Veranstaltung +CREATE TABLE IF NOT EXISTS teilnehmer_konten ( + id UUID PRIMARY KEY, + veranstaltung_id UUID NOT NULL REFERENCES veranstaltungen(id) ON DELETE CASCADE, + teilnehmer_id UUID NOT NULL, + saldo_cents BIGINT NOT NULL DEFAULT 0, + currency CHAR(3) NOT NULL DEFAULT 'EUR', + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_tkonten_veranstaltung_teilnehmer ON teilnehmer_konten(veranstaltung_id, teilnehmer_id); +CREATE INDEX IF NOT EXISTS idx_tkonten_veranstaltung_id ON teilnehmer_konten(veranstaltung_id); +CREATE INDEX IF NOT EXISTS idx_tkonten_teilnehmer_id ON teilnehmer_konten(teilnehmer_id); + +-- turnier_kassa – per tournament cash balance +CREATE TABLE IF NOT EXISTS turnier_kassa ( + id UUID PRIMARY KEY, + turnier_id UUID NOT NULL REFERENCES turniere(id) ON DELETE CASCADE, + saldo_cents BIGINT NOT NULL DEFAULT 0, + currency CHAR(3) NOT NULL DEFAULT 'EUR', + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_turnier_kassa_turnier ON turnier_kassa(turnier_id); +CREATE INDEX IF NOT EXISTS idx_turnier_kassa_turnier_id ON turnier_kassa(turnier_id); diff --git a/backend/services/entries/entries-service/src/test/kotlin/at/mocode/entries/service/migration/DomainHierarchyMigrationTest.kt b/backend/services/entries/entries-service/src/test/kotlin/at/mocode/entries/service/migration/DomainHierarchyMigrationTest.kt new file mode 100644 index 00000000..6b167d9a --- /dev/null +++ b/backend/services/entries/entries-service/src/test/kotlin/at/mocode/entries/service/migration/DomainHierarchyMigrationTest.kt @@ -0,0 +1,75 @@ +package at.mocode.entries.service.migration + +import org.flywaydb.core.Flyway +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle +import org.testcontainers.containers.PostgreSQLContainer +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers +import java.sql.Connection + +@Testcontainers +@TestInstance(Lifecycle.PER_CLASS) +class DomainHierarchyMigrationTest { + + companion object { + @Container + @JvmStatic + val postgres = PostgreSQLContainer("postgres:16-alpine").apply { + withDatabaseName("meldestelle") + withUsername("test") + withPassword("test") + } + } + + @Test + fun `tenant migration creates domain hierarchy tables`() { + val schema = "event_test" + + // Run tenant migrations (V1 + V2) + Flyway.configure() + .dataSource(postgres.jdbcUrl, postgres.username, postgres.password) + .locations("classpath:db/tenant") + .schemas(schema) + .baselineOnMigrate(true) + .load() + .migrate() + + java.sql.DriverManager.getConnection(postgres.jdbcUrl, postgres.username, postgres.password).use { conn -> + setSearchPath(conn, schema) + val expected = setOf( + "veranstaltungen", + "turniere", + "bewerbe", + "abteilungen", + "teilnehmer_konten", + "turnier_kassa" + ) + val actual = loadTables(conn, schema, expected) + assertEquals(expected, actual, "Alle erwarteten Tabellen müssen existieren") + } + } + + private fun setSearchPath(conn: Connection, schema: String) { + conn.createStatement().use { st -> st.execute("SET search_path TO \"$schema\"") } + } + + private fun loadTables(conn: Connection, schema: String, expected: Set): Set { + val sql = """ + select table_name + from information_schema.tables + where table_schema = ? and table_type = 'BASE TABLE' + """.trimIndent() + return conn.prepareStatement(sql).use { ps -> + ps.setString(1, schema) + ps.executeQuery().use { rs -> + val names = mutableSetOf() + while (rs.next()) names += rs.getString(1) + names.retainAll(expected) + names + } + } + } +} diff --git a/docs/04_Agents/Roadmaps/Backend_Roadmap.md b/docs/04_Agents/Roadmaps/Backend_Roadmap.md index 1babb163..20f24530 100644 --- a/docs/04_Agents/Roadmaps/Backend_Roadmap.md +++ b/docs/04_Agents/Roadmaps/Backend_Roadmap.md @@ -34,14 +34,14 @@ - [ ] Aktueller Status: Integrationstest temporär via `@Disabled` deaktiviert, um den Build zu entblocken; Re-Enable nach Stabilisierung der Jackson/Spring-Web-Konverter-Autokonfiguration - [ ] **A-2** | Datenbankschema: Domänen-Hierarchie umsetzen - - [ ] Tabelle `veranstaltungen` anlegen (interne ID, Tenant-Grenze) - - [ ] Tabelle `turniere` anlegen (FK → `veranstaltung_id`, OEPS-Turniernummer als eigenes Feld) - - [ ] Tabelle `bewerbe` anlegen (FK → `turnier_id`, Klasse, Höhe, Bezeichnung) - - [ ] Tabelle `abteilungen` anlegen (FK → `bewerb_id`, `nr`, `bezeichnung`, + - [x] Tabelle `veranstaltungen` anlegen (interne ID, Tenant-Grenze) + - [x] Tabelle `turniere` anlegen (FK → `veranstaltung_id`, OEPS-Turniernummer als eigenes Feld) + - [x] Tabelle `bewerbe` anlegen (FK → `turnier_id`, Klasse, Höhe, Bezeichnung) + - [x] Tabelle `abteilungen` anlegen (FK → `bewerb_id`, `nr`, `bezeichnung`, `typ: SEPARATE_SIEGEREHRUNG | ORGANISATORISCH`) - - [ ] Tabelle `teilnehmer_konten` anlegen (FK → `veranstaltung_id`, aggregiert Salden über Turniere) - - [ ] Tabelle `turnier_kassa` anlegen (FK → `turnier_id`, separate Kassa pro Turnier) - - [ ] Migrations-Skript schreiben und testen + - [x] Tabelle `teilnehmer_konten` anlegen (FK → `veranstaltung_id`, aggregiert Salden über Turniere) + - [x] Tabelle `turnier_kassa` anlegen (FK → `turnier_id`, separate Kassa pro Turnier) + - [x] Migrations-Skript schreiben und testen (`db/tenant/V2__domain_hierarchy.sql`, Test: `DomainHierarchyMigrationTest`) - [ ] **A-3** | Validierungs-Grundlage: Turnierkategorie-Limits - [ ] `Turnier.validate()`: Bewerbs-Klassen gegen Limits der Turnierkategorie prüfen (z.B. kein S-Springen auf