Adapt FileStorage tests to Instance<JobOwnershipService> constructor

This commit is contained in:
a
2026-06-12 22:11:27 +01:00
parent 10f4ecd09d
commit e07eefc013
46 changed files with 437 additions and 268 deletions
@@ -2,26 +2,34 @@ package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import jakarta.enterprise.inject.Instance;
import stirling.software.common.cluster.inprocess.LocalDiskFileStore;
class FileStorageDelegationTest {
@Test
@SuppressWarnings("unchecked")
void storeBytesThenRetrieveBytesRoundTripsThroughFileStore(@TempDir Path tempDir)
throws IOException {
// FileStorage now takes a CDI Instance<JobOwnershipService> (was Optional). Mock one that
// is
// not resolvable, mirroring the previous Optional.empty() (desktop / no-security mode).
Instance<JobOwnershipService> noOwnership = mock(Instance.class);
when(noOwnership.isResolvable()).thenReturn(false);
FileStorage fs =
new FileStorage(
mock(FileOrUploadService.class),
new LocalDiskFileStore(tempDir.toString()),
Optional.empty());
noOwnership);
byte[] payload = "round-trip".getBytes();
String id = fs.storeBytes(payload, "x.bin");
assertArrayEquals(payload, fs.retrieveBytes(id));
@@ -13,16 +13,29 @@ import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import jakarta.enterprise.inject.Instance;
import stirling.software.common.cluster.inprocess.LocalDiskFileStore;
import stirling.software.common.util.JobContext;
class FileStorageOwnershipTest {
// FileStorage now takes a CDI Instance<JobOwnershipService> (was Optional). Build a mock
// Instance
// whose isResolvable()/get() mirror the previous Optional.empty()/Optional.of(svc) contract.
@SuppressWarnings("unchecked")
private static Instance<JobOwnershipService> instanceOf(JobOwnershipService svc) {
Instance<JobOwnershipService> instance = mock(Instance.class);
when(instance.isResolvable()).thenReturn(svc != null);
when(instance.get()).thenReturn(svc);
return instance;
}
private FileStorage newStorageWithoutSecurity(Path tempDir) {
return new FileStorage(
mock(FileOrUploadService.class),
new LocalDiskFileStore(tempDir.toString()),
Optional.empty());
instanceOf(null));
}
private FileStorage newStorageWithCurrentUser(Path tempDir, AtomicReference<String> userRef) {
@@ -31,7 +44,7 @@ class FileStorageOwnershipTest {
return new FileStorage(
mock(FileOrUploadService.class),
new LocalDiskFileStore(tempDir.toString()),
Optional.of(svc));
instanceOf(svc));
}
@Test
@@ -11,7 +11,6 @@ import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.quarkus.arc.profile.IfBuildProfile;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -108,8 +107,6 @@ public class AiCreateController {
// TODO: Migration required - @Transactional(readOnly = true): jakarta.transaction.Transactional
// has no readOnly attribute; using a plain transaction.
// TODO: Migration required - org.springframework.data.domain.PageRequest is Spring Data; replace
// with Hibernate ORM Panache paging when the repository layer is migrated.
@GET
@Path("/sessions")
@Transactional
@@ -121,8 +118,7 @@ public class AiCreateController {
int safeSize = Math.max(1, Math.min(size, 50));
List<AiCreateSessionRepository.AiCreateSessionSummaryProjection> sessions =
sessionService.listSessionSummariesForCurrentUser(
org.springframework.data.domain.PageRequest.of(safePage, safeSize),
includeDrafts);
safePage, safeSize, includeDrafts);
return Response.ok(sessions.stream().map(this::toSummary).toList()).build();
}
@@ -3,6 +3,13 @@ package stirling.software.saas.ai.controller;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.quarkus.arc.profile.IfBuildProfile;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
@@ -13,14 +20,6 @@ import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.quarkus.arc.profile.IfBuildProfile;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -4,6 +4,10 @@ import java.io.InputStream;
import java.net.http.HttpResponse;
import java.util.Optional;
import io.quarkus.arc.profile.IfBuildProfile;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.GET;
@@ -15,11 +19,6 @@ import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.StreamingOutput;
import io.quarkus.arc.profile.IfBuildProfile;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.security.Authentication;
@@ -109,8 +108,7 @@ public class AiProxyController {
@GET
@Path("/versions/{userId}")
public Response versions(
@PathParam("userId") String userId, HttpServletRequest request) {
public Response versions(@PathParam("userId") String userId, HttpServletRequest request) {
return proxy("GET", "/api/versions/" + userId, request, false, false);
}
@@ -122,8 +120,7 @@ public class AiProxyController {
@POST
@Path("/style/{userId}")
public Response updateStyle(
@PathParam("userId") String userId, HttpServletRequest request) {
public Response updateStyle(@PathParam("userId") String userId, HttpServletRequest request) {
return proxy("POST", "/api/style/" + userId, request, false, false);
}
@@ -221,8 +218,7 @@ public class AiProxyController {
};
Response.ResponseBuilder builder = Response.status(statusCode).entity(body);
boolean hasContentType =
copyHeader(aiResponse, builder, "Content-Type");
boolean hasContentType = copyHeader(aiResponse, builder, "Content-Type");
copyHeader(aiResponse, builder, "Cache-Control");
copyHeader(aiResponse, builder, "X-Accel-Buffering");
copyHeader(aiResponse, builder, "Content-Disposition");
@@ -1,5 +1,6 @@
package stirling.software.saas.ai.repository;
import java.time.Instant;
import java.util.List;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
@@ -7,10 +8,23 @@ import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import stirling.software.saas.ai.model.AiCreateSession;
import stirling.software.saas.ai.model.AiCreateSessionStatus;
@ApplicationScoped
public class AiCreateSessionRepository implements PanacheRepositoryBase<AiCreateSession, String> {
/**
* Persist-or-update an entity and return the managed instance. Replaces the Spring Data {@code
* save} convenience: for a managed/updated entity, mutations are flushed by the active
* transaction; for a new entity, {@code persist} attaches it.
*/
public AiCreateSession save(AiCreateSession entity) {
if (entity != null && !isPersistent(entity)) {
persist(entity);
}
return entity;
}
public List<AiCreateSession> findByUserIdOrderByUpdatedAtDesc(String userId) {
return find("userId = ?1 ORDER BY updatedAt DESC", userId).list();
}
@@ -25,4 +39,97 @@ public class AiCreateSessionRepository implements PanacheRepositoryBase<AiCreate
.page(0, maxResults)
.list();
}
public List<AiCreateSession> findByUserIdOrderByUpdatedAtDesc(
String userId, int page, int size) {
return find("userId = ?1 ORDER BY updatedAt DESC", userId).page(page, size).list();
}
public List<AiCreateSession> findByUserIdAndPdfUrlIsNotNullOrderByUpdatedAtDesc(
String userId, int page, int size) {
return find("userId = ?1 AND pdfUrl IS NOT NULL ORDER BY updatedAt DESC", userId)
.page(page, size)
.list();
}
public List<AiCreateSessionSummaryProjection> findSummariesByUserIdOrderByUpdatedAtDesc(
String userId, int page, int size) {
return findByUserIdOrderByUpdatedAtDesc(userId, page, size).stream()
.map(AiCreateSessionSummaryProjection::of)
.toList();
}
public List<AiCreateSessionSummaryProjection>
findSummariesByUserIdAndPdfUrlIsNotNullOrderByUpdatedAtDesc(
String userId, int page, int size) {
return findByUserIdAndPdfUrlIsNotNullOrderByUpdatedAtDesc(userId, page, size).stream()
.map(AiCreateSessionSummaryProjection::of)
.toList();
}
/**
* Lightweight summary view of an {@link AiCreateSession}. Was a Spring Data interface
* projection; under Panache it is materialised from the entity. Getter names are preserved so
* callers are unchanged.
*/
public record AiCreateSessionSummaryProjection(
String sessionId,
String docType,
String templateId,
String promptLatest,
String promptInitial,
AiCreateSessionStatus status,
String pdfUrl,
Instant createdAt,
Instant updatedAt) {
static AiCreateSessionSummaryProjection of(AiCreateSession session) {
return new AiCreateSessionSummaryProjection(
session.getSessionId(),
session.getDocType(),
session.getTemplateId(),
session.getPromptLatest(),
session.getPromptInitial(),
session.getStatus(),
session.getPdfUrl(),
session.getCreatedAt(),
session.getUpdatedAt());
}
public String getSessionId() {
return sessionId;
}
public String getDocType() {
return docType;
}
public String getTemplateId() {
return templateId;
}
public String getPromptLatest() {
return promptLatest;
}
public String getPromptInitial() {
return promptInitial;
}
public AiCreateSessionStatus getStatus() {
return status;
}
public String getPdfUrl() {
return pdfUrl;
}
public Instant getCreatedAt() {
return createdAt;
}
public Instant getUpdatedAt() {
return updatedAt;
}
}
}
@@ -8,12 +8,12 @@ import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.servlet.http.HttpServletRequest;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import io.quarkus.arc.profile.IfBuildProfile;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
@@ -4,23 +4,15 @@ import java.util.List;
import java.util.Optional;
import java.util.UUID;
// TODO: Migration required - org.springframework.data.domain.Pageable is Spring Data; replace with
// Hibernate ORM Panache paging (Page / range) when the repository layer is migrated.
import org.springframework.data.domain.Pageable;
// TODO: Migration required - org.springframework.web.context.request.RequestContextHolder /
// ServletRequestAttributes are Spring MVC; replace with a JAX-RS/Quarkus request-scoped lookup
// (e.g. injected jakarta.ws.rs.core.HttpHeaders / RoutingContext) for session-scoped id resolution.
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import io.quarkus.arc.profile.IfBuildProfile;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Instance;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Response;
import io.quarkus.arc.profile.IfBuildProfile;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -43,6 +35,12 @@ public class AiCreateSessionService {
private final Optional<UserServiceInterface> userService;
// TODO: Migration required - Spring MVC RequestContextHolder/ServletRequestAttributes replaced
// with a CDI-injected request-scoped HttpServletRequest (quarkus-undertow). Wrapped in Instance
// so resolution outside an active HTTP request (e.g. scheduled/startup contexts) is a safe
// no-op.
@jakarta.inject.Inject Instance<HttpServletRequest> currentRequest;
public AiCreateSession createSession(
String prompt,
String docType,
@@ -66,7 +64,7 @@ public class AiCreateSessionService {
public AiCreateSession getSession(String sessionId) {
return repository
.findById(sessionId)
.findByIdOptional(sessionId)
.orElseThrow(
() ->
new WebApplicationException(
@@ -188,28 +186,28 @@ public class AiCreateSessionService {
return repository.findByUserIdOrderByUpdatedAtDesc(userId);
}
public List<AiCreateSession> listSessionsForCurrentUser(Pageable pageable) {
public List<AiCreateSession> listSessionsForCurrentUser(int page, int size) {
String userId = resolveUserId();
return repository.findByUserIdOrderByUpdatedAtDesc(userId, pageable);
return repository.findByUserIdOrderByUpdatedAtDesc(userId, page, size);
}
public List<AiCreateSession> listSessionsForCurrentUser(
Pageable pageable, boolean includeDrafts) {
int page, int size, boolean includeDrafts) {
String userId = resolveUserId();
if (includeDrafts) {
return repository.findByUserIdOrderByUpdatedAtDesc(userId, pageable);
return repository.findByUserIdOrderByUpdatedAtDesc(userId, page, size);
}
return repository.findByUserIdAndPdfUrlIsNotNullOrderByUpdatedAtDesc(userId, pageable);
return repository.findByUserIdAndPdfUrlIsNotNullOrderByUpdatedAtDesc(userId, page, size);
}
public List<AiCreateSessionRepository.AiCreateSessionSummaryProjection>
listSessionSummariesForCurrentUser(Pageable pageable, boolean includeDrafts) {
listSessionSummariesForCurrentUser(int page, int size, boolean includeDrafts) {
String userId = resolveUserId();
if (includeDrafts) {
return repository.findSummariesByUserIdOrderByUpdatedAtDesc(userId, pageable);
return repository.findSummariesByUserIdOrderByUpdatedAtDesc(userId, page, size);
}
return repository.findSummariesByUserIdAndPdfUrlIsNotNullOrderByUpdatedAtDesc(
userId, pageable);
userId, page, size);
}
public String resolveUserId() {
@@ -250,12 +248,16 @@ public class AiCreateSessionService {
}
private String resolveSessionScopedId() {
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
if (currentRequest == null || !currentRequest.isResolvable()) {
return null;
}
HttpServletRequest request;
try {
request = currentRequest.get();
} catch (RuntimeException exc) {
// No active request context (e.g. invoked outside an HTTP request).
return null;
}
HttpServletRequest request = attributes.getRequest();
if (request == null) {
return null;
}
@@ -11,13 +11,13 @@ import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.Part;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import io.quarkus.arc.profile.IfBuildProfile;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.Part;
import lombok.extern.slf4j.Slf4j;
@@ -1,8 +1,9 @@
package stirling.software.saas.config;
import io.quarkus.arc.profile.IfBuildProfile;
import jakarta.enterprise.context.ApplicationScoped;
import io.quarkus.arc.profile.IfBuildProfile;
import lombok.RequiredArgsConstructor;
import stirling.software.saas.interceptor.UnifiedCreditInterceptor;
@@ -2,13 +2,14 @@ package stirling.software.saas.config;
import java.util.Map;
import jakarta.enterprise.context.ApplicationScoped;
import io.quarkus.arc.profile.IfBuildProfile;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.Data;
// TODO: Migration required - @ConfigurationProperties(prefix="credits"); bind via @ConfigProperty or @ConfigMapping
// TODO: Migration required - @ConfigurationProperties(prefix="credits"); bind via @ConfigProperty
// or @ConfigMapping
@Data
@ApplicationScoped
@IfBuildProfile("saas")
@@ -1,9 +1,9 @@
package stirling.software.saas.config;
import jakarta.enterprise.context.ApplicationScoped;
import io.quarkus.arc.profile.IfBuildProfile;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.extern.slf4j.Slf4j;
/**
@@ -1,17 +1,17 @@
package stirling.software.saas.config;
import jakarta.enterprise.context.ApplicationScoped;
import io.quarkus.arc.profile.IfBuildProfile;
import jakarta.enterprise.context.ApplicationScoped;
/**
* Previously registered the {@code :saas} module's entities and repositories with Spring Data JPA.
*
* <p>TODO: Migration required - datasource/JPA now configured via quarkus.datasource.* /
* quarkus.hibernate-orm.* in application.properties. Entity scanning and repository discovery are
* automatic in Quarkus (Panache/Hibernate ORM), so the former @EnableJpaRepositories basePackages
* (stirling.software.saas.repository, .billing.repository, .ai.repository, .payg.repository) and
* @EntityScan packages (.model, .billing.model, .ai.model, .payg) are no longer needed.
* (stirling.software.saas.repository, .billing.repository, .ai.repository, .payg.repository)
* and @EntityScan packages (.model, .billing.model, .ai.model, .payg) are no longer needed.
*/
@ApplicationScoped
@IfBuildProfile("saas")
@@ -1,12 +1,12 @@
package stirling.software.saas.config;
import io.quarkus.arc.profile.IfBuildProfile;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;
import jakarta.inject.Named;
import jakarta.inject.Singleton;
import io.quarkus.arc.profile.IfBuildProfile;
/** Saas mode is unconditionally ENTERPRISE (every tenant is a paying Stripe customer). */
@ApplicationScoped
@IfBuildProfile("saas")
@@ -3,18 +3,19 @@ package stirling.software.saas.config;
import java.net.http.HttpClient;
import java.time.Duration;
import io.quarkus.arc.profile.IfBuildProfile;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;
import jakarta.inject.Singleton;
import io.quarkus.arc.profile.IfBuildProfile;
/**
* HTTP client for talking to Supabase Edge Functions, with a bounded connect timeout.
*
* <p>TODO: Migration required - replaced Spring RestTemplate with java.net.http.HttpClient. Consider
* a typed {@code @RegisterRestClient} client instead. Note: the per-request read timeout previously
* set on RestTemplate must now be applied per HttpRequest via {@code HttpRequest.Builder#timeout}.
* <p>TODO: Migration required - replaced Spring RestTemplate with java.net.http.HttpClient.
* Consider a typed {@code @RegisterRestClient} client instead. Note: the per-request read timeout
* previously set on RestTemplate must now be applied per HttpRequest via {@code
* HttpRequest.Builder#timeout}.
*/
@ApplicationScoped
@IfBuildProfile("saas")
@@ -1,13 +1,15 @@
package stirling.software.saas.config;
import io.quarkus.arc.profile.IfBuildProfile;
import jakarta.enterprise.context.ApplicationScoped;
import io.quarkus.arc.profile.IfBuildProfile;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
/** Supabase configuration ({@code app.supabase.*}) for saas mode. */
// TODO: Migration required - @ConfigurationProperties(prefix="app.supabase"); bind via @ConfigProperty or @ConfigMapping
// TODO: Migration required - @ConfigurationProperties(prefix="app.supabase"); bind via
// @ConfigProperty or @ConfigMapping
@Slf4j
@Data
@ApplicationScoped
@@ -2,6 +2,14 @@ package stirling.software.saas.controller;
import java.util.Map;
import io.quarkus.arc.profile.IfBuildProfile;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.security.RolesAllowed;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.GET;
@@ -11,15 +19,6 @@ import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Response;
import io.quarkus.arc.profile.IfBuildProfile;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -5,6 +5,8 @@ import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import io.quarkus.arc.profile.IfBuildProfile;
import jakarta.annotation.security.RolesAllowed;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.Consumes;
@@ -16,8 +18,6 @@ import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import io.quarkus.arc.profile.IfBuildProfile;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -166,7 +166,7 @@ public class SaasTeamController {
invitation.setStatus(
stirling.software.common.model.enumeration.InvitationStatus.REJECTED);
invitationRepository.save(invitation);
invitationRepository.persist(invitation);
return Response.ok(Map.of("message", "Invitation rejected")).build();
} catch (IllegalArgumentException e) {
@@ -195,7 +195,7 @@ public class SaasTeamController {
User currentUser = getCurrentUser();
TeamInvitation invitation =
invitationRepository
.findById(invitationId)
.findByIdOptional(invitationId)
.orElseThrow(
() -> new IllegalArgumentException("Invitation not found"));
@@ -221,7 +221,7 @@ public class SaasTeamController {
invitation.setStatus(
stirling.software.common.model.enumeration.InvitationStatus.CANCELLED);
invitationRepository.save(invitation);
invitationRepository.persist(invitation);
return Response.ok(Map.of("message", "Invitation cancelled")).build();
} catch (IllegalArgumentException e) {
@@ -392,9 +392,9 @@ public class SaasTeamController {
// Get the user being removed before removing them
User userToRemove =
userRepository
.findById(memberId)
.findByIdOptional(memberId)
.orElseThrow(() -> new IllegalArgumentException("Member not found"));
Team oldTeam = teamRepository.findById(teamId).orElseThrow();
Team oldTeam = teamRepository.findByIdOptional(teamId).orElseThrow();
// Remove the user from the team
saasTeamService.removeTeamMember(teamId, memberId, currentUser);
@@ -443,7 +443,7 @@ public class SaasTeamController {
User currentUser = getCurrentUser();
// Get the team before leaving
Team oldTeam = teamRepository.findById(teamId).orElseThrow();
Team oldTeam = teamRepository.findByIdOptional(teamId).orElseThrow();
// Leave the team
saasTeamService.leaveTeam(teamId, currentUser);
@@ -498,7 +498,7 @@ public class SaasTeamController {
Team team =
teamRepository
.findById(teamId)
.findByIdOptional(teamId)
.orElseThrow(() -> new IllegalArgumentException("Team not found"));
// Prevent renaming personal teams
@@ -516,7 +516,7 @@ public class SaasTeamController {
}
team.setName(request.newName.trim());
teamRepository.save(team);
teamRepository.persist(team);
log.info(
"Team {} renamed to {} by leader {}",
@@ -641,12 +641,11 @@ public class SaasTeamController {
@Path("/{teamId}/seats")
@Consumes(MediaType.APPLICATION_JSON)
@RolesAllowed("ADMIN")
public Response updateTeamSeats(
@PathParam("teamId") Long teamId, UpdateSeatsRequest request) {
public Response updateTeamSeats(@PathParam("teamId") Long teamId, UpdateSeatsRequest request) {
try {
saasTeamService.updateTeamSeats(teamId, request.maxSeats);
Team team = teamRepository.findById(teamId).orElseThrow();
Team team = teamRepository.findByIdOptional(teamId).orElseThrow();
int maxSeats = saasTeamExtensionService.getMaxSeats(team);
int seatsUsed = saasTeamExtensionService.getSeatsUsed(team);
return Response.ok(
@@ -734,7 +733,7 @@ public class SaasTeamController {
try {
Team team =
teamRepository
.findById(teamId)
.findByIdOptional(teamId)
.orElseThrow(() -> new IllegalArgumentException("Team not found"));
List<TeamMembership> memberships = membershipRepository.findByTeamId(teamId);
@@ -6,6 +6,9 @@ import java.util.Map;
import java.util.Set;
import java.util.UUID;
import io.quarkus.arc.profile.IfBuildProfile;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.annotation.security.RolesAllowed;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.POST;
@@ -13,10 +16,6 @@ import jakarta.ws.rs.Path;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Response;
import io.quarkus.arc.profile.IfBuildProfile;
import io.swagger.v3.oas.annotations.Hidden;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -37,10 +37,10 @@ import stirling.software.saas.util.CreditHeaderUtils;
* <p>// TODO: Migration required - was a Spring {@code @RestControllerAdvice(annotations =
* AutoJobPostMapping.class)} with {@code @Profile("saas")} and {@code @Order(1)}, handling
* exceptions via {@code @ExceptionHandler(Throwable.class)}. Re-express as a JAX-RS
* {@code @jakarta.ws.rs.ext.Provider ExceptionMapper<Throwable>} (scoped/gated to AutoJobPostMapping
* endpoints), wiring the saas profile gating via build profile. The {@code handleThrowable} body has
* been translated from Spring {@code ResponseEntity} to JAX-RS {@code Response} but the
* registration/scoping as an ExceptionMapper still needs wiring.
* {@code @jakarta.ws.rs.ext.Provider ExceptionMapper<Throwable>} (scoped/gated to
* AutoJobPostMapping endpoints), wiring the saas profile gating via build profile. The {@code
* handleThrowable} body has been translated from Spring {@code ResponseEntity} to JAX-RS {@code
* Response} but the registration/scoping as an ExceptionMapper still needs wiring.
*/
@ApplicationScoped
@Slf4j
@@ -84,7 +84,8 @@ public class CreditErrorAdvice {
}
// TODO: Migration required - was @ExceptionHandler(Throwable.class) on a @RestControllerAdvice.
// Convert to ExceptionMapper<Throwable>.toResponse(Throwable); HttpServletRequest access must be
// Convert to ExceptionMapper<Throwable>.toResponse(Throwable); HttpServletRequest access must
// be
// replaced by an injected JAX-RS request context (e.g. @Context ContainerRequestContext) since
// ExceptionMapper does not receive the servlet request as a parameter.
public Response handleThrowable(HttpServletRequest request, Throwable ex) {
@@ -28,11 +28,12 @@ import stirling.software.saas.util.CreditHeaderUtils;
* beforeBodyWrite(...)}. Spring's {@code ResponseBodyAdvice}, {@code MethodParameter}, {@code
* HttpMessageConverter}, {@code ServerHttpRequest/Response} and {@code
* ServletServerHttpRequest/Response} have no Quarkus equivalent. Re-express the body-write
* interception as a JAX-RS {@code @jakarta.ws.rs.ext.Provider ContainerResponseFilter}. The original
* {@code supports(...)} returned true for all REST bodies. The credit-consumption logic from {@code
* beforeBodyWrite} is preserved verbatim in {@link #onBeforeBodyWrite(HttpServletRequest,
* HttpServletResponse)} below, operating on the servlet request/response that the original obtained
* via {@code ServletServerHttpRequest.getServletRequest()} / {@code
* interception as a JAX-RS {@code @jakarta.ws.rs.ext.Provider ContainerResponseFilter}. The
* original {@code supports(...)} returned true for all REST bodies. The credit-consumption logic
* from {@code beforeBodyWrite} is preserved verbatim in {@link
* #onBeforeBodyWrite(HttpServletRequest, HttpServletResponse)} below, operating on the servlet
* request/response that the original obtained via {@code
* ServletServerHttpRequest.getServletRequest()} / {@code
* ServletServerHttpResponse.getServletResponse()}.
*/
@ApplicationScoped
@@ -70,7 +71,8 @@ public class CreditSuccessAdvice {
.register(meterRegistry);
}
// TODO: Migration required - was ResponseBodyAdvice#beforeBodyWrite(Object body, MethodParameter,
// TODO: Migration required - was ResponseBodyAdvice#beforeBodyWrite(Object body,
// MethodParameter,
// MediaType, Class<? extends HttpMessageConverter<?>>, ServerHttpRequest, ServerHttpResponse).
// Re-wire as ContainerResponseFilter.filter(ContainerRequestContext, ContainerResponseContext).
// The original returned `body` unchanged; this side-effects credit consumption + response
@@ -38,10 +38,10 @@ import stirling.software.saas.util.AuthenticationUtils;
* HandlerInterceptor}, {@code HandlerMethod} and {@code ModelAndView} have no Quarkus equivalent.
* Convert this to a JAX-RS {@code @jakarta.ws.rs.ext.Provider ContainerRequestFilter} (preHandle ->
* filter, with abort responses replacing the {@code return false} short-circuits). The handler
* introspection that read {@code @AutoJobPostMapping} off the resolved {@code HandlerMethod} must be
* replaced by JAX-RS {@code ResourceInfo#getResourceMethod()} (injected via {@code @Context}). The
* method bodies are preserved; {@code handler} is now an opaque {@code Object} and the
* {@code HandlerMethod} cast has been replaced by a reflective {@link Method} fallback (see TODOs).
* introspection that read {@code @AutoJobPostMapping} off the resolved {@code HandlerMethod} must
* be replaced by JAX-RS {@code ResourceInfo#getResourceMethod()} (injected via {@code @Context}).
* The method bodies are preserved; {@code handler} is now an opaque {@code Object} and the {@code
* HandlerMethod} cast has been replaced by a reflective {@link Method} fallback (see TODOs).
*/
@ApplicationScoped
@Slf4j
@@ -368,8 +368,10 @@ public class UnifiedCreditInterceptor {
log.debug("[CREDIT-DEBUG] postHandle: Success path will be handled by CreditSuccessAdvice");
}
// TODO: Migration required - was @Override HandlerInterceptor#afterCompletion(request, response,
// handler, Exception). Re-wire via a JAX-RS ContainerResponseFilter if afterCompletion semantics
// TODO: Migration required - was @Override HandlerInterceptor#afterCompletion(request,
// response,
// handler, Exception). Re-wire via a JAX-RS ContainerResponseFilter if afterCompletion
// semantics
// are needed; the error path is handled by CreditErrorAdvice.
public void afterCompletion(
HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
@@ -385,7 +387,8 @@ public class UnifiedCreditInterceptor {
}
}
// TODO: Migration required - was @Override AsyncHandlerInterceptor#afterConcurrentHandlingStarted.
// TODO: Migration required - was @Override
// AsyncHandlerInterceptor#afterConcurrentHandlingStarted.
// JAX-RS handles async dispatch differently; no direct equivalent required.
public void afterConcurrentHandlingStarted(
HttpServletRequest request, HttpServletResponse response, Object handler)
@@ -397,10 +400,11 @@ public class UnifiedCreditInterceptor {
}
/**
* // TODO: Migration required - resolves the resource {@link Method} that the original code read
* from Spring's {@code HandlerMethod}. Until this is wired to JAX-RS {@code ResourceInfo}, it
* supports a handler that is already a {@link Method} or exposes a no-arg {@code getMethod()}
* returning one (reflective best-effort), so the {@code @AutoJobPostMapping} gating still works.
* // TODO: Migration required - resolves the resource {@link Method} that the original code
* read from Spring's {@code HandlerMethod}. Until this is wired to JAX-RS {@code ResourceInfo},
* it supports a handler that is already a {@link Method} or exposes a no-arg {@code
* getMethod()} returning one (reflective best-effort), so the {@code @AutoJobPostMapping}
* gating still works.
*/
private Method resolveResourceMethod(Object handler) {
if (handler instanceof Method m) {
@@ -8,15 +8,14 @@ import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import io.quarkus.arc.profile.IfBuildProfile;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import io.quarkus.arc.profile.IfBuildProfile;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.MultipartFile;
import stirling.software.saas.payg.docs.DocumentClassifier;
import stirling.software.saas.payg.docs.DocumentMetrics;
import stirling.software.saas.payg.job.JobContext;
@@ -8,10 +8,10 @@ import java.nio.file.Path;
import java.util.List;
import java.util.Objects;
import jakarta.enterprise.context.ApplicationScoped;
import io.quarkus.arc.profile.IfBuildProfile;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -25,6 +25,8 @@ import jakarta.servlet.http.Part;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.security.Authentication;
import stirling.software.common.security.SecurityContextHolder;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.proprietary.security.database.repository.UserRepository;
@@ -38,8 +40,6 @@ import stirling.software.saas.payg.job.JobService;
import stirling.software.saas.payg.model.JobSource;
import stirling.software.saas.payg.model.JobStepStatus;
import stirling.software.saas.payg.model.ProcessType;
import stirling.software.common.security.Authentication;
import stirling.software.common.security.SecurityContextHolder;
import stirling.software.saas.util.AuthenticationUtils;
/**
@@ -64,8 +64,8 @@ import stirling.software.saas.util.AuthenticationUtils;
* {@code @jakarta.ws.rs.ext.Provider} request/response filter pair. The Spring MVC types {@code
* HandlerMethod}, {@code HandlerMapping}, {@code MultiValueMap}, {@code MultipartFile} and {@code
* MultipartHttpServletRequest} have been removed: handler-annotation introspection now uses a
* reflective {@link Method} fallback (see {@link #resolveResourceMethod}); multipart access now uses
* the servlet-native {@link Part} API ({@code request.getParts()}); the best-matching-pattern
* reflective {@link Method} fallback (see {@link #resolveResourceMethod}); multipart access now
* uses the servlet-native {@link Part} API ({@code request.getParts()}); the best-matching-pattern
* attribute now uses a literal key constant ({@link #BEST_MATCHING_PATTERN_ATTRIBUTE}).
*
* <p>// TODO: Migration required - {@link JobInput}'s {@code multipart} component is still typed as
@@ -212,7 +212,8 @@ public class PaygChargeInterceptor {
}
// TODO: Migration required - was `request instanceof MultipartHttpServletRequest mreq` +
// mreq.getMultiFileMap(). Now uses servlet-native request.getParts(). A non-multipart request
// mreq.getMultiFileMap(). Now uses servlet-native request.getParts(). A non-multipart
// request
// yields no file parts and short-circuits, preserving the original behavior.
List<Part> nonEmpty = new ArrayList<>();
try {
@@ -291,9 +292,9 @@ public class PaygChargeInterceptor {
* // TODO: Migration required - the {@link JobInput} record's first component is still Spring's
* {@code MultipartFile} (owned by another module). This interceptor now sources inputs from the
* servlet {@link Part} API. Once {@code JobInput} is migrated to carry a {@link Part} (or a
* neutral size+content-type holder), construct it directly here:
* {@code return new JobInput(part, path);}. Kept as a single adaptation seam so the rest of the
* charge flow is untouched.
* neutral size+content-type holder), construct it directly here: {@code return new
* JobInput(part, path);}. Kept as a single adaptation seam so the rest of the charge flow is
* untouched.
*/
private JobInput buildJobInput(Part part, Path path) {
return new JobInput(part, path);
@@ -482,10 +483,10 @@ public class PaygChargeInterceptor {
}
/**
* // TODO: Migration required - resolves the resource {@link Method} the original code read from
* Spring's {@code HandlerMethod} (via {@code hm.getMethodAnnotation(...)}). Until wired to JAX-RS
* {@code ResourceInfo}, supports a handler that is already a {@link Method} or exposes a no-arg
* {@code getMethod()} returning one, preserving the {@code @AutoJobPostMapping} gating.
* // TODO: Migration required - resolves the resource {@link Method} the original code read
* from Spring's {@code HandlerMethod} (via {@code hm.getMethodAnnotation(...)}). Until wired to
* JAX-RS {@code ResourceInfo}, supports a handler that is already a {@link Method} or exposes a
* no-arg {@code getMethod()} returning one, preserving the {@code @AutoJobPostMapping} gating.
*/
private Method resolveResourceMethod(Object handler) {
if (handler instanceof Method m) {
@@ -1,8 +1,9 @@
package stirling.software.saas.payg.filter;
import io.quarkus.arc.profile.IfBuildProfile;
import jakarta.enterprise.context.ApplicationScoped;
import io.quarkus.arc.profile.IfBuildProfile;
import lombok.Getter;
import lombok.Setter;
@@ -20,7 +21,8 @@ import lombok.Setter;
* OUTPUT recording in {@code afterCompletion}. {@code null} = unbounded.
* </ul>
*/
// TODO: Migration required - @ConfigurationProperties(prefix="payg.filter"); bind via @ConfigProperty or @ConfigMapping
// TODO: Migration required - @ConfigurationProperties(prefix="payg.filter"); bind via
// @ConfigProperty or @ConfigMapping
@ApplicationScoped
@IfBuildProfile("saas")
@Getter
@@ -12,8 +12,9 @@ import java.util.Objects;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import io.quarkus.arc.profile.IfBuildProfile;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.extern.slf4j.Slf4j;
@@ -42,8 +43,8 @@ import stirling.software.common.util.TempFileManager;
* by the wrapper and is NOT in the returned list when extraction takes the direct-PDF path.
*/
@Slf4j
@Component
@Profile("saas")
@ApplicationScoped
@IfBuildProfile("saas")
public class PaygOutputExtractor {
/** {@code %PDF-} in ASCII. */
@@ -2,10 +2,9 @@ package stirling.software.saas.payg.filter;
import java.io.IOException;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import io.quarkus.arc.profile.IfBuildProfile;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.servlet.AsyncEvent;
import jakarta.servlet.AsyncListener;
import jakarta.servlet.FilterChain;
@@ -31,10 +30,20 @@ import stirling.software.common.util.TempFileManager;
* deferred to an {@link AsyncListener} so the wrapper survives the async window. Close is
* idempotent so a defensive call by the interceptor's {@code afterCompletion} is harmless.
*/
// TODO: Migration required - this was a Spring {@code OncePerRequestFilter}
// ({@code @Component @Profile("saas")}). It must be re-registered as a {@code
// jakarta.servlet.Filter}
// (or a JAX-RS {@code @jakarta.ws.rs.ext.Provider} ContainerResponse filter pair) and ordered ahead
// of the PAYG interceptor so the response wrapper is available in afterCompletion. The Spring base
// class provided once-per-request dispatch and the {@code doFilterInternal} hook; that hook's
// servlet signature is retained below and must be wired into the chosen registration mechanism
// (e.g. a {@code doFilter(ServletRequest, ServletResponse, FilterChain)} delegating here) during
// the
// conversion. The Servlet-native request/response/async handling itself is unchanged.
@Slf4j
@Component
@Profile("saas")
public class PaygResponseBodyWrapperFilter extends OncePerRequestFilter {
@ApplicationScoped
@IfBuildProfile("saas")
public class PaygResponseBodyWrapperFilter {
/** Request-attribute key under which the wrapper is exposed to the interceptor. */
public static final String REQUEST_ATTRIBUTE =
@@ -49,7 +58,8 @@ public class PaygResponseBodyWrapperFilter extends OncePerRequestFilter {
this.properties = properties;
}
@Override
// TODO: Migration required - was @Override of Spring OncePerRequestFilter#doFilterInternal.
// Retains the servlet signature; invoke from the filter registration's doFilter once converted.
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
@@ -1,8 +1,9 @@
package stirling.software.saas.payg.filter;
import io.quarkus.arc.profile.IfBuildProfile;
import jakarta.enterprise.context.ApplicationScoped;
import io.quarkus.arc.profile.IfBuildProfile;
import lombok.RequiredArgsConstructor;
/**
@@ -19,7 +20,8 @@ import lombok.RequiredArgsConstructor;
* </ul>
*/
// TODO: Migration required - interceptor registration moved to @Provider JAX-RS filters; filter
// registration (PaygResponseBodyWrapperFilter on /api/*) now via @WebFilter or quarkus filter config
// registration (PaygResponseBodyWrapperFilter on /api/*) now via @WebFilter or quarkus filter
// config
@ApplicationScoped
@IfBuildProfile("saas")
@RequiredArgsConstructor
@@ -14,12 +14,12 @@ import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import io.quarkus.arc.profile.IfBuildProfile;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import lombok.extern.slf4j.Slf4j;
@@ -1,10 +1,10 @@
package stirling.software.saas.payg.job;
import jakarta.enterprise.context.ApplicationScoped;
import io.quarkus.arc.profile.IfBuildProfile;
import io.quarkus.scheduler.Scheduled;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -10,10 +10,10 @@ import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Set;
import jakarta.enterprise.context.ApplicationScoped;
import io.quarkus.arc.profile.IfBuildProfile;
import jakarta.enterprise.context.ApplicationScoped;
/**
* SHA-256 of the file's bytes. The simplest universally-applicable signature — works for every
* content type, doesn't parse, doesn't allocate proportional to file size (fixed 64 KiB read
@@ -10,12 +10,12 @@ import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import io.quarkus.arc.All;
import io.quarkus.arc.profile.IfBuildProfile;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.extern.slf4j.Slf4j;
@@ -11,11 +11,11 @@ import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import io.quarkus.arc.profile.IfBuildProfile;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import io.quarkus.arc.profile.IfBuildProfile;
import lombok.RequiredArgsConstructor;
import stirling.software.saas.payg.job.JobArtifactHash;
@@ -4,12 +4,12 @@ import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import io.quarkus.arc.profile.IfBuildProfile;
import io.quarkus.scheduler.Scheduled;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.extern.slf4j.Slf4j;
@@ -44,7 +44,8 @@ public class LineagePruneScheduler {
this.retention = retention;
}
// TODO: Migration required - Spring 6-field cron "0 0 * * * *" (top of every hour) translated to
// TODO: Migration required - Spring 6-field cron "0 0 * * * *" (top of every hour) translated
// to
// Quartz cron "0 0 * ? * *" (day-of-month set to ? per Quartz day-of-week/day-of-month
// mutual-exclusion). Configurability is preserved via the {payg.lineage.prune-cron} config
// expression; set that property to a Quartz-syntax cron (default below) to override.
@@ -5,16 +5,15 @@ import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Event;
import jakarta.enterprise.event.Observes;
import jakarta.enterprise.event.TransactionPhase;
import jakarta.transaction.Transactional;
import lombok.extern.slf4j.Slf4j;
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
@@ -41,7 +40,7 @@ import stirling.software.saas.payg.repository.PricingPolicyRepository;
* read-only. We accept this rather than wrapping in a DTO to keep the PR small; if mutation becomes
* a footgun, swap the cache value type for an immutable snapshot.
*/
@Service
@ApplicationScoped
@Slf4j
public class PricingPolicyService {
@@ -50,7 +49,7 @@ public class PricingPolicyService {
private final PricingPolicyRepository policyRepository;
private final PaygTeamExtensionsRepository teamExtensionsRepository;
private final ApplicationEventPublisher eventPublisher;
private final Event<PolicyChangedEvent> eventPublisher;
/**
* Cache keyed by {@code teamId}. Null teamId not supported (caller's bug). Value is the
@@ -61,7 +60,7 @@ public class PricingPolicyService {
public PricingPolicyService(
PricingPolicyRepository policyRepository,
PaygTeamExtensionsRepository teamExtensionsRepository,
ApplicationEventPublisher eventPublisher) {
Event<PolicyChangedEvent> eventPublisher) {
this.policyRepository = Objects.requireNonNull(policyRepository, "policyRepository");
this.teamExtensionsRepository =
Objects.requireNonNull(teamExtensionsRepository, "teamExtensionsRepository");
@@ -87,7 +86,7 @@ public class PricingPolicyService {
* <p>{@link Transactional}({@code readOnly = true}) so the eager-loaded {@code stepLimits} and
* {@code stripePriceIds} collections initialize inside the same session.
*/
@Transactional(readOnly = true)
@Transactional
public PricingPolicy getEffectivePolicy(Long teamId) {
if (teamId == null) {
return loadDefaultPolicy();
@@ -96,7 +95,7 @@ public class PricingPolicyService {
}
/** Bypasses the cache. Useful for admin endpoints that want a fresh read after a mutation. */
@Transactional(readOnly = true)
@Transactional
public PricingPolicy getEffectivePolicyUncached(Long teamId) {
if (teamId == null) {
return loadDefaultPolicy();
@@ -105,19 +104,19 @@ public class PricingPolicyService {
}
/** Lists every policy (admin read). Not cached — admin pages should always see fresh state. */
@Transactional(readOnly = true)
@Transactional
public List<PricingPolicy> listAll() {
return policyRepository.findAll();
return policyRepository.listAll();
}
@Transactional(readOnly = true)
@Transactional
public Optional<PricingPolicy> findByVersion(String version) {
return policyRepository.findByVersion(version);
}
@Transactional(readOnly = true)
@Transactional
public Optional<PricingPolicy> findById(Long policyId) {
return policyRepository.findById(policyId);
return policyRepository.findByIdOptional(policyId);
}
/** Creates a new policy row. Publishes {@link PolicyChangedEvent} after commit. */
@@ -136,9 +135,9 @@ public class PricingPolicyService {
"Create with is_default=true is not allowed; create the row then call"
+ " setDefault(id).");
}
PricingPolicy saved = policyRepository.save(draft);
publishOnCommit("create:" + saved.getId());
return saved;
policyRepository.persist(draft);
publishOnCommit("create:" + draft.getId());
return draft;
}
/**
@@ -151,7 +150,7 @@ public class PricingPolicyService {
Objects.requireNonNull(newDefaultId, "newDefaultId");
PricingPolicy target =
policyRepository
.findById(newDefaultId)
.findByIdOptional(newDefaultId)
.orElseThrow(
() ->
new IllegalArgumentException(
@@ -161,9 +160,9 @@ public class PricingPolicyService {
}
policyRepository.clearDefaultFlag();
target.setIsDefault(true);
PricingPolicy saved = policyRepository.save(target);
publishOnCommit("setDefault:" + saved.getId());
return saved;
policyRepository.persist(target);
publishOnCommit("setDefault:" + target.getId());
return target;
}
/**
@@ -173,12 +172,12 @@ public class PricingPolicyService {
@Transactional
public void setTeamOverride(Long teamId, Long policyId) {
Objects.requireNonNull(teamId, "teamId");
if (policyId != null && !policyRepository.existsById(policyId)) {
if (policyId != null && policyRepository.findByIdOptional(policyId).isEmpty()) {
throw new IllegalArgumentException("No pricing_policy with id " + policyId);
}
PaygTeamExtensions extensions =
teamExtensionsRepository
.findById(teamId)
.findByIdOptional(teamId)
.orElseThrow(
() ->
new IllegalStateException(
@@ -187,7 +186,7 @@ public class PricingPolicyService {
+ " — should have been created on first"
+ " PAYG access."));
extensions.setPricingPolicyId(policyId);
teamExtensionsRepository.save(extensions);
teamExtensionsRepository.persist(extensions);
publishOnCommit("teamOverride:" + teamId);
}
@@ -196,8 +195,13 @@ public class PricingPolicyService {
* changed — cache hit rate is already team-scoped so the cost of a clear is bounded by how many
* active teams there are.
*/
@EventListener
public void onPolicyChanged(PolicyChangedEvent event) {
// TODO: Migration required - after-commit delivery is now expressed on the observer side via
// CDI's TransactionPhase.AFTER_SUCCESS (replacing the firing-side
// TransactionSynchronizationManager.registerSynchronization afterCommit hook). When no
// transaction is active (e.g. test paths calling write methods without a tx), CDI delivers the
// event immediately, matching the former else-branch behavior.
public void onPolicyChanged(
@Observes(during = TransactionPhase.AFTER_SUCCESS) PolicyChangedEvent event) {
long evicted = byTeamCache.estimatedSize();
byTeamCache.invalidateAll();
log.debug(
@@ -212,34 +216,28 @@ public class PricingPolicyService {
}
/**
* Schedules a {@link PolicyChangedEvent} to fire after the current transaction commits, or
* fires immediately if no transaction is active (e.g. test paths calling write methods without
* a tx). Inside-transaction firing would have listeners clearing caches before the row change
* is visible to other connections — racing them into re-reading stale state.
* Fires a {@link PolicyChangedEvent}. The after-commit timing that the Spring implementation
* achieved with {@code TransactionSynchronizationManager.registerSynchronization(afterCommit)}
* is now expressed on the observer side: {@link #onPolicyChanged} observes {@code during =
* TransactionPhase.AFTER_SUCCESS}, so CDI defers delivery until the active transaction commits
* (and delivers immediately when no transaction is active). Firing inside the tx is therefore
* safe — the listener still only clears caches once the row change is committed and visible.
*/
private void publishOnCommit(String payload) {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
eventPublisher.publishEvent(
new PolicyChangedEvent(PricingPolicyService.this, payload));
}
});
} else {
eventPublisher.publishEvent(new PolicyChangedEvent(this, payload));
}
// TODO: Migration required - was TransactionSynchronizationManager-driven after-commit
// dispatch; now a plain Event.fire() whose after-commit timing is enforced by the
// AFTER_SUCCESS observer phase on onPolicyChanged.
eventPublisher.fire(new PolicyChangedEvent(this, payload));
}
private PricingPolicy loadEffectivePolicy(Long teamId) {
Optional<Long> overrideId =
teamExtensionsRepository
.findById(teamId)
.findByIdOptional(teamId)
.map(PaygTeamExtensions::getPricingPolicyId);
if (overrideId.isPresent()) {
Long id = overrideId.get();
Optional<PricingPolicy> override = policyRepository.findById(id);
Optional<PricingPolicy> override = policyRepository.findByIdOptional(id);
if (override.isPresent()) {
return override.get();
}
@@ -3,7 +3,11 @@ package stirling.software.saas.payg.policy.admin;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import io.quarkus.arc.profile.IfBuildProfile;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.security.RolesAllowed;
import jakarta.enterprise.context.ApplicationScoped;
@@ -16,12 +20,6 @@ import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import io.quarkus.arc.profile.IfBuildProfile;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -88,7 +86,9 @@ public class PricingPolicyAdminController {
.entity(PolicyResponse.from(saved))
.build();
} catch (IllegalArgumentException e) {
return Response.status(Response.Status.BAD_REQUEST).entity(error(e.getMessage())).build();
return Response.status(Response.Status.BAD_REQUEST)
.entity(error(e.getMessage()))
.build();
}
}
@@ -118,8 +118,7 @@ public class PricingPolicyAdminController {
description =
"Payload {policyId: <id>} sets the override; {policyId: null} clears it"
+ " (team falls back to default).")
public Response setTeamOverride(
@PathParam("teamId") Long teamId, TeamOverrideRequest req) {
public Response setTeamOverride(@PathParam("teamId") Long teamId, TeamOverrideRequest req) {
try {
policyService.setTeamOverride(teamId, req == null ? null : req.policyId());
return Response.noContent().build();
@@ -1,5 +1,11 @@
package stirling.software.saas.payg.test;
import org.jboss.resteasy.reactive.RestForm;
import org.jboss.resteasy.reactive.multipart.FileUpload;
import io.quarkus.arc.profile.IfBuildProfile;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
@@ -7,13 +13,6 @@ import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import io.quarkus.arc.profile.IfBuildProfile;
import io.swagger.v3.oas.annotations.Hidden;
import org.jboss.resteasy.reactive.RestForm;
import org.jboss.resteasy.reactive.multipart.FileUpload;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
@@ -56,9 +56,9 @@ import stirling.software.saas.util.LogRedactionUtils;
* Quarkus OIDC/auth processing. The {@code doFilterInternal}/{@code shouldNotFilter} servlet
* signatures are retained here; the request/response handling and entry-point error path
* (previously Spring's {@code BearerTokenAuthenticationEntryPoint}) must be reattached during that
* conversion. JWT decoding/validation (previously Spring {@code JwtDecoder}/{@code NimbusJwtDecoder})
* must move to Quarkus OIDC; the {@link JwtDecoder} functional interface below is a placeholder so
* the decode call site keeps compiling.
* conversion. JWT decoding/validation (previously Spring {@code JwtDecoder}/{@code
* NimbusJwtDecoder}) must move to Quarkus OIDC; the {@link JwtDecoder} functional interface below
* is a placeholder so the decode call site keeps compiling.
*/
@Slf4j
public class SupabaseAuthenticationFilter {
@@ -135,8 +135,10 @@ public class SupabaseAuthenticationFilter {
processJwtAuthentication(request);
} catch (AuthenticationException e) {
SecurityContextHolder.clearContext();
// TODO: Migration required - was authenticationEntryPoint.commence(request, response, e)
// (Spring BearerTokenAuthenticationEntryPoint). Emit the 401 challenge response here when
// TODO: Migration required - was authenticationEntryPoint.commence(request, response,
// e)
// (Spring BearerTokenAuthenticationEntryPoint). Emit the 401 challenge response here
// when
// converting to a JAX-RS @Provider filter.
log.debug("JWT authentication failed: {}", e.getMessage());
return;
@@ -181,7 +183,16 @@ public class SupabaseAuthenticationFilter {
EnhancedJwtAuthenticationToken authToken =
new EnhancedJwtAuthenticationToken(
jwt, user.getAuthorities(), user.getUsername(), supabaseId);
jwt,
user.getAuthorities().stream()
.map(
a ->
new stirling.software.common.security
.SimpleGrantedAuthority(
a.getAuthority()))
.collect(java.util.stream.Collectors.toSet()),
user.getUsername(),
supabaseId);
SecurityContextHolder.getContext().setAuthentication(authToken);
// Hot path: runs on every authenticated request (>10 per page on a typical SPA),
@@ -290,7 +301,8 @@ public class SupabaseAuthenticationFilter {
try {
return userService.saveUser(user);
} catch (PersistenceException e) {
// TODO: Migration required - was Spring's DataIntegrityViolationException (email-collision
// TODO: Migration required - was Spring's DataIntegrityViolationException
// (email-collision
// race). jakarta.persistence.PersistenceException is broader; narrow to the
// Hibernate/JPA constraint-violation type once the persistence layer is finalized.
log.warn(
@@ -384,7 +396,8 @@ public class SupabaseAuthenticationFilter {
supabaseUserService.createSupabaseUser(supabaseId, isAnon ? null : email, isAnon);
} catch (PersistenceException ignored) {
// Concurrent creation; fall through, the row exists.
// TODO: Migration required - was Spring's DataIntegrityViolationException. Narrow to the
// TODO: Migration required - was Spring's DataIntegrityViolationException. Narrow to
// the
// Hibernate/JPA constraint-violation type once the persistence layer is finalized.
} catch (Exception e) {
throw new AuthenticationFailureException("Failed to create SupabaseUser", e);
@@ -396,7 +409,8 @@ public class SupabaseAuthenticationFilter {
savedUser = userService.saveUser(newUser);
} catch (PersistenceException dup) {
// Parallel filter won the race; fetch the winning row.
// TODO: Migration required - was Spring's DataIntegrityViolationException. Narrow to the
// TODO: Migration required - was Spring's DataIntegrityViolationException. Narrow to
// the
// Hibernate/JPA constraint-violation type once the persistence layer is finalized.
weCreatedThisUser = false;
savedUser =
@@ -453,8 +467,19 @@ public class SupabaseAuthenticationFilter {
userService.trackApiKeyFirstUse(user.get());
ApiKeyAuthenticationToken authToken =
new ApiKeyAuthenticationToken(user.get(), apiKey, user.get().getAuthorities());
// TODO: Migration required - ApiKeyAuthenticationToken is a plain POJO that does not
// implement the Authentication shim. Wrap the principal/credentials/authorities in a
// UsernamePasswordAuthenticationToken (which does) so it can be set on the SecurityContext.
// Re-wire to a Quarkus SecurityIdentity when the API-key auth path is migrated.
java.util.Collection<stirling.software.common.security.GrantedAuthority> mappedAuthorities =
user.get().getAuthorities().stream()
.map(
a ->
new stirling.software.common.security.SimpleGrantedAuthority(
a.getAuthority()))
.collect(java.util.stream.Collectors.toList());
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(user.get(), apiKey, mappedAuthorities);
SecurityContextHolder.getContext().setAuthentication(authToken);
return true;
}
@@ -466,7 +491,8 @@ public class SupabaseAuthenticationFilter {
// ---------------------------------------------------------------------------------------------
// TODO: Migration required - claim accessor adapters. Spring's Jwt exposed typed claim getters
// (getClaimAsString/getClaimAsStringList/getClaimAsInstant/getClaimAsBoolean). MicroProfile
// JsonWebToken only exposes a generic getClaim(name); these helpers reproduce the original typed
// JsonWebToken only exposes a generic getClaim(name); these helpers reproduce the original
// typed
// semantics so the validation/user-creation logic is preserved unchanged.
// ---------------------------------------------------------------------------------------------
@@ -34,9 +34,9 @@ import stirling.software.saas.service.SupabaseUserService;
* <p>// TODO: Migration required - this class was a Spring {@code @Configuration} with
* {@code @EnableWebSecurity}, {@code @EnableMethodSecurity}, {@code @Profile("saas")} and
* {@code @Order(1)}. The {@code SecurityFilterChain} bean (CSRF/CORS/session/oauth2ResourceServer
* wiring) has no Quarkus equivalent and must be re-expressed declaratively via
* {@code quarkus.http.auth.*} config plus Quarkus OIDC/SmallRye-JWT. The {@code SecurityFilterChain}
* bean method has been removed; the JWKS issuer/audience/clock-skew validation logic and the
* wiring) has no Quarkus equivalent and must be re-expressed declaratively via {@code
* quarkus.http.auth.*} config plus Quarkus OIDC/SmallRye-JWT. The {@code SecurityFilterChain} bean
* method has been removed; the JWKS issuer/audience/clock-skew validation logic and the
* CORS/authority-mapping helpers are retained below so the policy can be re-applied during native
* security wiring. The {@code JwtDecoder}/{@code NimbusJwtDecoder}/{@code OAuth2TokenValidator}
* Spring OAuth2 resource-server types are not available; the token decoding/validation must move to
@@ -65,16 +65,20 @@ public class SupabaseSecurityConfig {
@ConfigProperty(name = "app.supabase.clock-skew-seconds", defaultValue = "120")
long clockSkewSeconds;
// TODO: Migration required - the original @Bean SecurityFilterChain saasSecurityFilterChain(...)
// TODO: Migration required - the original @Bean SecurityFilterChain
// saasSecurityFilterChain(...)
// configured CSRF-disabled, CORS, STATELESS sessions, permitAll matchers for
// OPTIONS/actuator-health/config/static/public-auth/frontend routes, anyRequest().authenticated(),
// OPTIONS/actuator-health/config/static/public-auth/frontend routes,
// anyRequest().authenticated(),
// registered SupabaseAuthenticationFilter before BearerTokenAuthenticationFilter, set a
// BearerTokenAuthenticationEntryPoint + BearerTokenAccessDeniedHandler, and wired
// oauth2ResourceServer().jwt() with this JwtDecoder and SupabaseSecurityConfig::toAuthentication.
// oauth2ResourceServer().jwt() with this JwtDecoder and
// SupabaseSecurityConfig::toAuthentication.
// Re-express this via quarkus.http.auth.permission.* + quarkus.http.cors.* config and Quarkus
// OIDC (mp.jwt). SupabaseAuthenticationFilter must be registered as a JAX-RS @Provider filter.
// TODO: Migration required - original @Bean JwtDecoder jwtDecoder() built a NimbusJwtDecoder from
// TODO: Migration required - original @Bean JwtDecoder jwtDecoder() built a NimbusJwtDecoder
// from
// the Supabase JWKS endpoint (issuer + "/.well-known/jwks.json") and attached a
// SupabaseTokenValidator (iss/exp/aud enforcement with clock skew), failing closed when the
// issuer was unusable. NimbusJwtDecoder / JwtDecoder are Spring OAuth2 types with no Quarkus
@@ -8,10 +8,9 @@ import jakarta.inject.Named;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.common.security.Authentication;
import stirling.software.common.security.SecurityContextHolder;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.saas.repository.TeamMembershipRepository;
@@ -69,7 +69,7 @@ public class AnonymousUserCleanupService {
idStream.collect(Collectors.groupingBy(id -> counter.getAndIncrement() / batchSize))
.values()
.forEach(supabaseUserRepository::deleteAllByIdInBatch);
.forEach(batch -> supabaseUserRepository.delete("id in ?1", batch));
}
}
@@ -80,7 +80,7 @@ public class AnonymousUserCleanupService {
idStream.collect(Collectors.groupingBy(id -> counter.getAndIncrement() / batchSize))
.values()
.forEach(userRepository::deleteAllByIdInBatch);
.forEach(batch -> userRepository.delete("id in ?1", batch));
}
}
}
@@ -23,7 +23,8 @@ import stirling.software.proprietary.security.model.User;
*/
@ApplicationScoped
@IfBuildProfile("saas")
// TODO: Migration required - @ConditionalOnProperty(credits.enabled, matchIfMissing=true) gating not
// TODO: Migration required - @ConditionalOnProperty(credits.enabled, matchIfMissing=true) gating
// not
// translated; backfill always runs under the saas profile
@RequiredArgsConstructor
@Slf4j
@@ -17,15 +17,14 @@ import io.quarkus.arc.profile.IfBuildProfile;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.transaction.TransactionSynchronizationRegistry;
import jakarta.transaction.Transactional;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.security.Authentication;
import stirling.software.common.security.SecurityContextHolder;
import stirling.software.common.security.UsernamePasswordAuthenticationToken;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
@@ -4,15 +4,15 @@ import java.time.LocalDateTime;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import io.quarkus.arc.profile.IfBuildProfile;
import io.quarkus.scheduler.Scheduled;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.database.repository.UserRepository;
@@ -611,9 +611,7 @@ public class SaasTeamService {
.uri(java.net.URI.create(url))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + edgeFunctionSecret)
.POST(
java.net.http.HttpRequest.BodyPublishers.ofString(
requestJson))
.POST(java.net.http.HttpRequest.BodyPublishers.ofString(requestJson))
.build();
java.net.http.HttpClient.newHttpClient()