feat(tenant): welcome email, admin email display, async delete fix
- Send branded welcome email to tenant admin after provisioning completes (includes username and dashboard URL) - Store admin_email on tenant entity (V004 migration) - Show admin email in vendor tenant list table and detail page - Fix ClickHouse cleanup: skip materialized views (can't ALTER DELETE on MVs) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
package io.cameleer.saas.notification;
|
||||
|
||||
import io.cameleer.saas.identity.LogtoManagementClient;
|
||||
import io.cameleer.saas.provisioning.ProvisioningProperties;
|
||||
import io.cameleer.saas.vendor.EmailConnectorService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.mail.javamail.JavaMailSenderImpl;
|
||||
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
|
||||
@Service
|
||||
public class TenantWelcomeNotificationService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TenantWelcomeNotificationService.class);
|
||||
|
||||
private final EmailConnectorService emailConnectorService;
|
||||
private final LogtoManagementClient logtoClient;
|
||||
private final ProvisioningProperties provisioningProps;
|
||||
|
||||
public TenantWelcomeNotificationService(EmailConnectorService emailConnectorService,
|
||||
LogtoManagementClient logtoClient,
|
||||
ProvisioningProperties provisioningProps) {
|
||||
this.emailConnectorService = emailConnectorService;
|
||||
this.logtoClient = logtoClient;
|
||||
this.provisioningProps = provisioningProps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a welcome email to the tenant admin after provisioning completes.
|
||||
* Fire-and-forget: logs a warning on failure but does not throw.
|
||||
*/
|
||||
public void sendWelcomeEmail(String toEmail, String username, String tenantName, String slug) {
|
||||
try {
|
||||
doSend(toEmail, username, tenantName, slug);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to send welcome email to {}: {}", toEmail, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void doSend(String toEmail, String username, String tenantName, String slug) throws Exception {
|
||||
var connectorStatus = emailConnectorService.getEmailConnector();
|
||||
if (connectorStatus == null) {
|
||||
log.debug("No email connector configured — skipping welcome email for {}", toEmail);
|
||||
return;
|
||||
}
|
||||
|
||||
var connectors = logtoClient.listConnectors();
|
||||
var raw = connectors.stream()
|
||||
.filter(c -> "Email".equals(c.get("type")))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (raw == null) return;
|
||||
var config = (Map<String, Object>) raw.getOrDefault("config", Map.of());
|
||||
var auth = (Map<String, Object>) config.getOrDefault("auth", Map.of());
|
||||
|
||||
String host = connectorStatus.host();
|
||||
int port = connectorStatus.port();
|
||||
String smtpUsername = connectorStatus.username();
|
||||
String fromEmail = connectorStatus.fromEmail();
|
||||
String password = String.valueOf(auth.getOrDefault("pass", ""));
|
||||
|
||||
String dashboardUrl = provisioningProps.publicProtocol() + "://"
|
||||
+ provisioningProps.publicHost() + "/t/" + slug;
|
||||
String watermarkUrl = provisioningProps.publicProtocol() + "://"
|
||||
+ provisioningProps.publicHost() + "/platform/assets/email-watermark.png";
|
||||
|
||||
String htmlBody = new ClassPathResource("email-templates/welcome-tenant.html")
|
||||
.getContentAsString(StandardCharsets.UTF_8)
|
||||
.replace("{{watermarkUrl}}", watermarkUrl)
|
||||
.replace("{{tenantName}}", tenantName)
|
||||
.replace("{{username}}", username)
|
||||
.replace("{{dashboardUrl}}", dashboardUrl);
|
||||
|
||||
var sender = new JavaMailSenderImpl();
|
||||
sender.setHost(host);
|
||||
sender.setPort(port);
|
||||
sender.setUsername(smtpUsername);
|
||||
sender.setPassword(password);
|
||||
sender.setDefaultEncoding("UTF-8");
|
||||
|
||||
Properties props = sender.getJavaMailProperties();
|
||||
props.put("mail.transport.protocol", "smtp");
|
||||
props.put("mail.smtp.auth", "true");
|
||||
if (port == 465) {
|
||||
props.put("mail.smtp.ssl.enable", "true");
|
||||
} else {
|
||||
props.put("mail.smtp.starttls.enable", "true");
|
||||
}
|
||||
props.put("mail.smtp.timeout", "10000");
|
||||
props.put("mail.smtp.connectiontimeout", "10000");
|
||||
|
||||
var mimeMessage = sender.createMimeMessage();
|
||||
var helper = new MimeMessageHelper(mimeMessage, false, "UTF-8");
|
||||
helper.setTo(toEmail);
|
||||
helper.setFrom(fromEmail);
|
||||
helper.setSubject("Your Cameleer tenant is ready");
|
||||
helper.setText(htmlBody, true);
|
||||
|
||||
sender.send(mimeMessage);
|
||||
log.info("Welcome email sent to {} for tenant {}", toEmail, slug);
|
||||
}
|
||||
}
|
||||
@@ -44,11 +44,13 @@ public class TenantDataCleanupService {
|
||||
try (Connection conn = DriverManager.getConnection(url, props.clickhouseUser(), props.clickhousePassword());
|
||||
Statement stmt = conn.createStatement()) {
|
||||
|
||||
// Find all tables with a tenant_id column
|
||||
// Find all base tables with a tenant_id column (skip materialized views)
|
||||
List<String> tables = new ArrayList<>();
|
||||
try (ResultSet rs = stmt.executeQuery(
|
||||
"SELECT DISTINCT table FROM system.columns " +
|
||||
"WHERE database = currentDatabase() AND name = 'tenant_id'")) {
|
||||
"SELECT DISTINCT c.table FROM system.columns c " +
|
||||
"INNER JOIN system.tables t ON c.table = t.name AND c.database = t.database " +
|
||||
"WHERE c.database = currentDatabase() AND c.name = 'tenant_id' " +
|
||||
"AND t.engine NOT LIKE '%MaterializedView%'")) {
|
||||
while (rs.next()) {
|
||||
tables.add(rs.getString(1));
|
||||
}
|
||||
|
||||
@@ -61,6 +61,9 @@ public class TenantEntity {
|
||||
@Column(name = "db_password")
|
||||
private String dbPassword;
|
||||
|
||||
@Column(name = "admin_email")
|
||||
private String adminEmail;
|
||||
|
||||
@Column(name = "ca_applied_at")
|
||||
private Instant caAppliedAt;
|
||||
|
||||
@@ -105,6 +108,8 @@ public class TenantEntity {
|
||||
public void setProvisionError(String provisionError) { this.provisionError = provisionError; }
|
||||
public String getDbPassword() { return dbPassword; }
|
||||
public void setDbPassword(String dbPassword) { this.dbPassword = dbPassword; }
|
||||
public String getAdminEmail() { return adminEmail; }
|
||||
public void setAdminEmail(String adminEmail) { this.adminEmail = adminEmail; }
|
||||
public Instant getCaAppliedAt() { return caAppliedAt; }
|
||||
public void setCaAppliedAt(Instant caAppliedAt) { this.caAppliedAt = caAppliedAt; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
|
||||
@@ -11,6 +11,7 @@ public record TenantResponse(
|
||||
String slug,
|
||||
String tier,
|
||||
String status,
|
||||
String adminEmail,
|
||||
String serverEndpoint,
|
||||
String provisionError,
|
||||
Instant createdAt,
|
||||
@@ -20,6 +21,7 @@ public record TenantResponse(
|
||||
return new TenantResponse(
|
||||
e.getId(), e.getName(), e.getSlug(),
|
||||
e.getTier().name(), e.getStatus().name(),
|
||||
e.getAdminEmail(),
|
||||
e.getServerEndpoint(), e.getProvisionError(),
|
||||
e.getCreatedAt(), e.getUpdatedAt()
|
||||
);
|
||||
|
||||
@@ -45,6 +45,7 @@ public class VendorTenantController {
|
||||
String slug,
|
||||
String tier,
|
||||
String status,
|
||||
String adminEmail,
|
||||
String serverState,
|
||||
String licenseExpiry,
|
||||
String provisionError,
|
||||
@@ -94,6 +95,7 @@ public class VendorTenantController {
|
||||
return new VendorTenantSummary(
|
||||
tenant.getId(), tenant.getName(), tenant.getSlug(),
|
||||
tenant.getTier().name(), tenant.getStatus().name(),
|
||||
tenant.getAdminEmail(),
|
||||
status.state().name(), licenseExpiry, tenant.getProvisionError(),
|
||||
agentCount, environmentCount, agentLimit
|
||||
);
|
||||
|
||||
@@ -56,6 +56,7 @@ public class VendorTenantService {
|
||||
private final ProvisioningProperties provisioningProps;
|
||||
private final TenantDataCleanupService dataCleanupService;
|
||||
private final TenantDatabaseService tenantDatabaseService;
|
||||
private final io.cameleer.saas.notification.TenantWelcomeNotificationService welcomeNotificationService;
|
||||
private final VendorTenantService self;
|
||||
|
||||
public VendorTenantService(TenantService tenantService,
|
||||
@@ -70,6 +71,7 @@ public class VendorTenantService {
|
||||
ProvisioningProperties provisioningProps,
|
||||
TenantDataCleanupService dataCleanupService,
|
||||
TenantDatabaseService tenantDatabaseService,
|
||||
io.cameleer.saas.notification.TenantWelcomeNotificationService welcomeNotificationService,
|
||||
@Lazy VendorTenantService self) {
|
||||
this.tenantService = tenantService;
|
||||
this.tenantRepository = tenantRepository;
|
||||
@@ -83,6 +85,7 @@ public class VendorTenantService {
|
||||
this.provisioningProps = provisioningProps;
|
||||
this.dataCleanupService = dataCleanupService;
|
||||
this.tenantDatabaseService = tenantDatabaseService;
|
||||
this.welcomeNotificationService = welcomeNotificationService;
|
||||
this.self = self;
|
||||
}
|
||||
|
||||
@@ -91,6 +94,12 @@ public class VendorTenantService {
|
||||
// 1. Create tenant record (sets status = PROVISIONING) + Logto org
|
||||
TenantEntity tenant = tenantService.create(request, actorId);
|
||||
|
||||
// Store admin email on entity
|
||||
if (request.adminEmail() != null && !request.adminEmail().isBlank()) {
|
||||
tenant.setAdminEmail(request.adminEmail());
|
||||
tenantRepository.save(tenant);
|
||||
}
|
||||
|
||||
// 2. Create initial admin user in Logto org (if credentials provided)
|
||||
if (tenant.getLogtoOrgId() != null && logtoClient.isAvailable()) {
|
||||
String ownerRoleId = logtoClient.findOrgRoleIdByName("owner");
|
||||
@@ -218,6 +227,13 @@ public class VendorTenantService {
|
||||
}
|
||||
|
||||
log.info("Tenant {} provisioned successfully", slug);
|
||||
|
||||
// Send welcome email to tenant admin
|
||||
if (tenant.getAdminEmail() != null) {
|
||||
String username = tenant.getAdminEmail().substring(0, tenant.getAdminEmail().indexOf('@'));
|
||||
welcomeNotificationService.sendWelcomeEmail(
|
||||
tenant.getAdminEmail(), username, tenant.getName(), slug);
|
||||
}
|
||||
} else {
|
||||
tenant.setProvisionError(result.error());
|
||||
tenantRepository.save(tenant);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE tenants ADD COLUMN admin_email VARCHAR(255);
|
||||
24
src/main/resources/email-templates/welcome-tenant.html
Normal file
24
src/main/resources/email-templates/welcome-tenant.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
|
||||
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
|
||||
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
|
||||
</div>
|
||||
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
|
||||
<img src="{{watermarkUrl}}" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;border:0;outline:none;" alt="" />
|
||||
<div style="position:relative;">
|
||||
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Welcome to Cameleer</p>
|
||||
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 16px;">Your tenant <strong>{{tenantName}}</strong> has been provisioned and is ready to use.</p>
|
||||
<div style="background:#FDF6EC;border:1px solid #e8e0d4;border-radius:6px;padding:12px 16px;margin:0 0 16px;">
|
||||
<p style="color:#444;font-size:13px;line-height:1.8;margin:0;">
|
||||
<strong>Username:</strong> {{username}}<br/>
|
||||
<strong>Dashboard:</strong> <a href="{{dashboardUrl}}" style="color:#C6820E;text-decoration:none;">{{dashboardUrl}}</a>
|
||||
</p>
|
||||
</div>
|
||||
<p style="color:#888;font-size:13px;line-height:1.5;margin:0 0 16px;">Sign in with the credentials provided by your administrator. We recommend changing your password and enabling MFA after your first login.</p>
|
||||
<a href="{{dashboardUrl}}" style="display:inline-block;background:#C6820E;color:#ffffff;text-decoration:none;padding:10px 24px;border-radius:6px;font-size:14px;font-weight:500;">Open Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
|
||||
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
|
||||
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,6 +77,9 @@ class VendorTenantServiceTest {
|
||||
@Mock
|
||||
private TenantDatabaseService tenantDatabaseService;
|
||||
|
||||
@Mock
|
||||
private io.cameleer.saas.notification.TenantWelcomeNotificationService welcomeNotificationService;
|
||||
|
||||
private VendorTenantService vendorTenantService;
|
||||
|
||||
@BeforeEach
|
||||
@@ -91,11 +94,13 @@ class VendorTenantServiceTest {
|
||||
vendorTenantService = new VendorTenantService(
|
||||
tenantService, tenantRepository, licenseService, signingKeyService,
|
||||
tenantProvisioner, serverApiClient, logtoClient, logtoConfig,
|
||||
auditService, provisioningProps, dataCleanupService, tenantDatabaseService, null);
|
||||
auditService, provisioningProps, dataCleanupService, tenantDatabaseService,
|
||||
welcomeNotificationService, null);
|
||||
vendorTenantService = new VendorTenantService(
|
||||
tenantService, tenantRepository, licenseService, signingKeyService,
|
||||
tenantProvisioner, serverApiClient, logtoClient, logtoConfig,
|
||||
auditService, provisioningProps, dataCleanupService, tenantDatabaseService, vendorTenantService);
|
||||
auditService, provisioningProps, dataCleanupService, tenantDatabaseService,
|
||||
welcomeNotificationService, vendorTenantService);
|
||||
|
||||
// Enable transaction synchronization so afterCommit callbacks can be registered
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
|
||||
6
ui/src/pages/vendor/TenantDetailPage.tsx
vendored
6
ui/src/pages/vendor/TenantDetailPage.tsx
vendored
@@ -400,6 +400,12 @@ export function TenantDetailPage() {
|
||||
<span className={styles.kvLabel}>Slug</span>
|
||||
<span className={styles.kvValueMono}>{tenant.slug}</span>
|
||||
</div>
|
||||
{tenant.adminEmail && (
|
||||
<div className={styles.kvRow}>
|
||||
<span className={styles.kvLabel}>Admin</span>
|
||||
<span className={styles.kvValue}>{tenant.adminEmail}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.kvRow}>
|
||||
<span className={styles.kvLabel}>Created</span>
|
||||
<span className={styles.kvValue}>{new Date(tenant.createdAt).toLocaleDateString()}</span>
|
||||
|
||||
7
ui/src/pages/vendor/VendorTenantsPage.tsx
vendored
7
ui/src/pages/vendor/VendorTenantsPage.tsx
vendored
@@ -41,6 +41,13 @@ const columns: Column<VendorTenantSummary>[] = [
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}>{row.slug}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'adminEmail',
|
||||
header: 'Admin',
|
||||
render: (_v, row) => (
|
||||
<span style={{ fontSize: '0.875rem' }}>{row.adminEmail ?? '—'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tier',
|
||||
header: 'Tier',
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface TenantResponse {
|
||||
slug: string;
|
||||
tier: string;
|
||||
status: string;
|
||||
adminEmail: string | null;
|
||||
serverEndpoint: string | null;
|
||||
provisionError: string | null;
|
||||
createdAt: string;
|
||||
@@ -75,6 +76,7 @@ export interface VendorTenantSummary {
|
||||
slug: string;
|
||||
tier: string;
|
||||
status: string;
|
||||
adminEmail: string | null;
|
||||
serverState: string;
|
||||
licenseExpiry: string | null;
|
||||
provisionError: string | null;
|
||||
|
||||
Reference in New Issue
Block a user