feat(db): add postgres setup and multi-tenant config
This commit is contained in:
7
.env.example
Normal file
7
.env.example
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
POSTGRES_DB=smartseat_db
|
||||||
|
POSTGRES_USER=postgres_admin
|
||||||
|
POSTGRES_PASSWORD=change_me_super_secret_admin_password
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
|
||||||
|
POSTGRES_APP_PASSWORD=change_me_app_user_password
|
||||||
|
POSTGRES_MIGRATION_PASSWORD=change_me_migration_user_password
|
||||||
13
Makefile
Normal file
13
Makefile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
.PHONY: up down logs status
|
||||||
|
|
||||||
|
up:
|
||||||
|
bash start-db.sh
|
||||||
|
|
||||||
|
down:
|
||||||
|
bash stop-db.sh
|
||||||
|
|
||||||
|
logs:
|
||||||
|
docker compose logs -f postgres
|
||||||
|
|
||||||
|
status:
|
||||||
|
docker ps --filter name=caspos-postgres
|
||||||
57
docker-compose.yml
Normal file
57
docker-compose.yml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# 1. PostgreSQL 16 Datenbank (Hybrid Multi-Tenant Storage)
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: caspos-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-caspos_reservations}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-caspos_admin}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secretpassword}
|
||||||
|
ports:
|
||||||
|
- "${POSTGRES_PORT:-5432}:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./docker/postgres/init:/docker-entrypoint-initdb.d
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-caspos_admin} -d ${POSTGRES_DB:-caspos_reservations}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# 2. Spring Boot 3 Java Backend (API)
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: caspos-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-caspos_reservations}
|
||||||
|
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-caspos_admin}
|
||||||
|
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-secretpassword}
|
||||||
|
SPRING_PROFILES_ACTIVE: docker
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
|
||||||
|
# 3. TypeScript / React Frontend (Tischplan & UI)
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: caspos-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
environment:
|
||||||
|
NEXT_PUBLIC_API_URL: http://localhost:8080/api/v1
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
29
docker/postgres/init/01-init-security.sql
Normal file
29
docker/postgres/init/01-init-security.sql
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
-- Extensions aktivieren
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
||||||
|
-- Entziehe allgemeine Rechte auf PUBLIC Schema
|
||||||
|
REVOKE ALL ON SCHEMA public FROM PUBLIC;
|
||||||
|
|
||||||
|
-- Rollen anlegen
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'app_user') THEN
|
||||||
|
CREATE ROLE app_user WITH LOGIN PASSWORD 'POSTGRES_APP_PASSWORD_PLACEHOLDER';
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'migration_user') THEN
|
||||||
|
CREATE ROLE migration_user WITH LOGIN PASSWORD 'POSTGRES_MIGRATION_PASSWORD_PLACEHOLDER';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Schema-Rechte
|
||||||
|
GRANT USAGE, CREATE ON SCHEMA public TO migration_user;
|
||||||
|
GRANT USAGE ON SCHEMA public TO app_user;
|
||||||
|
|
||||||
|
-- Standard-Berechtigungen für zukünftige Tabellen/Sequenzen
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO app_user;
|
||||||
|
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO migration_user;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO migration_user;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.caspos.config.tenant;
|
||||||
|
|
||||||
|
import org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class SchemaBasedMultiTenantConnectionProvider implements MultiTenantConnectionProvider<String> {
|
||||||
|
|
||||||
|
private final DataSource dataSource;
|
||||||
|
|
||||||
|
public SchemaBasedMultiTenantConnectionProvider(DataSource dataSource) {
|
||||||
|
this.dataSource = dataSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Connection getAnyConnection() throws SQLException {
|
||||||
|
return dataSource.getConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void releaseAnyConnection(Connection connection) throws SQLException {
|
||||||
|
connection.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Connection getConnection(String tenantIdentifier) throws SQLException {
|
||||||
|
final Connection connection = getAnyConnection();
|
||||||
|
connection.setSchema(tenantIdentifier);
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void releaseConnection(String tenantIdentifier, Connection connection) throws SQLException {
|
||||||
|
try {
|
||||||
|
connection.setSchema("public");
|
||||||
|
} finally {
|
||||||
|
releaseAnyConnection(connection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supportsAggressiveRelease() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isUnwrappableAs(Class<?> unwrapType) {
|
||||||
|
return MultiTenantConnectionProvider.class.isAssignableFrom(unwrapType);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <T> T unwrap(Class<T> unwrapType) {
|
||||||
|
if (isUnwrappableAs(unwrapType)) {
|
||||||
|
return unwrapType.cast(this);
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("Can't unwrap to " + unwrapType.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/main/java/com/caspos/config/tenant/TenantContext.java
Normal file
25
src/main/java/com/caspos/config/tenant/TenantContext.java
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package com.caspos.config.tenant;
|
||||||
|
|
||||||
|
public final class TenantContext {
|
||||||
|
|
||||||
|
private static final String DEFAULT_TENANT = "public";
|
||||||
|
private static final ThreadLocal<String> CURRENT_TENANT = ThreadLocal.withInitial(() -> DEFAULT_TENANT);
|
||||||
|
|
||||||
|
private TenantContext() {}
|
||||||
|
|
||||||
|
public static String getCurrentTenant() {
|
||||||
|
return CURRENT_TENANT.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void setCurrentTenant(String tenantSchema) {
|
||||||
|
if (tenantSchema == null || tenantSchema.isBlank()) {
|
||||||
|
CURRENT_TENANT.set(DEFAULT_TENANT);
|
||||||
|
} else {
|
||||||
|
CURRENT_TENANT.set(tenantSchema);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void clear() {
|
||||||
|
CURRENT_TENANT.set(DEFAULT_TENANT);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.caspos.config.tenant;
|
||||||
|
|
||||||
|
import org.hibernate.context.spi.CurrentTenantIdentifierResolver;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class TenantIdentifierResolver implements CurrentTenantIdentifierResolver<String> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String resolveCurrentTenantIdentifier() {
|
||||||
|
String tenant = TenantContext.getCurrentTenant();
|
||||||
|
return (tenant != null && !tenant.isBlank()) ? tenant : "public";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean validateExistingCurrentSessions() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/main/resources/application.yml
Normal file
26
src/main/resources/application.yml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:smartseat_db}
|
||||||
|
username: ${POSTGRES_USER:postgres_admin}
|
||||||
|
password: ${POSTGRES_PASSWORD:change_me_super_secret_admin_password}
|
||||||
|
driver-class-name: org.postgresql.Driver
|
||||||
|
hikari:
|
||||||
|
maximum-pool-size: 20
|
||||||
|
minimum-idle: 5
|
||||||
|
idle-timeout: 300000
|
||||||
|
connection-timeout: 20000
|
||||||
|
max-lifetime: 1200000
|
||||||
|
leak-detection-threshold: 5000
|
||||||
|
pool-name: CasposHikariCP
|
||||||
|
|
||||||
|
jpa:
|
||||||
|
database-platform: org.hibernate.dialect.PostgreSQLDialect
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: validate
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
multiTenancy: SCHEMA
|
||||||
|
multi_tenant_connection_provider: com.caspos.config.tenant.SchemaBasedMultiTenantConnectionProvider
|
||||||
|
tenant_identifier_resolver: com.caspos.config.tenant.TenantIdentifierResolver
|
||||||
|
format_sql: true
|
||||||
|
show_sql: false
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS tenants (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(150) NOT NULL,
|
||||||
|
slug VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
db_schema VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
api_key VARCHAR(64) UNIQUE NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
-- Dining Areas
|
||||||
|
CREATE TABLE IF NOT EXISTS dining_areas (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
display_order INT DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Restaurant Tables
|
||||||
|
CREATE TABLE IF NOT EXISTS restaurant_tables (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
dining_area_id UUID NOT NULL REFERENCES dining_areas(id) ON DELETE CASCADE,
|
||||||
|
table_number VARCHAR(20) NOT NULL,
|
||||||
|
min_capacity INT NOT NULL DEFAULT 1,
|
||||||
|
max_capacity INT NOT NULL,
|
||||||
|
is_combinable BOOLEAN DEFAULT true,
|
||||||
|
pos_x DOUBLE PRECISION,
|
||||||
|
pos_y DOUBLE PRECISION,
|
||||||
|
width DOUBLE PRECISION,
|
||||||
|
height DOUBLE PRECISION,
|
||||||
|
shape VARCHAR(20) CHECK (shape IN ('RECTANGLE', 'ROUND'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_restaurant_tables_number_area ON restaurant_tables(table_number, dining_area_id);
|
||||||
|
|
||||||
|
-- Guests
|
||||||
|
CREATE TABLE IF NOT EXISTS guests (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
first_name VARCHAR(100),
|
||||||
|
last_name VARCHAR(100),
|
||||||
|
phone VARCHAR(50),
|
||||||
|
email VARCHAR(255),
|
||||||
|
notes TEXT,
|
||||||
|
no_show_count INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Reservations
|
||||||
|
CREATE TABLE IF NOT EXISTS reservations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
guest_id UUID REFERENCES guests(id),
|
||||||
|
start_time TIMESTAMPTZ NOT NULL,
|
||||||
|
end_time TIMESTAMPTZ NOT NULL,
|
||||||
|
guest_count INT NOT NULL,
|
||||||
|
status VARCHAR(30) NOT NULL CHECK (status IN ('PENDING', 'CONFIRMED', 'ARRIVED', 'FINISHED', 'NO_SHOW', 'CANCELLED')),
|
||||||
|
special_requests TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reservations_availability ON reservations(start_time, end_time, status);
|
||||||
|
|
||||||
|
-- Reservation Table Assignments
|
||||||
|
CREATE TABLE IF NOT EXISTS reservation_table_assignments (
|
||||||
|
reservation_id UUID NOT NULL REFERENCES reservations(id) ON DELETE CASCADE,
|
||||||
|
table_id UUID NOT NULL REFERENCES restaurant_tables(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (reservation_id, table_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Staff Users
|
||||||
|
CREATE TABLE IF NOT EXISTS staff_users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
display_name VARCHAR(100) NOT NULL,
|
||||||
|
role VARCHAR(30) NOT NULL,
|
||||||
|
pin_hash VARCHAR(255) NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT true
|
||||||
|
);
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.caspos.config.tenant;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
|
import org.testcontainers.junit.jupiter.Container;
|
||||||
|
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.Statement;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
@Testcontainers
|
||||||
|
class MultiTenantIsolationTest {
|
||||||
|
|
||||||
|
@Container
|
||||||
|
private static final PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
|
||||||
|
.withDatabaseName("test_db")
|
||||||
|
.withUsername("test_user")
|
||||||
|
.withPassword("test_pass");
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() throws Exception {
|
||||||
|
try (Connection conn = DriverManager.getConnection(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
|
||||||
|
Statement stmt = conn.createStatement()) {
|
||||||
|
|
||||||
|
stmt.execute("CREATE SCHEMA IF NOT EXISTS tenant_a");
|
||||||
|
stmt.execute("CREATE SCHEMA IF NOT EXISTS tenant_b");
|
||||||
|
|
||||||
|
stmt.execute("CREATE TABLE tenant_a.dining_areas (id UUID PRIMARY KEY, name VARCHAR(100))");
|
||||||
|
stmt.execute("CREATE TABLE tenant_b.dining_areas (id UUID PRIMARY KEY, name VARCHAR(100))");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
TenantContext.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testTenantDataIsolation() throws Exception {
|
||||||
|
try (Connection conn = DriverManager.getConnection(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword())) {
|
||||||
|
|
||||||
|
// Insert in Tenant A
|
||||||
|
TenantContext.setCurrentTenant("tenant_a");
|
||||||
|
conn.setSchema(TenantContext.getCurrentTenant());
|
||||||
|
try (Statement stmt = conn.createStatement()) {
|
||||||
|
stmt.execute("INSERT INTO dining_areas (id, name) VALUES (gen_random_uuid(), 'Main Hall A')");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert in Tenant B
|
||||||
|
TenantContext.setCurrentTenant("tenant_b");
|
||||||
|
conn.setSchema(TenantContext.getCurrentTenant());
|
||||||
|
try (Statement stmt = conn.createStatement()) {
|
||||||
|
stmt.execute("INSERT INTO dining_areas (id, name) VALUES (gen_random_uuid(), 'Terrace B')");
|
||||||
|
stmt.execute("INSERT INTO dining_areas (id, name) VALUES (gen_random_uuid(), 'Vip B')");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Tenant A Count
|
||||||
|
TenantContext.setCurrentTenant("tenant_a");
|
||||||
|
conn.setSchema(TenantContext.getCurrentTenant());
|
||||||
|
try (Statement stmt = conn.createStatement();
|
||||||
|
ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM dining_areas")) {
|
||||||
|
rs.next();
|
||||||
|
assertEquals(1, rs.getInt(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Tenant B Count
|
||||||
|
TenantContext.setCurrentTenant("tenant_b");
|
||||||
|
conn.setSchema(TenantContext.getCurrentTenant());
|
||||||
|
try (Statement stmt = conn.createStatement();
|
||||||
|
ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM dining_areas")) {
|
||||||
|
rs.next();
|
||||||
|
assertEquals(2, rs.getInt(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
start-db.sh
Normal file
18
start-db.sh
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
echo "Keine .env vorhanden. Kopiere .env.example..."
|
||||||
|
cp .env.example .env
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starte PostgreSQL Container..."
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
echo "Warte auf Datenbank Healthcheck..."
|
||||||
|
until [ "`docker inspect --format='{{json .State.Health.Status}}' caspos-postgres`" == "\"healthy\"" ]; do
|
||||||
|
echo "PostgreSQL startet noch..."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "PostgreSQL ist einsatzbereit!"
|
||||||
6
stop-db.sh
Normal file
6
stop-db.sh
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Stoppe PostgreSQL Container..."
|
||||||
|
docker compose down
|
||||||
|
echo "PostgreSQL gestoppt."
|
||||||
Reference in New Issue
Block a user