diff --git a/src/main/java/net/siegeln/cameleer/saas/vendor/InfrastructureService.java b/src/main/java/net/siegeln/cameleer/saas/vendor/InfrastructureService.java index 0c8e453..8641c06 100644 --- a/src/main/java/net/siegeln/cameleer/saas/vendor/InfrastructureService.java +++ b/src/main/java/net/siegeln/cameleer/saas/vendor/InfrastructureService.java @@ -224,7 +224,11 @@ public class InfrastructureService { .put(table, cnt); } } catch (Exception e) { - log.error("Failed to query ClickHouse table '{}' for tenant stats: {}", table, e.getMessage(), e); + if (e.getMessage() != null && e.getMessage().contains("UNKNOWN_TABLE")) { + log.debug("ClickHouse table '{}' does not exist yet — skipping", table); + } else { + log.error("Failed to query ClickHouse table '{}' for tenant stats: {}", table, e.getMessage(), e); + } } } } catch (Exception e) { @@ -256,8 +260,12 @@ public class InfrastructureService { result.add(new ChTableStats(table, rs.getLong("cnt"))); } } catch (Exception e) { - log.error("Failed to query ClickHouse table '{}' for tenant '{}': {}", - table, tenantId, e.getMessage(), e); + if (e.getMessage() != null && e.getMessage().contains("UNKNOWN_TABLE")) { + log.debug("ClickHouse table '{}' does not exist yet — skipping", table); + } else { + log.error("Failed to query ClickHouse table '{}' for tenant '{}': {}", + table, tenantId, e.getMessage(), e); + } } } } catch (Exception e) { diff --git a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java index 5140343..05550d0 100644 --- a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java +++ b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java @@ -26,6 +26,8 @@ import org.springframework.context.annotation.Lazy; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.time.Duration; import java.util.List; @@ -116,9 +118,19 @@ public class VendorTenantService { AuditAction.TENANT_CREATE, "provision:" + tenant.getSlug(), null, null, "SUCCESS", null); - // 4. Provision server asynchronously (Docker containers, health check, config push) + // 4. Provision server asynchronously AFTER transaction commits + // (the async thread needs the tenant row to be visible) if (tenantProvisioner.isAvailable()) { - self.provisionAsync(tenant.getId(), tenant.getSlug(), tenant.getTier().name(), license.getToken(), actorId); + UUID tenantId = tenant.getId(); + String slug = tenant.getSlug(); + String tierName = tenant.getTier().name(); + String token = license.getToken(); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + self.provisionAsync(tenantId, slug, tierName, token, actorId); + } + }); } return tenant; diff --git a/src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantServiceTest.java index a2df71e..8ef2c1f 100644 --- a/src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantServiceTest.java +++ b/src/test/java/net/siegeln/cameleer/saas/vendor/VendorTenantServiceTest.java @@ -17,11 +17,14 @@ import net.siegeln.cameleer.saas.tenant.TenantService; import net.siegeln.cameleer.saas.tenant.TenantStatus; import net.siegeln.cameleer.saas.tenant.Tier; import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.time.Duration; import java.time.Instant; @@ -89,6 +92,28 @@ class VendorTenantServiceTest { tenantService, tenantRepository, licenseService, tenantProvisioner, serverApiClient, logtoClient, logtoConfig, auditService, provisioningProps, dataCleanupService, tenantDatabaseService, vendorTenantService); + + // Enable transaction synchronization so afterCommit callbacks can be registered + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.initSynchronization(); + } + } + + @AfterEach + void tearDown() { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + /** Simulate transaction commit — runs all registered afterCommit callbacks. */ + private void flushAfterCommit() { + var syncs = TransactionSynchronizationManager.getSynchronizations(); + for (TransactionSynchronization sync : syncs) { + sync.afterCommit(); + } + TransactionSynchronizationManager.clearSynchronization(); + TransactionSynchronizationManager.initSynchronization(); } // --- Helpers --- @@ -155,8 +180,9 @@ class VendorTenantServiceTest { when(tenantRepository.save(any(TenantEntity.class))).thenAnswer(inv -> inv.getArgument(0)); vendorTenantService.createAndProvision(request, actorId); + flushAfterCommit(); - // provisionAsync modifies the tenant entity in-place (runs synchronously in unit tests) + // provisionAsync runs via afterCommit callback (synchronously in unit tests) assertThat(tenant.getStatus()).isEqualTo(TenantStatus.ACTIVE); assertThat(tenant.getServerEndpoint()).isEqualTo("http://server:8080"); assertThat(tenant.getProvisionError()).isNull(); @@ -178,8 +204,9 @@ class VendorTenantServiceTest { when(tenantRepository.save(any(TenantEntity.class))).thenAnswer(inv -> inv.getArgument(0)); vendorTenantService.createAndProvision(request, actorId); + flushAfterCommit(); - // provisionAsync modifies the tenant entity in-place (runs synchronously in unit tests) + // provisionAsync runs via afterCommit callback (synchronously in unit tests) assertThat(tenant.getProvisionError()).isEqualTo("Docker failure"); assertThat(tenant.getStatus()).isEqualTo(TenantStatus.PROVISIONING); verify(tenantRepository, times(2)).save(tenant); diff --git a/ui/src/pages/OnboardingPage.tsx b/ui/src/pages/OnboardingPage.tsx index 1467ca1..cbd8dd6 100644 --- a/ui/src/pages/OnboardingPage.tsx +++ b/ui/src/pages/OnboardingPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { Card, Input, Button, FormField, Alert } from '@cameleer/design-system'; import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg'; import { api } from '../api/client'; @@ -14,16 +14,10 @@ interface TenantResponse { export function OnboardingPage() { const [name, setName] = useState(''); - const [slug, setSlug] = useState(''); - const [slugTouched, setSlugTouched] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - useEffect(() => { - if (!slugTouched) { - setSlug(toSlug(name)); - } - }, [name, slugTouched]); + const slug = toSlug(name); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -78,10 +72,8 @@ export function OnboardingPage() { { setSlugTouched(true); setSlug(e.target.value); }} placeholder="acme-corp" - disabled={loading} - required + readOnly />