From 2e51deb511b02fe287c0832174e86db54881708d Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 11:05:35 +0200 Subject: [PATCH] feat(license): PostgresLicenseRepository + LicenseRecord JdbcTemplate-backed repo; upsert is ON CONFLICT (tenant_id), touch updates only last_validated_at, delete is provided for future operator-clear flow (not exposed as REST in v1). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../server/app/config/StorageBeanConfig.java | 8 +++ .../server/app/license/LicenseRecord.java | 14 ++++ .../server/app/license/LicenseRepository.java | 17 +++++ .../license/PostgresLicenseRepository.java | 66 +++++++++++++++++++ .../license/PostgresLicenseRepositoryIT.java | 45 +++++++++++++ 5 files changed, 150 insertions(+) create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRecord.java create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRepository.java create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/license/PostgresLicenseRepository.java create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/license/PostgresLicenseRepositoryIT.java diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/config/StorageBeanConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/config/StorageBeanConfig.java index 6df6a51b..e4cf16b5 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/config/StorageBeanConfig.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/config/StorageBeanConfig.java @@ -203,4 +203,12 @@ public class StorageBeanConfig { ClickHouseUsageTracker usageTracker) { return new com.cameleer.server.app.analytics.UsageFlushScheduler(usageTracker); } + + // ── License Repository ─────────────────────────────────────────── + + @Bean + public com.cameleer.server.app.license.LicenseRepository licenseRepository( + JdbcTemplate jdbcTemplate) { + return new com.cameleer.server.app.license.PostgresLicenseRepository(jdbcTemplate); + } } diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRecord.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRecord.java new file mode 100644 index 00000000..fc1abe56 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRecord.java @@ -0,0 +1,14 @@ +package com.cameleer.server.app.license; + +import java.time.Instant; +import java.util.UUID; + +public record LicenseRecord( + String tenantId, + String token, + UUID licenseId, + Instant installedAt, + String installedBy, + Instant expiresAt, + Instant lastValidatedAt +) {} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRepository.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRepository.java new file mode 100644 index 00000000..b2ce3018 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseRepository.java @@ -0,0 +1,17 @@ +package com.cameleer.server.app.license; + +import java.time.Instant; +import java.util.Optional; + +public interface LicenseRepository { + Optional findByTenantId(String tenantId); + + /** Insert or replace the row for tenantId. */ + void upsert(LicenseRecord record); + + /** Update last_validated_at to `now` and return rows affected (0 = no row). */ + int touchValidated(String tenantId, Instant now); + + /** Delete the row (used when the operator clears a license; not a public API in v1). */ + int delete(String tenantId); +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/PostgresLicenseRepository.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/PostgresLicenseRepository.java new file mode 100644 index 00000000..b0b3369c --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/PostgresLicenseRepository.java @@ -0,0 +1,66 @@ +package com.cameleer.server.app.license; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +public class PostgresLicenseRepository implements LicenseRepository { + + private final JdbcTemplate jdbc; + + public PostgresLicenseRepository(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + private static final RowMapper MAPPER = (rs, n) -> new LicenseRecord( + rs.getString("tenant_id"), + rs.getString("token"), + (UUID) rs.getObject("license_id"), + rs.getTimestamp("installed_at").toInstant(), + rs.getString("installed_by"), + rs.getTimestamp("expires_at").toInstant(), + rs.getTimestamp("last_validated_at").toInstant() + ); + + @Override + public Optional findByTenantId(String tenantId) { + return jdbc.query( + "SELECT tenant_id, token, license_id, installed_at, installed_by, expires_at, last_validated_at " + + "FROM license WHERE tenant_id = ?", + MAPPER, tenantId).stream().findFirst(); + } + + @Override + public void upsert(LicenseRecord r) { + jdbc.update( + "INSERT INTO license (tenant_id, token, license_id, installed_at, installed_by, expires_at, last_validated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT (tenant_id) DO UPDATE SET " + + " token = EXCLUDED.token, " + + " license_id = EXCLUDED.license_id, " + + " installed_at = EXCLUDED.installed_at, " + + " installed_by = EXCLUDED.installed_by, " + + " expires_at = EXCLUDED.expires_at, " + + " last_validated_at = EXCLUDED.last_validated_at", + r.tenantId(), r.token(), r.licenseId(), + Timestamp.from(r.installedAt()), r.installedBy(), + Timestamp.from(r.expiresAt()), Timestamp.from(r.lastValidatedAt()) + ); + } + + @Override + public int touchValidated(String tenantId, Instant now) { + return jdbc.update( + "UPDATE license SET last_validated_at = ? WHERE tenant_id = ?", + Timestamp.from(now), tenantId); + } + + @Override + public int delete(String tenantId) { + return jdbc.update("DELETE FROM license WHERE tenant_id = ?", tenantId); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/PostgresLicenseRepositoryIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/PostgresLicenseRepositoryIT.java new file mode 100644 index 00000000..04b5af4e --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/PostgresLicenseRepositoryIT.java @@ -0,0 +1,45 @@ +package com.cameleer.server.app.license; + +import com.cameleer.server.app.AbstractPostgresIT; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class PostgresLicenseRepositoryIT extends AbstractPostgresIT { + + @Autowired LicenseRepository repo; + + @Test + void roundTrip() { + UUID id = UUID.randomUUID(); + Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); + LicenseRecord rec = new LicenseRecord( + "default", "tok.sig", id, now, "system", + now.plus(365, ChronoUnit.DAYS), now); + repo.upsert(rec); + + var loaded = repo.findByTenantId("default").orElseThrow(); + assertThat(loaded.licenseId()).isEqualTo(id); + assertThat(loaded.installedBy()).isEqualTo("system"); + } + + @Test + void touchValidated_updatesTimestamp() throws Exception { + UUID id = UUID.randomUUID(); + Instant t0 = Instant.now().truncatedTo(ChronoUnit.MILLIS); + repo.upsert(new LicenseRecord("default", "tok.sig", id, t0, "system", + t0.plus(7, ChronoUnit.DAYS), t0)); + + Thread.sleep(10); + Instant t1 = Instant.now().truncatedTo(ChronoUnit.MILLIS); + int affected = repo.touchValidated("default", t1); + assertThat(affected).isEqualTo(1); + assertThat(repo.findByTenantId("default").orElseThrow().lastValidatedAt()) + .isAfterOrEqualTo(t1); + } +}