feat(db): add postgres setup and multi-tenant config

This commit is contained in:
DanielS
2026-08-18 23:56:51 +02:00
commit 407d40b2e8
13 changed files with 420 additions and 0 deletions

View File

@@ -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());
}
}

View 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);
}
}

View File

@@ -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;
}
}

View 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

View File

@@ -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()
);

View File

@@ -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
);

View File

@@ -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));
}
}
}
}