mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
SaaS Consolidation (#6384)
Co-authored-by: ConnorYoh <[email protected]>
This commit is contained in:
co-authored by
ConnorYoh
parent
089de247b4
commit
9d081d1792
@@ -0,0 +1,410 @@
|
||||
package stirling.software.saas.ai.controller;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.ai.model.AiCreateSession;
|
||||
import stirling.software.saas.ai.repository.AiCreateSessionRepository;
|
||||
import stirling.software.saas.ai.service.AiCreateProxyService;
|
||||
import stirling.software.saas.ai.service.AiCreateSessionService;
|
||||
import stirling.software.saas.service.CreditService;
|
||||
import stirling.software.saas.service.TeamCreditService;
|
||||
import stirling.software.saas.util.AuthenticationUtils;
|
||||
import stirling.software.saas.util.CreditHeaderUtils;
|
||||
|
||||
@RestController
|
||||
@Profile("saas")
|
||||
@RequestMapping("/api/v1/ai/create")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AiCreateController {
|
||||
|
||||
private final AiCreateSessionService sessionService;
|
||||
private final AiCreateProxyService proxyService;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final CreditService creditService;
|
||||
private final TeamCreditService teamCreditService;
|
||||
private final UserRepository userRepository;
|
||||
private final CreditHeaderUtils creditHeaderUtils;
|
||||
|
||||
@PostMapping("/sessions")
|
||||
public ResponseEntity<CreateSessionResponse> createSession(
|
||||
@RequestBody CreateSessionRequest request) {
|
||||
if (request.prompt() == null || request.prompt().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Prompt is required");
|
||||
}
|
||||
AiCreateSession session =
|
||||
sessionService.createSession(
|
||||
request.prompt(),
|
||||
request.docType(),
|
||||
request.templateId(),
|
||||
request.templateTex(),
|
||||
request.previewTex());
|
||||
log.info(
|
||||
"AI create session created sessionId={} userId={} docType={} templateId={}",
|
||||
session.getSessionId(),
|
||||
session.getUserId(),
|
||||
session.getDocType(),
|
||||
session.getTemplateId());
|
||||
return ResponseEntity.ok(new CreateSessionResponse(session.getSessionId()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/sessions/{sessionId}")
|
||||
public ResponseEntity<Void> deleteSession(@PathVariable String sessionId) {
|
||||
sessionService.deleteSessionForCurrentUser(sessionId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/sessions/{sessionId}")
|
||||
@Transactional(readOnly = true)
|
||||
public ResponseEntity<AiCreateSessionResponse> getSession(@PathVariable String sessionId) {
|
||||
AiCreateSession session = sessionService.getSessionForCurrentUser(sessionId);
|
||||
return ResponseEntity.ok(toResponse(session));
|
||||
}
|
||||
|
||||
@GetMapping("/sessions")
|
||||
@Transactional(readOnly = true)
|
||||
public ResponseEntity<List<AiCreateSessionSummary>> listSessions(
|
||||
@RequestParam(name = "page", defaultValue = "0") int page,
|
||||
@RequestParam(name = "size", defaultValue = "10") int size,
|
||||
@RequestParam(name = "includeDrafts", defaultValue = "false") boolean includeDrafts) {
|
||||
int safePage = Math.max(0, page);
|
||||
int safeSize = Math.max(1, Math.min(size, 50));
|
||||
List<AiCreateSessionRepository.AiCreateSessionSummaryProjection> sessions =
|
||||
sessionService.listSessionSummariesForCurrentUser(
|
||||
org.springframework.data.domain.PageRequest.of(safePage, safeSize),
|
||||
includeDrafts);
|
||||
return ResponseEntity.ok(sessions.stream().map(this::toSummary).toList());
|
||||
}
|
||||
|
||||
@PostMapping("/sessions/{sessionId}/outline")
|
||||
public ResponseEntity<AiCreateSessionResponse> updateOutline(
|
||||
@PathVariable String sessionId, @RequestBody OutlineRequest request) {
|
||||
if (request.outlineText() == null) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Outline text is required");
|
||||
}
|
||||
// Allow empty string to indicate "use AI-generated outline"
|
||||
String constraintsPayload = null;
|
||||
if (request.constraints() != null) {
|
||||
try {
|
||||
constraintsPayload = objectMapper.writeValueAsString(request.constraints());
|
||||
} catch (JsonProcessingException exc) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Invalid constraints payload", exc);
|
||||
}
|
||||
}
|
||||
AiCreateSession session =
|
||||
sessionService.updateOutline(
|
||||
sessionId,
|
||||
request.outlineText(),
|
||||
request.outlineFilename(),
|
||||
constraintsPayload);
|
||||
return ResponseEntity.ok(toResponse(session));
|
||||
}
|
||||
|
||||
@PostMapping("/sessions/{sessionId}/reprompt")
|
||||
public ResponseEntity<AiCreateSessionResponse> reprompt(
|
||||
@PathVariable String sessionId, @RequestBody RepromptRequest request) {
|
||||
if (request.prompt() == null || request.prompt().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Prompt is required");
|
||||
}
|
||||
AiCreateSession session = sessionService.reprompt(sessionId, request.prompt());
|
||||
return ResponseEntity.ok(toResponse(session));
|
||||
}
|
||||
|
||||
@PostMapping("/sessions/{sessionId}/draft")
|
||||
public ResponseEntity<AiCreateSessionResponse> updateDraft(
|
||||
@PathVariable String sessionId, @RequestBody DraftRequest request) {
|
||||
if (request.draftSections() == null) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Draft sections are required");
|
||||
}
|
||||
// Allow empty list to indicate "use AI-generated sections"
|
||||
String payload;
|
||||
try {
|
||||
payload = objectMapper.writeValueAsString(request.draftSections());
|
||||
} catch (JsonProcessingException exc) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Invalid draft sections payload", exc);
|
||||
}
|
||||
AiCreateSession session = sessionService.updateDraftSections(sessionId, payload);
|
||||
return ResponseEntity.ok(toResponse(session));
|
||||
}
|
||||
|
||||
@PostMapping("/sessions/{sessionId}/template")
|
||||
public ResponseEntity<AiCreateSessionResponse> updateTemplate(
|
||||
@PathVariable String sessionId, @RequestBody TemplateRequest request) {
|
||||
if ((request.docType() == null || request.docType().isBlank())
|
||||
&& (request.templateId() == null || request.templateId().isBlank())) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "docType or templateId is required");
|
||||
}
|
||||
AiCreateSession session =
|
||||
sessionService.updateTemplate(sessionId, request.docType(), request.templateId());
|
||||
return ResponseEntity.ok(toResponse(session));
|
||||
}
|
||||
|
||||
@PostMapping("/sessions/{sessionId}/fields")
|
||||
public ResponseEntity<StreamingResponseBody> fillFields(
|
||||
@PathVariable String sessionId, HttpServletRequest request) {
|
||||
sessionService.getSessionForCurrentUser(sessionId);
|
||||
log.info("AI create fillFields sessionId={}", sessionId);
|
||||
return proxy(
|
||||
"POST", "/api/create/sessions/" + sessionId + "/fields", request, false, false);
|
||||
}
|
||||
|
||||
@GetMapping(
|
||||
value = "/sessions/{sessionId}/stream",
|
||||
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public ResponseEntity<StreamingResponseBody> stream(
|
||||
@PathVariable String sessionId, HttpServletRequest request) {
|
||||
sessionService.getSessionForCurrentUser(sessionId);
|
||||
return proxy(
|
||||
"GET",
|
||||
"/api/create/sessions/" + sessionId + "/stream",
|
||||
request,
|
||||
true,
|
||||
true); // Add credits header: frontend endpoint that triggers AI
|
||||
}
|
||||
|
||||
private ResponseEntity<StreamingResponseBody> proxy(
|
||||
String method,
|
||||
String path,
|
||||
HttpServletRequest request,
|
||||
boolean acceptEventStream,
|
||||
boolean includeCreditsHeader) {
|
||||
try {
|
||||
HttpResponse<InputStream> response =
|
||||
proxyService.forward(method, path, request, acceptEventStream);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
copyHeader(response, headers, HttpHeaders.CONTENT_TYPE);
|
||||
copyHeader(response, headers, HttpHeaders.CACHE_CONTROL);
|
||||
copyHeader(response, headers, "X-Accel-Buffering");
|
||||
copyHeader(response, headers, HttpHeaders.CONTENT_DISPOSITION);
|
||||
copyHeader(response, headers, HttpHeaders.CONTENT_LENGTH);
|
||||
if (acceptEventStream && !headers.containsHeader(HttpHeaders.CONTENT_TYPE)) {
|
||||
headers.set(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE);
|
||||
}
|
||||
|
||||
// Add credit headers if requested
|
||||
if (includeCreditsHeader) {
|
||||
addCreditHeaders(headers);
|
||||
}
|
||||
|
||||
StreamingResponseBody body =
|
||||
outputStream -> {
|
||||
try (InputStream inputStream = response.body()) {
|
||||
inputStream.transferTo(outputStream);
|
||||
}
|
||||
};
|
||||
HttpStatus status =
|
||||
Optional.ofNullable(HttpStatus.resolve(response.statusCode()))
|
||||
.orElse(HttpStatus.BAD_GATEWAY);
|
||||
return new ResponseEntity<>(body, headers, status);
|
||||
} catch (Exception exc) {
|
||||
log.error("AI create proxy failed path={}", path, exc);
|
||||
StreamingResponseBody body =
|
||||
outputStream ->
|
||||
outputStream.write("{\"error\":\"AI backend unavailable\"}".getBytes());
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
return new ResponseEntity<>(body, headers, HttpStatus.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
|
||||
private void copyHeader(HttpResponse<?> response, HttpHeaders headers, String headerName) {
|
||||
response.headers()
|
||||
.firstValue(headerName)
|
||||
.ifPresent(value -> headers.set(headerName, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add credit headers to the response headers.
|
||||
*
|
||||
* @param headers The headers to add credit information to
|
||||
*/
|
||||
private void addCreditHeaders(HttpHeaders headers) {
|
||||
try {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
log.debug("[AI-CREATE] No authentication found, skipping credit header");
|
||||
return;
|
||||
}
|
||||
|
||||
User user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
||||
int remainingCredits =
|
||||
creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService);
|
||||
if (remainingCredits >= 0) {
|
||||
headers.set("X-Credits-Remaining", Integer.toString(remainingCredits));
|
||||
log.warn("[AI-CREATE] Added X-Credits-Remaining header: {}", remainingCredits);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[AI-CREATE] Failed to add credit header: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public record CreateSessionRequest(
|
||||
String prompt,
|
||||
String docType,
|
||||
String templateId,
|
||||
String templateTex,
|
||||
String previewTex) {}
|
||||
|
||||
public record CreateSessionResponse(String sessionId) {}
|
||||
|
||||
public record OutlineRequest(
|
||||
String outlineText, String outlineFilename, Map<String, Object> constraints) {}
|
||||
|
||||
public record RepromptRequest(String prompt) {}
|
||||
|
||||
public record DraftRequest(List<DraftSection> draftSections) {}
|
||||
|
||||
public record DraftSection(String label, String value) {}
|
||||
|
||||
public record TemplateRequest(String docType, String templateId) {}
|
||||
|
||||
public record AiCreateSessionResponse(
|
||||
String sessionId,
|
||||
String userId,
|
||||
String docType,
|
||||
String templateId,
|
||||
String templateTex,
|
||||
String previewTex,
|
||||
String promptInitial,
|
||||
String promptLatest,
|
||||
String outlineText,
|
||||
String outlineFilename,
|
||||
boolean outlineApproved,
|
||||
Map<String, Object> outlineConstraints,
|
||||
List<DraftSection> draftSections,
|
||||
String polishedLatex,
|
||||
String pdfUrl,
|
||||
Instant createdAt,
|
||||
Instant updatedAt,
|
||||
String status) {}
|
||||
|
||||
public record AiCreateSessionSummary(
|
||||
String sessionId,
|
||||
String docType,
|
||||
String templateId,
|
||||
String promptLatest,
|
||||
String promptInitial,
|
||||
String status,
|
||||
String pdfUrl,
|
||||
Instant createdAt,
|
||||
Instant updatedAt) {}
|
||||
|
||||
private AiCreateSessionResponse toResponse(AiCreateSession session) {
|
||||
return new AiCreateSessionResponse(
|
||||
session.getSessionId(),
|
||||
session.getUserId(),
|
||||
session.getDocType(),
|
||||
session.getTemplateId(),
|
||||
session.getTemplateTex(),
|
||||
session.getPreviewTex(),
|
||||
session.getPromptInitial(),
|
||||
session.getPromptLatest(),
|
||||
session.getOutlineText(),
|
||||
session.getOutlineFilename(),
|
||||
session.isOutlineApproved(),
|
||||
parseOutlineConstraints(session.getOutlineConstraints()),
|
||||
parseDraftSections(session.getDraftSections()),
|
||||
session.getPolishedLatex(),
|
||||
session.getPdfUrl(),
|
||||
session.getCreatedAt(),
|
||||
session.getUpdatedAt(),
|
||||
session.getStatus() != null ? session.getStatus().name() : null);
|
||||
}
|
||||
|
||||
private AiCreateSessionSummary toSummary(AiCreateSession session) {
|
||||
return new AiCreateSessionSummary(
|
||||
session.getSessionId(),
|
||||
session.getDocType(),
|
||||
session.getTemplateId(),
|
||||
session.getPromptLatest(),
|
||||
session.getPromptInitial(),
|
||||
session.getStatus() != null ? session.getStatus().name() : null,
|
||||
session.getPdfUrl(),
|
||||
session.getCreatedAt(),
|
||||
session.getUpdatedAt());
|
||||
}
|
||||
|
||||
private AiCreateSessionSummary toSummary(
|
||||
AiCreateSessionRepository.AiCreateSessionSummaryProjection session) {
|
||||
return new AiCreateSessionSummary(
|
||||
session.getSessionId(),
|
||||
session.getDocType(),
|
||||
session.getTemplateId(),
|
||||
session.getPromptLatest(),
|
||||
session.getPromptInitial(),
|
||||
session.getStatus() != null ? session.getStatus().name() : null,
|
||||
session.getPdfUrl(),
|
||||
session.getCreatedAt(),
|
||||
session.getUpdatedAt());
|
||||
}
|
||||
|
||||
private List<DraftSection> parseDraftSections(String payload) {
|
||||
if (payload == null || payload.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(
|
||||
payload,
|
||||
objectMapper
|
||||
.getTypeFactory()
|
||||
.constructCollectionType(List.class, DraftSection.class));
|
||||
} catch (JsonProcessingException exc) {
|
||||
log.warn("Failed to parse draft sections payload", exc);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> parseOutlineConstraints(String payload) {
|
||||
if (payload == null || payload.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(
|
||||
payload,
|
||||
objectMapper
|
||||
.getTypeFactory()
|
||||
.constructMapType(Map.class, String.class, Object.class));
|
||||
} catch (JsonProcessingException exc) {
|
||||
log.warn("Failed to parse outline constraints payload", exc);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package stirling.software.saas.ai.controller;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.saas.ai.model.AiCreateSession;
|
||||
import stirling.software.saas.ai.model.AiCreateSessionStatus;
|
||||
import stirling.software.saas.ai.service.AiCreateSessionService;
|
||||
|
||||
@RestController
|
||||
@Profile("saas")
|
||||
@RequestMapping("/api/v1/ai/create/internal")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AiCreateInternalController {
|
||||
|
||||
private final AiCreateSessionService sessionService;
|
||||
// Inlined: Stirling's parent build uses Jackson 3 (tools.jackson), no Jackson 2 ObjectMapper
|
||||
// bean in the context. Stateless usage, so a fresh instance per controller is fine.
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@GetMapping("/sessions/{sessionId}")
|
||||
public ResponseEntity<AiCreateController.AiCreateSessionResponse> getSession(
|
||||
@PathVariable String sessionId) {
|
||||
log.info("AI create internal getSession sessionId={}", sessionId);
|
||||
AiCreateSession session = sessionService.getSession(sessionId);
|
||||
return ResponseEntity.ok(toResponse(session));
|
||||
}
|
||||
|
||||
@PostMapping("/sessions/{sessionId}/update")
|
||||
public ResponseEntity<AiCreateController.AiCreateSessionResponse> updateSession(
|
||||
@PathVariable String sessionId, @RequestBody UpdateSessionRequest request) {
|
||||
log.info("AI create internal updateSession sessionId={}", sessionId);
|
||||
String outlineConstraintsPayload = null;
|
||||
if (request.outlineConstraints() != null) {
|
||||
try {
|
||||
outlineConstraintsPayload =
|
||||
objectMapper.writeValueAsString(request.outlineConstraints());
|
||||
} catch (JsonProcessingException exc) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Invalid outline constraints payload", exc);
|
||||
}
|
||||
}
|
||||
String draftSectionsPayload = null;
|
||||
if (request.draftSections() != null) {
|
||||
try {
|
||||
draftSectionsPayload = objectMapper.writeValueAsString(request.draftSections());
|
||||
} catch (JsonProcessingException exc) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Invalid draft sections payload", exc);
|
||||
}
|
||||
}
|
||||
AiCreateSession session =
|
||||
sessionService.applyInternalUpdate(
|
||||
sessionId,
|
||||
request.outlineText(),
|
||||
request.outlineFilename(),
|
||||
request.outlineApproved(),
|
||||
outlineConstraintsPayload,
|
||||
draftSectionsPayload,
|
||||
request.polishedLatex(),
|
||||
request.pdfUrl(),
|
||||
request.docType(),
|
||||
request.templateId(),
|
||||
request.status());
|
||||
return ResponseEntity.ok(toResponse(session));
|
||||
}
|
||||
|
||||
public record UpdateSessionRequest(
|
||||
String outlineText,
|
||||
String outlineFilename,
|
||||
Boolean outlineApproved,
|
||||
Map<String, Object> outlineConstraints,
|
||||
List<AiCreateController.DraftSection> draftSections,
|
||||
String polishedLatex,
|
||||
String pdfUrl,
|
||||
String docType,
|
||||
String templateId,
|
||||
AiCreateSessionStatus status) {}
|
||||
|
||||
private AiCreateController.AiCreateSessionResponse toResponse(AiCreateSession session) {
|
||||
return new AiCreateController.AiCreateSessionResponse(
|
||||
session.getSessionId(),
|
||||
session.getUserId(),
|
||||
session.getDocType(),
|
||||
session.getTemplateId(),
|
||||
session.getTemplateTex(),
|
||||
session.getPreviewTex(),
|
||||
session.getPromptInitial(),
|
||||
session.getPromptLatest(),
|
||||
session.getOutlineText(),
|
||||
session.getOutlineFilename(),
|
||||
session.isOutlineApproved(),
|
||||
parseOutlineConstraints(session.getOutlineConstraints()),
|
||||
parseDraftSections(session.getDraftSections()),
|
||||
session.getPolishedLatex(),
|
||||
session.getPdfUrl(),
|
||||
session.getCreatedAt(),
|
||||
session.getUpdatedAt(),
|
||||
session.getStatus() != null ? session.getStatus().name() : null);
|
||||
}
|
||||
|
||||
private List<AiCreateController.DraftSection> parseDraftSections(String payload) {
|
||||
if (payload == null || payload.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(
|
||||
payload,
|
||||
objectMapper
|
||||
.getTypeFactory()
|
||||
.constructCollectionType(
|
||||
List.class, AiCreateController.DraftSection.class));
|
||||
} catch (JsonProcessingException exc) {
|
||||
log.warn("Failed to parse draft sections payload", exc);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> parseOutlineConstraints(String payload) {
|
||||
if (payload == null || payload.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(
|
||||
payload,
|
||||
objectMapper
|
||||
.getTypeFactory()
|
||||
.constructMapType(Map.class, String.class, Object.class));
|
||||
} catch (JsonProcessingException exc) {
|
||||
log.warn("Failed to parse outline constraints payload", exc);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package stirling.software.saas.ai.controller;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.ai.service.AiProxyService;
|
||||
import stirling.software.saas.service.CreditService;
|
||||
import stirling.software.saas.service.TeamCreditService;
|
||||
import stirling.software.saas.util.AuthenticationUtils;
|
||||
import stirling.software.saas.util.CreditHeaderUtils;
|
||||
|
||||
@RestController
|
||||
@Profile("saas")
|
||||
@RequestMapping("/api/v1/ai")
|
||||
@Slf4j
|
||||
public class AiProxyController {
|
||||
|
||||
private final AiProxyService aiProxyService;
|
||||
private final CreditService creditService;
|
||||
private final TeamCreditService teamCreditService;
|
||||
private final UserRepository userRepository;
|
||||
private final CreditHeaderUtils creditHeaderUtils;
|
||||
|
||||
public AiProxyController(
|
||||
AiProxyService aiProxyService,
|
||||
CreditService creditService,
|
||||
TeamCreditService teamCreditService,
|
||||
UserRepository userRepository,
|
||||
CreditHeaderUtils creditHeaderUtils) {
|
||||
this.aiProxyService = aiProxyService;
|
||||
this.creditService = creditService;
|
||||
this.teamCreditService = teamCreditService;
|
||||
this.userRepository = userRepository;
|
||||
this.creditHeaderUtils = creditHeaderUtils;
|
||||
}
|
||||
|
||||
@PostMapping("/generate_section")
|
||||
public ResponseEntity<StreamingResponseBody> generateSection(HttpServletRequest request) {
|
||||
return proxy("POST", "/api/generate_section", request, false, false);
|
||||
}
|
||||
|
||||
@PostMapping("/generate_all_sections")
|
||||
public ResponseEntity<StreamingResponseBody> generateAllSections(HttpServletRequest request) {
|
||||
return proxy("POST", "/api/generate_all_sections", request, false, false);
|
||||
}
|
||||
|
||||
@PostMapping("/intent/check")
|
||||
public ResponseEntity<StreamingResponseBody> intentCheck(HttpServletRequest request) {
|
||||
return proxy("POST", "/api/intent/check", request, false, false);
|
||||
}
|
||||
|
||||
@PostMapping("/chat/route")
|
||||
public ResponseEntity<StreamingResponseBody> chatRoute(HttpServletRequest request) {
|
||||
return proxy("POST", "/api/chat/route", request, false, true);
|
||||
}
|
||||
|
||||
@PostMapping("/chat/create-smart-folder")
|
||||
public ResponseEntity<StreamingResponseBody> createSmartFolder(HttpServletRequest request) {
|
||||
return proxy("POST", "/api/chat/create-smart-folder", request, false, true);
|
||||
}
|
||||
|
||||
@PostMapping("/chat/info")
|
||||
public ResponseEntity<StreamingResponseBody> chatInfo(HttpServletRequest request) {
|
||||
return proxy("POST", "/api/chat/info", request, false, true);
|
||||
}
|
||||
|
||||
@PostMapping("/pdf/answer")
|
||||
public ResponseEntity<StreamingResponseBody> pdfAnswer(HttpServletRequest request) {
|
||||
return proxy("POST", "/api/pdf/answer", request, false, false);
|
||||
}
|
||||
|
||||
@PostMapping("/progressive_render")
|
||||
public ResponseEntity<StreamingResponseBody> progressiveRender(HttpServletRequest request) {
|
||||
return proxy("POST", "/api/progressive_render", request, false, false);
|
||||
}
|
||||
|
||||
@GetMapping("/versions/{userId}")
|
||||
public ResponseEntity<StreamingResponseBody> versions(
|
||||
@PathVariable("userId") String userId, HttpServletRequest request) {
|
||||
return proxy("GET", "/api/versions/" + userId, request, false, false);
|
||||
}
|
||||
|
||||
@GetMapping("/style/{userId}")
|
||||
public ResponseEntity<StreamingResponseBody> style(
|
||||
@PathVariable("userId") String userId, HttpServletRequest request) {
|
||||
return proxy("GET", "/api/style/" + userId, request, false, false);
|
||||
}
|
||||
|
||||
@PostMapping("/style/{userId}")
|
||||
public ResponseEntity<StreamingResponseBody> updateStyle(
|
||||
@PathVariable("userId") String userId, HttpServletRequest request) {
|
||||
return proxy("POST", "/api/style/" + userId, request, false, false);
|
||||
}
|
||||
|
||||
@PostMapping("/import_template")
|
||||
public ResponseEntity<StreamingResponseBody> importTemplate(HttpServletRequest request) {
|
||||
return proxy("POST", "/api/import_template", request, false, false);
|
||||
}
|
||||
|
||||
@PostMapping("/edit/sessions")
|
||||
public ResponseEntity<StreamingResponseBody> createEditSession(HttpServletRequest request) {
|
||||
return proxy("POST", "/api/edit/sessions", request, false, false);
|
||||
}
|
||||
|
||||
@PostMapping("/edit/sessions/{sessionId}/messages")
|
||||
public ResponseEntity<StreamingResponseBody> editSessionMessage(
|
||||
@PathVariable("sessionId") String sessionId, HttpServletRequest request) {
|
||||
return proxy("POST", "/api/edit/sessions/" + sessionId + "/messages", request, false, true);
|
||||
}
|
||||
|
||||
@PostMapping("/edit/sessions/{sessionId}/attachments")
|
||||
public ResponseEntity<StreamingResponseBody> editSessionAttachment(
|
||||
@PathVariable("sessionId") String sessionId, HttpServletRequest request) {
|
||||
return proxy(
|
||||
"POST", "/api/edit/sessions/" + sessionId + "/attachments", request, false, false);
|
||||
}
|
||||
|
||||
@PostMapping(
|
||||
value = "/edit/sessions/{sessionId}/run",
|
||||
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public ResponseEntity<StreamingResponseBody> runEditSession(
|
||||
@PathVariable("sessionId") String sessionId, HttpServletRequest request) {
|
||||
return proxy("POST", "/api/edit/sessions/" + sessionId + "/run", request, true, false);
|
||||
}
|
||||
|
||||
@GetMapping("/pdf-editor/document")
|
||||
public ResponseEntity<StreamingResponseBody> pdfEditorDocument(HttpServletRequest request) {
|
||||
return proxy("GET", "/api/pdf-editor/document", request, false, false);
|
||||
}
|
||||
|
||||
@PostMapping("/pdf-editor/upload")
|
||||
public ResponseEntity<StreamingResponseBody> pdfEditorUpload(HttpServletRequest request) {
|
||||
return proxy("POST", "/api/pdf-editor/upload", request, false, false);
|
||||
}
|
||||
|
||||
@GetMapping("/output/**")
|
||||
public ResponseEntity<StreamingResponseBody> output(HttpServletRequest request) {
|
||||
String requestUri = request.getRequestURI();
|
||||
String prefix = request.getContextPath() + "/api/v1/ai/output/";
|
||||
String path = requestUri.startsWith(prefix) ? requestUri.substring(prefix.length()) : "";
|
||||
return proxy("GET", "/output/" + path, request, false, false);
|
||||
}
|
||||
|
||||
// Health endpoint at /api/v1/ai/health is owned by the proprietary AiEngineController; both
|
||||
// proxy to the same backing AI engine. No need for credit-aware wrapping on a health probe.
|
||||
|
||||
/**
|
||||
* Proxy method that optionally adds credit headers.
|
||||
*
|
||||
* @param method HTTP method
|
||||
* @param path API path
|
||||
* @param request The incoming request
|
||||
* @param acceptEventStream Whether to accept event stream responses
|
||||
* @param includeCreditsHeader Whether to add credit balance header
|
||||
*/
|
||||
private ResponseEntity<StreamingResponseBody> proxy(
|
||||
String method,
|
||||
String path,
|
||||
HttpServletRequest request,
|
||||
boolean acceptEventStream,
|
||||
boolean includeCreditsHeader) {
|
||||
try {
|
||||
// Forward to AI backend
|
||||
HttpResponse<InputStream> aiResponse =
|
||||
aiProxyService.forward(method, path, request, acceptEventStream);
|
||||
|
||||
// Build response headers
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
copyHeader(aiResponse, headers, HttpHeaders.CONTENT_TYPE);
|
||||
copyHeader(aiResponse, headers, HttpHeaders.CACHE_CONTROL);
|
||||
copyHeader(aiResponse, headers, "X-Accel-Buffering");
|
||||
copyHeader(aiResponse, headers, HttpHeaders.CONTENT_DISPOSITION);
|
||||
copyHeader(aiResponse, headers, HttpHeaders.CONTENT_LENGTH);
|
||||
if (acceptEventStream && !headers.containsHeader(HttpHeaders.CONTENT_TYPE)) {
|
||||
headers.set(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE);
|
||||
}
|
||||
|
||||
// Add credit headers if requested (after AI processing completes)
|
||||
if (includeCreditsHeader) {
|
||||
addCreditHeaders(headers);
|
||||
}
|
||||
|
||||
StreamingResponseBody body =
|
||||
outputStream -> {
|
||||
try (InputStream inputStream = aiResponse.body()) {
|
||||
inputStream.transferTo(outputStream);
|
||||
}
|
||||
};
|
||||
HttpStatus status =
|
||||
Optional.ofNullable(HttpStatus.resolve(aiResponse.statusCode()))
|
||||
.orElse(HttpStatus.BAD_GATEWAY);
|
||||
return new ResponseEntity<>(body, headers, status);
|
||||
} catch (Exception exc) {
|
||||
log.error("AI proxy failed path={}", path, exc);
|
||||
StreamingResponseBody body =
|
||||
outputStream ->
|
||||
outputStream.write("{\"error\":\"AI backend unavailable\"}".getBytes());
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
return new ResponseEntity<>(body, headers, HttpStatus.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
|
||||
private void copyHeader(HttpResponse<?> response, HttpHeaders headers, String headerName) {
|
||||
if (headerName == null || headerName.isBlank()) {
|
||||
return;
|
||||
}
|
||||
response.headers()
|
||||
.firstValue(headerName)
|
||||
.filter(value -> value != null && !value.isBlank())
|
||||
.filter(value -> !value.contains("\r") && !value.contains("\n"))
|
||||
.ifPresent(
|
||||
value -> {
|
||||
try {
|
||||
headers.set(headerName, value);
|
||||
} catch (IllegalArgumentException exc) {
|
||||
log.warn("Skipping invalid header {}: {}", headerName, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add credit headers to the response headers.
|
||||
*
|
||||
* @param headers The headers to add credit information to
|
||||
*/
|
||||
private void addCreditHeaders(HttpHeaders headers) {
|
||||
try {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
log.debug("[AI-PROXY] No authentication found, skipping credit header");
|
||||
return;
|
||||
}
|
||||
|
||||
User user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
||||
int remainingCredits =
|
||||
creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService);
|
||||
if (remainingCredits >= 0) {
|
||||
headers.set("X-Credits-Remaining", Integer.toString(remainingCredits));
|
||||
log.warn("[AI-PROXY] Added X-Credits-Remaining header: {}", remainingCredits);
|
||||
}
|
||||
headers.set("X-Credit-Source", "AI_TOOL_CALL");
|
||||
} catch (Exception e) {
|
||||
log.error("[AI-PROXY] Failed to add credit header: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package stirling.software.saas.ai.model;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Lob;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Entity
|
||||
@Table(name = "ai_create_sessions")
|
||||
@Data
|
||||
public class AiCreateSession {
|
||||
@Id private String sessionId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String userId;
|
||||
|
||||
private String docType;
|
||||
|
||||
private String templateId;
|
||||
|
||||
private String templateTex;
|
||||
|
||||
private String previewTex;
|
||||
|
||||
@Lob private String promptInitial;
|
||||
|
||||
@Lob private String promptLatest;
|
||||
|
||||
@Lob private String outlineText;
|
||||
|
||||
private String outlineFilename;
|
||||
|
||||
private boolean outlineApproved;
|
||||
|
||||
@Lob private String outlineConstraints;
|
||||
|
||||
@Lob private String draftSections;
|
||||
|
||||
@Lob private String polishedLatex;
|
||||
|
||||
// Default JPA String column is varchar(255); signed Supabase / S3 URLs commonly run
|
||||
// 500-1500 chars due to embedded query params (signature, expiry, response headers).
|
||||
@Column(length = 2048)
|
||||
private String pdfUrl;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private AiCreateSessionStatus status;
|
||||
|
||||
@CreationTimestamp private Instant createdAt;
|
||||
|
||||
@UpdateTimestamp private Instant updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package stirling.software.saas.ai.model;
|
||||
|
||||
public enum AiCreateSessionStatus {
|
||||
OUTLINE_PENDING,
|
||||
OUTLINE_APPROVED,
|
||||
DRAFT_READY,
|
||||
POLISHED_READY,
|
||||
SAVED,
|
||||
SHARED
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package stirling.software.saas.ai.repository;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import stirling.software.saas.ai.model.AiCreateSession;
|
||||
import stirling.software.saas.ai.model.AiCreateSessionStatus;
|
||||
|
||||
public interface AiCreateSessionRepository extends JpaRepository<AiCreateSession, String> {
|
||||
List<AiCreateSession> findByUserIdOrderByUpdatedAtDesc(String userId);
|
||||
|
||||
List<AiCreateSession> findByUserIdOrderByUpdatedAtDesc(String userId, Pageable pageable);
|
||||
|
||||
List<AiCreateSession> findByUserIdAndPdfUrlIsNotNullOrderByUpdatedAtDesc(
|
||||
String userId, Pageable pageable);
|
||||
|
||||
@Query(
|
||||
"""
|
||||
select s.sessionId as sessionId,
|
||||
s.docType as docType,
|
||||
s.templateId as templateId,
|
||||
s.promptLatest as promptLatest,
|
||||
s.promptInitial as promptInitial,
|
||||
s.status as status,
|
||||
s.pdfUrl as pdfUrl,
|
||||
s.createdAt as createdAt,
|
||||
s.updatedAt as updatedAt
|
||||
from AiCreateSession s
|
||||
where s.userId = :userId
|
||||
order by s.updatedAt desc
|
||||
""")
|
||||
List<AiCreateSessionSummaryProjection> findSummariesByUserIdOrderByUpdatedAtDesc(
|
||||
@Param("userId") String userId, Pageable pageable);
|
||||
|
||||
@Query(
|
||||
"""
|
||||
select s.sessionId as sessionId,
|
||||
s.docType as docType,
|
||||
s.templateId as templateId,
|
||||
s.promptLatest as promptLatest,
|
||||
s.promptInitial as promptInitial,
|
||||
s.status as status,
|
||||
s.pdfUrl as pdfUrl,
|
||||
s.createdAt as createdAt,
|
||||
s.updatedAt as updatedAt
|
||||
from AiCreateSession s
|
||||
where s.userId = :userId
|
||||
and s.pdfUrl is not null
|
||||
order by s.updatedAt desc
|
||||
""")
|
||||
List<AiCreateSessionSummaryProjection>
|
||||
findSummariesByUserIdAndPdfUrlIsNotNullOrderByUpdatedAtDesc(
|
||||
@Param("userId") String userId, Pageable pageable);
|
||||
|
||||
interface AiCreateSessionSummaryProjection {
|
||||
String getSessionId();
|
||||
|
||||
String getDocType();
|
||||
|
||||
String getTemplateId();
|
||||
|
||||
String getPromptLatest();
|
||||
|
||||
String getPromptInitial();
|
||||
|
||||
AiCreateSessionStatus getStatus();
|
||||
|
||||
String getPdfUrl();
|
||||
|
||||
Instant getCreatedAt();
|
||||
|
||||
Instant getUpdatedAt();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package stirling.software.saas.ai.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@Slf4j
|
||||
public class AiCreateProxyService {
|
||||
|
||||
private static final String DEFAULT_AI_BASE_URL = "http://localhost:5001";
|
||||
|
||||
private final String aiServiceBaseUrl;
|
||||
|
||||
private final HttpClient httpClient;
|
||||
private final UserRepository userRepository;
|
||||
private final UserService userService;
|
||||
|
||||
public AiCreateProxyService(
|
||||
@Value("${app.ai.service-base-url:" + DEFAULT_AI_BASE_URL + "}")
|
||||
String aiServiceBaseUrl,
|
||||
UserRepository userRepository,
|
||||
UserService userService) {
|
||||
this.aiServiceBaseUrl = aiServiceBaseUrl;
|
||||
this.httpClient = HttpClient.newBuilder().build();
|
||||
this.userRepository = userRepository;
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
public HttpResponse<InputStream> forward(
|
||||
String method, String path, HttpServletRequest request, boolean acceptEventStream)
|
||||
throws IOException, InterruptedException {
|
||||
String targetUrl = buildTargetUrl(path, request.getQueryString());
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(targetUrl));
|
||||
|
||||
String contentType = request.getContentType();
|
||||
if (contentType != null && !contentType.isBlank()) {
|
||||
builder.header("Content-Type", contentType);
|
||||
}
|
||||
|
||||
String authorization = request.getHeader("Authorization");
|
||||
if (authorization != null && !authorization.isBlank()) {
|
||||
builder.header("Authorization", authorization);
|
||||
}
|
||||
|
||||
// Extract user API key from authenticated user and forward to AI backend
|
||||
String apiKey = request.getHeader("X-API-KEY");
|
||||
if (apiKey == null || apiKey.isBlank()) {
|
||||
apiKey = extractUserApiKey();
|
||||
}
|
||||
if (apiKey != null && !apiKey.isBlank()) {
|
||||
builder.header("X-API-KEY", apiKey);
|
||||
log.debug("Forwarding X-API-KEY header to AI backend");
|
||||
}
|
||||
|
||||
String accept = request.getHeader("Accept");
|
||||
if (acceptEventStream) {
|
||||
builder.header("Accept", "text/event-stream");
|
||||
} else if (accept != null && !accept.isBlank()) {
|
||||
builder.header("Accept", accept);
|
||||
}
|
||||
|
||||
builder.method(method, buildBodyPublisher(method, request));
|
||||
log.debug("Proxying AI create request {} {}", method, targetUrl);
|
||||
return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofInputStream());
|
||||
}
|
||||
|
||||
private String buildTargetUrl(String path, String queryString) {
|
||||
String baseUrl = aiServiceBaseUrl;
|
||||
if (baseUrl == null || baseUrl.isBlank()) {
|
||||
baseUrl = DEFAULT_AI_BASE_URL;
|
||||
}
|
||||
baseUrl = baseUrl.trim();
|
||||
if (baseUrl.endsWith("/")) {
|
||||
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
|
||||
}
|
||||
if (!path.startsWith("/")) {
|
||||
path = "/" + path;
|
||||
}
|
||||
String url = baseUrl + path;
|
||||
if (queryString != null && !queryString.isBlank()) {
|
||||
url += "?" + queryString;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
private HttpRequest.BodyPublisher buildBodyPublisher(
|
||||
String method, HttpServletRequest request) {
|
||||
if ("GET".equalsIgnoreCase(method) || "DELETE".equalsIgnoreCase(method)) {
|
||||
return HttpRequest.BodyPublishers.noBody();
|
||||
}
|
||||
return HttpRequest.BodyPublishers.ofInputStream(
|
||||
() -> {
|
||||
try {
|
||||
return request.getInputStream();
|
||||
} catch (IOException exc) {
|
||||
throw new UncheckedIOException(exc);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the authenticated user's API key from the database. If the user doesn't have an API
|
||||
* key, one will be created automatically.
|
||||
*
|
||||
* @return The user's API key, or null if not authenticated or key creation fails
|
||||
*/
|
||||
private String extractUserApiKey() {
|
||||
try {
|
||||
// Use getCurrentUsername() which handles all auth types including anonymous users
|
||||
String username = userService.getCurrentUsername();
|
||||
if (username == null || username.isBlank()) {
|
||||
log.debug("No authenticated user found for API key extraction");
|
||||
return null;
|
||||
}
|
||||
|
||||
// getApiKeyForUser will create a key if it doesn't exist
|
||||
String apiKey = userService.getApiKeyForUser(username);
|
||||
log.debug("Retrieved API key for user: {}", username);
|
||||
return apiKey;
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"Failed to extract or create user API key for user: {}",
|
||||
userService.getCurrentUsername(),
|
||||
e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package stirling.software.saas.ai.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.saas.ai.model.AiCreateSession;
|
||||
import stirling.software.saas.ai.model.AiCreateSessionStatus;
|
||||
import stirling.software.saas.ai.repository.AiCreateSessionRepository;
|
||||
import stirling.software.saas.util.AuthenticationUtils;
|
||||
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AiCreateSessionService {
|
||||
private static final String DEFAULT_USER_ID = "default_user";
|
||||
|
||||
private final AiCreateSessionRepository repository;
|
||||
|
||||
private final Optional<UserServiceInterface> userService;
|
||||
|
||||
public AiCreateSession createSession(
|
||||
String prompt,
|
||||
String docType,
|
||||
String templateId,
|
||||
String templateTex,
|
||||
String previewTex) {
|
||||
String userId = resolveUserId();
|
||||
AiCreateSession session = new AiCreateSession();
|
||||
session.setSessionId(UUID.randomUUID().toString());
|
||||
session.setUserId(userId);
|
||||
session.setDocType(docType);
|
||||
session.setTemplateId(templateId);
|
||||
session.setTemplateTex(templateTex);
|
||||
session.setPreviewTex(previewTex);
|
||||
session.setPromptInitial(prompt);
|
||||
session.setPromptLatest(prompt);
|
||||
session.setOutlineApproved(false);
|
||||
session.setStatus(AiCreateSessionStatus.OUTLINE_PENDING);
|
||||
return repository.save(session);
|
||||
}
|
||||
|
||||
public AiCreateSession getSession(String sessionId) {
|
||||
return repository
|
||||
.findById(sessionId)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND, "AI session not found"));
|
||||
}
|
||||
|
||||
public AiCreateSession getSessionForCurrentUser(String sessionId) {
|
||||
AiCreateSession session = getSession(sessionId);
|
||||
String userId = resolveUserId();
|
||||
if (!userId.equals(session.getUserId())) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "AI session not found");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
public AiCreateSession updateOutline(
|
||||
String sessionId,
|
||||
String outlineText,
|
||||
String outlineFilename,
|
||||
String outlineConstraints) {
|
||||
AiCreateSession session = getSessionForCurrentUser(sessionId);
|
||||
session.setOutlineText(outlineText);
|
||||
if (outlineFilename != null && !outlineFilename.isBlank()) {
|
||||
session.setOutlineFilename(outlineFilename);
|
||||
}
|
||||
session.setOutlineApproved(true);
|
||||
if (outlineConstraints != null) {
|
||||
session.setOutlineConstraints(outlineConstraints);
|
||||
}
|
||||
session.setStatus(AiCreateSessionStatus.OUTLINE_APPROVED);
|
||||
return repository.save(session);
|
||||
}
|
||||
|
||||
public AiCreateSession updateDraftSections(String sessionId, String draftSections) {
|
||||
AiCreateSession session = getSessionForCurrentUser(sessionId);
|
||||
session.setDraftSections(draftSections);
|
||||
session.setStatus(AiCreateSessionStatus.DRAFT_READY);
|
||||
return repository.save(session);
|
||||
}
|
||||
|
||||
public AiCreateSession updateTemplate(String sessionId, String docType, String templateId) {
|
||||
AiCreateSession session = getSessionForCurrentUser(sessionId);
|
||||
if (docType != null && !docType.isBlank()) {
|
||||
session.setDocType(docType);
|
||||
}
|
||||
if (templateId != null && !templateId.isBlank()) {
|
||||
session.setTemplateId(templateId);
|
||||
}
|
||||
return repository.save(session);
|
||||
}
|
||||
|
||||
public AiCreateSession reprompt(String sessionId, String prompt) {
|
||||
AiCreateSession session = getSessionForCurrentUser(sessionId);
|
||||
session.setPromptLatest(prompt);
|
||||
session.setOutlineText(null);
|
||||
session.setOutlineFilename(null);
|
||||
session.setOutlineApproved(false);
|
||||
session.setOutlineConstraints(null);
|
||||
session.setDraftSections(null);
|
||||
session.setPolishedLatex(null);
|
||||
session.setPdfUrl(null);
|
||||
session.setStatus(AiCreateSessionStatus.OUTLINE_PENDING);
|
||||
return repository.save(session);
|
||||
}
|
||||
|
||||
public void deleteSessionForCurrentUser(String sessionId) {
|
||||
AiCreateSession session = getSessionForCurrentUser(sessionId);
|
||||
repository.delete(session);
|
||||
}
|
||||
|
||||
public AiCreateSession applyInternalUpdate(
|
||||
String sessionId,
|
||||
String outlineText,
|
||||
String outlineFilename,
|
||||
Boolean outlineApproved,
|
||||
String outlineConstraints,
|
||||
String draftSections,
|
||||
String polishedLatex,
|
||||
String pdfUrl,
|
||||
String docType,
|
||||
String templateId,
|
||||
AiCreateSessionStatus status) {
|
||||
AiCreateSession session = getSession(sessionId);
|
||||
if (outlineText != null) {
|
||||
session.setOutlineText(outlineText);
|
||||
}
|
||||
if (outlineFilename != null) {
|
||||
session.setOutlineFilename(outlineFilename);
|
||||
}
|
||||
if (outlineApproved != null) {
|
||||
session.setOutlineApproved(outlineApproved);
|
||||
}
|
||||
if (outlineConstraints != null) {
|
||||
session.setOutlineConstraints(outlineConstraints);
|
||||
}
|
||||
if (draftSections != null) {
|
||||
session.setDraftSections(draftSections);
|
||||
}
|
||||
if (polishedLatex != null) {
|
||||
session.setPolishedLatex(polishedLatex);
|
||||
}
|
||||
if (pdfUrl != null) {
|
||||
session.setPdfUrl(pdfUrl);
|
||||
}
|
||||
if (docType != null) {
|
||||
session.setDocType(docType);
|
||||
}
|
||||
if (templateId != null) {
|
||||
session.setTemplateId(templateId);
|
||||
}
|
||||
if (status != null) {
|
||||
session.setStatus(status);
|
||||
}
|
||||
return repository.save(session);
|
||||
}
|
||||
|
||||
public List<AiCreateSession> listSessionsForCurrentUser() {
|
||||
String userId = resolveUserId();
|
||||
return repository.findByUserIdOrderByUpdatedAtDesc(userId);
|
||||
}
|
||||
|
||||
public List<AiCreateSession> listSessionsForCurrentUser(Pageable pageable) {
|
||||
String userId = resolveUserId();
|
||||
return repository.findByUserIdOrderByUpdatedAtDesc(userId, pageable);
|
||||
}
|
||||
|
||||
public List<AiCreateSession> listSessionsForCurrentUser(
|
||||
Pageable pageable, boolean includeDrafts) {
|
||||
String userId = resolveUserId();
|
||||
if (includeDrafts) {
|
||||
return repository.findByUserIdOrderByUpdatedAtDesc(userId, pageable);
|
||||
}
|
||||
return repository.findByUserIdAndPdfUrlIsNotNullOrderByUpdatedAtDesc(userId, pageable);
|
||||
}
|
||||
|
||||
public List<AiCreateSessionRepository.AiCreateSessionSummaryProjection>
|
||||
listSessionSummariesForCurrentUser(Pageable pageable, boolean includeDrafts) {
|
||||
String userId = resolveUserId();
|
||||
if (includeDrafts) {
|
||||
return repository.findSummariesByUserIdOrderByUpdatedAtDesc(userId, pageable);
|
||||
}
|
||||
return repository.findSummariesByUserIdAndPdfUrlIsNotNullOrderByUpdatedAtDesc(
|
||||
userId, pageable);
|
||||
}
|
||||
|
||||
public String resolveUserId() {
|
||||
String userId = resolveFromUserService();
|
||||
if (userId != null) {
|
||||
return userId;
|
||||
}
|
||||
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication != null && authentication.isAuthenticated()) {
|
||||
String authId = AuthenticationUtils.extractSupabaseId(authentication);
|
||||
if (authId != null && !authId.isBlank() && !"anonymousUser".equals(authId)) {
|
||||
return authId;
|
||||
}
|
||||
}
|
||||
|
||||
String sessionScoped = resolveSessionScopedId();
|
||||
if (sessionScoped != null) {
|
||||
return sessionScoped;
|
||||
}
|
||||
|
||||
return DEFAULT_USER_ID;
|
||||
}
|
||||
|
||||
private String resolveFromUserService() {
|
||||
if (userService.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
String username = userService.get().getCurrentUsername();
|
||||
if (username != null && !username.isBlank() && !"anonymousUser".equals(username)) {
|
||||
return username;
|
||||
}
|
||||
} catch (Exception exc) {
|
||||
log.debug("Failed to resolve current username: {}", exc.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String resolveSessionScopedId() {
|
||||
ServletRequestAttributes attributes =
|
||||
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attributes == null) {
|
||||
return null;
|
||||
}
|
||||
HttpServletRequest request = attributes.getRequest();
|
||||
if (request == null) {
|
||||
return null;
|
||||
}
|
||||
HttpSession session = request.getSession(false);
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
return "session:" + session.getId();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package stirling.software.saas.ai.service;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.Part;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@Slf4j
|
||||
public class AiProxyService {
|
||||
|
||||
private static final String DEFAULT_AI_BASE_URL = "http://localhost:5001";
|
||||
|
||||
private final String aiServiceBaseUrl;
|
||||
|
||||
private final HttpClient httpClient;
|
||||
private final UserRepository userRepository;
|
||||
private final UserService userService;
|
||||
|
||||
public AiProxyService(
|
||||
@Value("${app.ai.service-base-url:" + DEFAULT_AI_BASE_URL + "}")
|
||||
String aiServiceBaseUrl,
|
||||
UserRepository userRepository,
|
||||
UserService userService) {
|
||||
this.aiServiceBaseUrl = aiServiceBaseUrl;
|
||||
this.httpClient = HttpClient.newBuilder().build();
|
||||
this.userRepository = userRepository;
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
public HttpResponse<InputStream> forward(
|
||||
String method, String path, HttpServletRequest request, boolean acceptEventStream)
|
||||
throws IOException, InterruptedException {
|
||||
String targetUrl = buildTargetUrl(path, request.getQueryString());
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(targetUrl));
|
||||
|
||||
String contentType = request.getContentType();
|
||||
|
||||
String authorization = request.getHeader("Authorization");
|
||||
if (authorization != null && !authorization.isBlank()) {
|
||||
builder.header("Authorization", authorization);
|
||||
}
|
||||
|
||||
// Extract user API key from authenticated user and forward to AI backend
|
||||
String apiKey = request.getHeader("X-API-KEY");
|
||||
if (apiKey == null || apiKey.isBlank()) {
|
||||
apiKey = extractUserApiKey();
|
||||
}
|
||||
if (apiKey != null && !apiKey.isBlank()) {
|
||||
builder.header("X-API-KEY", apiKey);
|
||||
log.debug("Forwarding X-API-KEY header to AI backend");
|
||||
}
|
||||
|
||||
String accept = request.getHeader("Accept");
|
||||
if (acceptEventStream) {
|
||||
builder.header("Accept", "text/event-stream");
|
||||
} else if (accept != null && !accept.isBlank()) {
|
||||
builder.header("Accept", accept);
|
||||
}
|
||||
|
||||
BodyPublisherWithContentType body = buildBodyPublisher(method, request, contentType);
|
||||
if (body.contentType != null && !body.contentType.isBlank()) {
|
||||
builder.header("Content-Type", body.contentType);
|
||||
} else if (contentType != null && !contentType.isBlank()) {
|
||||
builder.header("Content-Type", contentType);
|
||||
}
|
||||
builder.method(method, body.publisher);
|
||||
log.debug("Proxying AI request {} {}", method, targetUrl);
|
||||
return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofInputStream());
|
||||
}
|
||||
|
||||
private String buildTargetUrl(String path, String queryString) {
|
||||
String baseUrl = aiServiceBaseUrl;
|
||||
if (baseUrl == null || baseUrl.isBlank()) {
|
||||
baseUrl = DEFAULT_AI_BASE_URL;
|
||||
}
|
||||
baseUrl = baseUrl.trim();
|
||||
if (baseUrl.endsWith("/")) {
|
||||
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
|
||||
}
|
||||
if (!path.startsWith("/")) {
|
||||
path = "/" + path;
|
||||
}
|
||||
String url = baseUrl + path;
|
||||
if (queryString != null && !queryString.isBlank()) {
|
||||
url += "?" + queryString;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
private BodyPublisherWithContentType buildBodyPublisher(
|
||||
String method, HttpServletRequest request, String contentType) throws IOException {
|
||||
if ("GET".equalsIgnoreCase(method) || "DELETE".equalsIgnoreCase(method)) {
|
||||
return new BodyPublisherWithContentType(HttpRequest.BodyPublishers.noBody(), null);
|
||||
}
|
||||
if (contentType != null && contentType.startsWith("multipart/form-data")) {
|
||||
String boundary = "----spdf-" + UUID.randomUUID().toString().replace("-", "");
|
||||
byte[] body = buildMultipartBody(request, boundary);
|
||||
return new BodyPublisherWithContentType(
|
||||
HttpRequest.BodyPublishers.ofByteArray(body),
|
||||
"multipart/form-data; boundary=" + boundary);
|
||||
}
|
||||
return new BodyPublisherWithContentType(
|
||||
HttpRequest.BodyPublishers.ofInputStream(
|
||||
() -> {
|
||||
try {
|
||||
return request.getInputStream();
|
||||
} catch (IOException exc) {
|
||||
throw new UncheckedIOException(exc);
|
||||
}
|
||||
}),
|
||||
null);
|
||||
}
|
||||
|
||||
private byte[] buildMultipartBody(HttpServletRequest request, String boundary)
|
||||
throws IOException {
|
||||
try {
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
for (Part part : request.getParts()) {
|
||||
writeLine(output, "--" + boundary);
|
||||
String name = part.getName();
|
||||
String filename = part.getSubmittedFileName();
|
||||
if (filename != null && !filename.isBlank()) {
|
||||
writeLine(
|
||||
output,
|
||||
"Content-Disposition: form-data; name=\""
|
||||
+ name
|
||||
+ "\"; filename=\""
|
||||
+ filename
|
||||
+ "\"");
|
||||
} else {
|
||||
writeLine(output, "Content-Disposition: form-data; name=\"" + name + "\"");
|
||||
}
|
||||
String partContentType = part.getContentType();
|
||||
if (partContentType != null && !partContentType.isBlank()) {
|
||||
writeLine(output, "Content-Type: " + partContentType);
|
||||
}
|
||||
writeLine(output, "");
|
||||
try (InputStream input = part.getInputStream()) {
|
||||
input.transferTo(output);
|
||||
}
|
||||
writeLine(output, "");
|
||||
}
|
||||
writeLine(output, "--" + boundary + "--");
|
||||
writeLine(output, "");
|
||||
return output.toByteArray();
|
||||
} catch (Exception exc) {
|
||||
if (exc instanceof IOException) {
|
||||
throw (IOException) exc;
|
||||
}
|
||||
throw new IOException("Failed to proxy multipart request", exc);
|
||||
}
|
||||
}
|
||||
|
||||
private void writeLine(ByteArrayOutputStream output, String value) throws IOException {
|
||||
output.write(value.getBytes(StandardCharsets.UTF_8));
|
||||
output.write("\r\n".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the authenticated user's API key from the database. If the user doesn't have an API
|
||||
* key, one will be created automatically.
|
||||
*
|
||||
* @return The user's API key, or null if not authenticated or key creation fails
|
||||
*/
|
||||
private String extractUserApiKey() {
|
||||
try {
|
||||
// Use getCurrentUsername() which handles all auth types including anonymous users
|
||||
String username = userService.getCurrentUsername();
|
||||
if (username == null || username.isBlank()) {
|
||||
log.debug("No authenticated user found for API key extraction");
|
||||
return null;
|
||||
}
|
||||
|
||||
// getApiKeyForUser will create a key if it doesn't exist
|
||||
String apiKey = userService.getApiKeyForUser(username);
|
||||
log.debug("Retrieved API key for user: {}", username);
|
||||
return apiKey;
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"Failed to extract or create user API key for user: {}",
|
||||
userService.getCurrentUsername(),
|
||||
e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static class BodyPublisherWithContentType {
|
||||
private final HttpRequest.BodyPublisher publisher;
|
||||
private final String contentType;
|
||||
|
||||
private BodyPublisherWithContentType(
|
||||
HttpRequest.BodyPublisher publisher, String contentType) {
|
||||
this.publisher = publisher;
|
||||
this.contentType = contentType;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package stirling.software.saas.billing.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Stripe billing subscription mirror. A row appears here when Supabase webhooks the server to
|
||||
* record a tenant's subscription state.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "billing_subscriptions")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class BillingSubscription implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** Stripe subscription ID. */
|
||||
@Id
|
||||
@Column(name = "id")
|
||||
private String id;
|
||||
|
||||
/** Supabase auth user ID owning this subscription. */
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private UUID userId;
|
||||
|
||||
/** Optional team ID if this subscription is at team level rather than per-user. */
|
||||
@Column(name = "team_id")
|
||||
private Long teamId;
|
||||
|
||||
/** Stripe subscription status: active, trialing, past_due, canceled, etc. */
|
||||
@Column(name = "status", nullable = false)
|
||||
private String status;
|
||||
|
||||
/** Stripe price ID. */
|
||||
@Column(name = "price_id")
|
||||
private String priceId;
|
||||
|
||||
@Column(name = "current_period_end")
|
||||
private LocalDateTime currentPeriodEnd;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public boolean isActive() {
|
||||
return "active".equalsIgnoreCase(status)
|
||||
|| "trialing".equalsIgnoreCase(status)
|
||||
|| "past_due".equalsIgnoreCase(status);
|
||||
}
|
||||
|
||||
public boolean isValid() {
|
||||
return isActive()
|
||||
&& (currentPeriodEnd == null || currentPeriodEnd.isAfter(LocalDateTime.now()));
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package stirling.software.saas.billing.repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.saas.billing.model.BillingSubscription;
|
||||
|
||||
/** Read/write access to the Stripe subscription mirror table. */
|
||||
@Repository
|
||||
public interface BillingSubscriptionRepository extends JpaRepository<BillingSubscription, String> {
|
||||
|
||||
List<BillingSubscription> findByUserId(UUID userId);
|
||||
|
||||
@Query(
|
||||
"SELECT s FROM BillingSubscription s "
|
||||
+ "WHERE s.userId = :userId "
|
||||
+ "AND s.status IN ('active', 'trialing', 'past_due') "
|
||||
+ "ORDER BY s.createdAt DESC")
|
||||
List<BillingSubscription> findActiveSubscriptionsByUserId(@Param("userId") UUID userId);
|
||||
|
||||
@Query(
|
||||
"SELECT COUNT(s) > 0 FROM BillingSubscription s "
|
||||
+ "WHERE s.userId = :userId "
|
||||
+ "AND s.status IN ('active', 'trialing', 'past_due')")
|
||||
boolean existsActiveSubscriptionForUser(@Param("userId") UUID userId);
|
||||
|
||||
/**
|
||||
* Active PAID subscription (excludes trials). 'active' and 'past_due' count as paid; 'trialing'
|
||||
* does not, because trial users can be invited to teams without becoming payers.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT COUNT(s) > 0 FROM BillingSubscription s "
|
||||
+ "WHERE s.userId = :userId "
|
||||
+ "AND s.status IN ('active', 'past_due')")
|
||||
boolean existsActivePaidSubscriptionForUser(@Param("userId") UUID userId);
|
||||
|
||||
default Optional<BillingSubscription> findLatestActiveSubscription(UUID userId) {
|
||||
return findActiveSubscriptionsByUserId(userId).stream().findFirst();
|
||||
}
|
||||
|
||||
@Query(
|
||||
"SELECT COUNT(s) > 0 FROM BillingSubscription s "
|
||||
+ "WHERE s.teamId = :teamId "
|
||||
+ "AND s.status IN ('active', 'trialing', 'past_due')")
|
||||
boolean existsActiveSubscriptionForTeam(@Param("teamId") Long teamId);
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package stirling.software.saas.billing.service;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.saas.config.SupabaseConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Reports per-tenant overage to Stripe Billing Meters via the Supabase {@code meter-usage} Edge
|
||||
* Function. Only credits consumed above the free tier flow through {@link #reportUsageToStripe}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("saas")
|
||||
public class StripeUsageReportingService {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final SupabaseConfigurationProperties supabaseConfig;
|
||||
|
||||
@Value("${supabase.url:}")
|
||||
private String supabaseUrl;
|
||||
|
||||
private final HttpClient httpClient =
|
||||
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
|
||||
|
||||
public StripeUsageReportingService(SupabaseConfigurationProperties supabaseConfig) {
|
||||
this.supabaseConfig = supabaseConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports a usage overage for a tenant. Returns {@code true} on a 200 response from Supabase,
|
||||
* {@code false} on any non-success outcome (including missing config). Caller should retry with
|
||||
* the same {@code idempotencyKey} on transient failures.
|
||||
*/
|
||||
public boolean reportUsageToStripe(
|
||||
String supabaseId, int overageCredits, String idempotencyKey) {
|
||||
|
||||
if (overageCredits <= 0) {
|
||||
log.warn(
|
||||
"[USAGE-BILLING] non-positive overage {} for user {}",
|
||||
overageCredits,
|
||||
supabaseId);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (supabaseUrl == null || supabaseUrl.isEmpty()) {
|
||||
log.error(
|
||||
"[USAGE-BILLING] supabase.url not configured; cannot report usage. Set SUPABASE_URL.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!supabaseConfig.isEdgeFunctionConfigured()) {
|
||||
log.error(
|
||||
"[USAGE-BILLING] Supabase edge function not configured (URL + secret required); cannot report usage.");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
String meterUsageUrl = supabaseUrl + "/functions/v1/meter-usage";
|
||||
|
||||
Map<String, Object> requestBody = new HashMap<>();
|
||||
requestBody.put("user_id", supabaseId);
|
||||
requestBody.put("credits", overageCredits);
|
||||
requestBody.put("idempotency_key", idempotencyKey);
|
||||
String requestJson = objectMapper.writeValueAsString(requestBody);
|
||||
|
||||
HttpRequest request =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(URI.create(meterUsageUrl))
|
||||
.header("Content-Type", "application/json")
|
||||
.header(
|
||||
"Authorization",
|
||||
"Bearer " + supabaseConfig.getEdgeFunctionSecret())
|
||||
.POST(HttpRequest.BodyPublishers.ofString(requestJson))
|
||||
.timeout(Duration.ofSeconds(30))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response =
|
||||
httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() == 200) {
|
||||
log.info(
|
||||
"[USAGE-BILLING] reported {} overage credits for user {}",
|
||||
overageCredits,
|
||||
supabaseId);
|
||||
return true;
|
||||
}
|
||||
log.error(
|
||||
"[USAGE-BILLING] failed to report usage HTTP {}: {}",
|
||||
response.statusCode(),
|
||||
response.body());
|
||||
return false;
|
||||
} catch (java.io.IOException e) {
|
||||
log.error(
|
||||
"[USAGE-BILLING] network error reporting usage for {}: {}",
|
||||
supabaseId,
|
||||
e.getMessage(),
|
||||
e);
|
||||
return false;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.error(
|
||||
"[USAGE-BILLING] interrupted reporting usage for {}: {}",
|
||||
supabaseId,
|
||||
e.getMessage());
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"[USAGE-BILLING] unexpected error reporting usage for {}: {}",
|
||||
supabaseId,
|
||||
e.getMessage(),
|
||||
e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotency key derived from the user + amount + operation. Stable across retries of the same
|
||||
* logical operation. Caller supplies a stable {@code operationId} (e.g. the request UUID
|
||||
* captured at the start of the credit-consume path) so a retry produces the same key.
|
||||
*/
|
||||
public String generateIdempotencyKey(
|
||||
String supabaseId, int overageCredits, String operationId) {
|
||||
return String.format("usage_%s_%d_%s", supabaseId, overageCredits, operationId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package stirling.software.saas.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.saas.interceptor.UnifiedCreditInterceptor;
|
||||
|
||||
@Configuration
|
||||
@Profile("saas")
|
||||
@RequiredArgsConstructor
|
||||
public class CreditInterceptorConfig implements WebMvcConfigurer {
|
||||
|
||||
private final UnifiedCreditInterceptor unifiedCreditInterceptor;
|
||||
private final CreditsProperties creditsProperties;
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
if (creditsProperties.isEnabled()) {
|
||||
registry.addInterceptor(unifiedCreditInterceptor)
|
||||
.addPathPatterns("/api/**")
|
||||
.excludePathPatterns(
|
||||
"/api/v1/credits/**", "/api/v1/config/**", "/api/v1/info/**");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package stirling.software.saas.config;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Component
|
||||
@Profile("saas")
|
||||
@ConfigurationProperties(prefix = "credits")
|
||||
public class CreditsProperties {
|
||||
|
||||
/** Whether the credits system is enabled */
|
||||
private boolean enabled = true;
|
||||
|
||||
/** Credit allocations per billing cycle (monthly) */
|
||||
private CycleAllocations cycle = new CycleAllocations();
|
||||
|
||||
/** Reset configuration */
|
||||
private Reset reset = new Reset();
|
||||
|
||||
/** Error tracking configuration */
|
||||
private Errors errors = new Errors();
|
||||
|
||||
/** Cache configuration */
|
||||
private Cache cache = new Cache();
|
||||
|
||||
@Data
|
||||
public static class CycleAllocations {
|
||||
/** Whether admin role has unlimited credits */
|
||||
private boolean adminUnlimited = true;
|
||||
|
||||
/** Credit allocations per billing cycle (monthly) per role */
|
||||
private Map<String, Integer> allocations =
|
||||
Map.of(
|
||||
"ROLE_ADMIN", 1000,
|
||||
"ROLE_PRO_USER", 500,
|
||||
"ROLE_USER", 50,
|
||||
"ROLE_LIMITED_API_USER", 10,
|
||||
"ROLE_EXTRA_LIMITED_API_USER", 20,
|
||||
"ROLE_WEB_ONLY_USER", 0,
|
||||
"ROLE_DEMO_USER", 100);
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Reset {
|
||||
/** Cron expression for monthly reset (default: 1st of month 02:00 UTC) */
|
||||
private String cron = "0 0 2 1 * *";
|
||||
|
||||
/** Time zone for the reset schedule */
|
||||
private String zone = "UTC";
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Errors {
|
||||
/** How long error counts are tracked (in minutes) */
|
||||
private int ttlMinutes = 60;
|
||||
|
||||
/** Number of free processing errors before charging */
|
||||
private int freeProcessingErrors = 2;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Cache {
|
||||
/** Enable local Caffeine cache for error counts */
|
||||
private boolean localEnabled = true;
|
||||
|
||||
/** Enable Redis cache for multi-instance deployments */
|
||||
private boolean redisEnabled = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package stirling.software.saas.config;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.jdbc.DatabaseDriver;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
|
||||
import com.zaxxer.hikari.HikariConfig;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/** SaaS-profile Postgres Hikari DataSource. {@code @Primary} so it shadows the OSS H2 default. */
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@Profile("saas")
|
||||
public class SaasDataSourceConfig {
|
||||
|
||||
@Value("${spring.datasource.url:}")
|
||||
private String url;
|
||||
|
||||
@Value("${spring.datasource.username:postgres}")
|
||||
private String username;
|
||||
|
||||
@Value("${spring.datasource.password:}")
|
||||
private String password;
|
||||
|
||||
@Value("${spring.datasource.hikari.maximum-pool-size:20}")
|
||||
private int maximumPoolSize;
|
||||
|
||||
@Value("${spring.datasource.hikari.minimum-idle:5}")
|
||||
private int minimumIdle;
|
||||
|
||||
@Value("${spring.datasource.hikari.idle-timeout:600000}")
|
||||
private long idleTimeout;
|
||||
|
||||
@Value("${spring.datasource.hikari.max-lifetime:1800000}")
|
||||
private long maxLifetime;
|
||||
|
||||
@Value("${spring.datasource.hikari.keepalive-time:300000}")
|
||||
private long keepaliveTime;
|
||||
|
||||
@Value("${spring.datasource.hikari.data-source-properties.ApplicationName:StirlingPDF-SaaS}")
|
||||
private String applicationName;
|
||||
|
||||
// search_path so native SQL hits stirling_pdf, not the postgres-default public.
|
||||
@Value(
|
||||
"${spring.datasource.hikari.connection-init-sql:SET search_path TO stirling_pdf, auth, public}")
|
||||
private String connectionInitSql;
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
@Qualifier("dataSource")
|
||||
public DataSource saasDataSource() {
|
||||
if (url == null || url.isBlank()) {
|
||||
throw new IllegalStateException(
|
||||
"spring.datasource.url is required when the saas profile is active. "
|
||||
+ "Set it via application-{profile}.properties (e.g. application-dev.properties) "
|
||||
+ "or via the SPRING_DATASOURCE_URL env var.");
|
||||
}
|
||||
|
||||
HikariConfig config = new HikariConfig();
|
||||
config.setJdbcUrl(addApplicationName(url, applicationName));
|
||||
config.setUsername(username);
|
||||
config.setPassword(password);
|
||||
config.setDriverClassName(DatabaseDriver.POSTGRESQL.getDriverClassName());
|
||||
config.setMaximumPoolSize(maximumPoolSize);
|
||||
config.setMinimumIdle(minimumIdle);
|
||||
config.setIdleTimeout(idleTimeout);
|
||||
config.setMaxLifetime(maxLifetime);
|
||||
config.setKeepaliveTime(keepaliveTime);
|
||||
if (connectionInitSql != null && !connectionInitSql.isBlank()) {
|
||||
config.setConnectionInitSql(connectionInitSql);
|
||||
}
|
||||
|
||||
log.info(
|
||||
"Saas DataSource configured (ApplicationName: '{}', max pool: {}, min idle: {}, search_path init: '{}')",
|
||||
applicationName,
|
||||
maximumPoolSize,
|
||||
minimumIdle,
|
||||
connectionInitSql);
|
||||
|
||||
return new HikariDataSource(config);
|
||||
}
|
||||
|
||||
private static String addApplicationName(String jdbcUrl, String appName) {
|
||||
if (jdbcUrl == null
|
||||
|| appName == null
|
||||
|| jdbcUrl.toLowerCase().contains("applicationname=")) {
|
||||
return jdbcUrl;
|
||||
}
|
||||
String separator = jdbcUrl.contains("?") ? "&" : "?";
|
||||
return jdbcUrl + separator + "ApplicationName=" + appName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package stirling.software.saas.config;
|
||||
|
||||
import org.springframework.boot.persistence.autoconfigure.EntityScan;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
|
||||
/** Registers the {@code :saas} module's entities and repositories with Spring Data JPA. */
|
||||
@Configuration
|
||||
@Profile("saas")
|
||||
@EnableJpaRepositories(
|
||||
basePackages = {
|
||||
"stirling.software.saas.repository",
|
||||
"stirling.software.saas.billing.repository",
|
||||
"stirling.software.saas.ai.repository"
|
||||
})
|
||||
@EntityScan({
|
||||
"stirling.software.saas.model",
|
||||
"stirling.software.saas.billing.model",
|
||||
"stirling.software.saas.ai.model"
|
||||
})
|
||||
public class SaasJpaConfig {}
|
||||
@@ -0,0 +1,26 @@
|
||||
package stirling.software.saas.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
|
||||
/** Saas mode is unconditionally ENTERPRISE (every tenant is a paying Stripe customer). */
|
||||
@Configuration
|
||||
@Profile("saas")
|
||||
public class SaasLicenseOverride {
|
||||
|
||||
@Bean(name = "runningProOrHigher")
|
||||
public boolean runningProOrHigherSaas() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Bean(name = "license")
|
||||
public String licenseTypeSaas() {
|
||||
return "ENTERPRISE";
|
||||
}
|
||||
|
||||
@Bean(name = "runningEE")
|
||||
public boolean runningEnterpriseSaas() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package stirling.software.saas.config;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
/** {@link RestTemplate} for talking to Supabase Edge Functions, with bounded timeouts. */
|
||||
@Configuration
|
||||
@Profile("saas")
|
||||
public class SaasRestTemplateConfig {
|
||||
|
||||
@Bean
|
||||
public RestTemplate saasRestTemplate() {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout((int) Duration.ofSeconds(10).toMillis());
|
||||
factory.setReadTimeout((int) Duration.ofSeconds(30).toMillis());
|
||||
return new RestTemplate(factory);
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package stirling.software.saas.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/** Supabase configuration ({@code app.supabase.*}) for saas mode. */
|
||||
@Slf4j
|
||||
@Data
|
||||
@Component
|
||||
@Profile("saas")
|
||||
@ConfigurationProperties(prefix = "app.supabase")
|
||||
public class SupabaseConfigurationProperties {
|
||||
|
||||
/** Supabase project issuer URL, e.g. {@code https://abcd1234.supabase.co/auth/v1}. */
|
||||
private String issuer;
|
||||
|
||||
/** Optional expected JWT audience. Empty string disables aud validation. */
|
||||
private String expectedAud;
|
||||
|
||||
/** Clock skew tolerance for JWT exp validation. */
|
||||
private long clockSkewSeconds = 120L;
|
||||
|
||||
/** Edge Function URL for server→Supabase admin calls (optional). */
|
||||
private String edgeFunctionUrl;
|
||||
|
||||
/** Edge Function shared secret for authenticated server→Supabase admin calls. */
|
||||
private String edgeFunctionSecret;
|
||||
|
||||
/** True when JWT verification can run (issuer URL set so JWKS can be fetched). */
|
||||
public boolean isJwtConfigured() {
|
||||
return issuer != null && !issuer.isBlank();
|
||||
}
|
||||
|
||||
/** True when server→Supabase Edge Function calls can run (URL + shared secret set). */
|
||||
public boolean isEdgeFunctionConfigured() {
|
||||
return edgeFunctionUrl != null
|
||||
&& !edgeFunctionUrl.isBlank()
|
||||
&& edgeFunctionSecret != null
|
||||
&& !edgeFunctionSecret.isBlank();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package stirling.software.saas.controller;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
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;
|
||||
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.security.EnhancedJwtAuthenticationToken;
|
||||
import stirling.software.saas.service.CreditService;
|
||||
import stirling.software.saas.service.CreditService.CreditSummary;
|
||||
import stirling.software.saas.util.LogRedactionUtils;
|
||||
|
||||
@RestController
|
||||
@Profile("saas")
|
||||
@RequestMapping("/api/v1/credits")
|
||||
@Tag(name = "Credit Management", description = "Endpoints for managing user API credits")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CreditController {
|
||||
|
||||
private final CreditService creditService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(
|
||||
summary = "Get user credit information",
|
||||
description =
|
||||
"Retrieve current credit balance and usage statistics for the authenticated user")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Credit information retrieved successfully",
|
||||
content = @Content(schema = @Schema(implementation = CreditSummary.class)))
|
||||
public ResponseEntity<CreditSummary> getUserCredits(Authentication authentication) {
|
||||
return ResponseEntity.ok(getCreditSummaryForAuthentication(authentication));
|
||||
}
|
||||
|
||||
@PostMapping("/purchase")
|
||||
@Hidden
|
||||
@Operation(
|
||||
summary = "Purchase additional credits",
|
||||
description = "Add bought credits to user account (admin only)")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@ApiResponse(responseCode = "200", description = "Credits purchased successfully")
|
||||
public ResponseEntity<Map<String, Object>> purchaseCredits(
|
||||
@RequestParam("username") String username, @RequestParam("credits") int credits) {
|
||||
|
||||
if (credits <= 0) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Credits must be positive"));
|
||||
}
|
||||
|
||||
try {
|
||||
creditService.addBoughtCredits(username, credits);
|
||||
log.info("Admin added {} credits to user: {}", credits, username);
|
||||
return ResponseEntity.ok(Map.of("success", true, "creditsAdded", credits));
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("purchaseCredits rejected: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid request"));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/purchase-by-supabase-id")
|
||||
@Hidden
|
||||
@Operation(
|
||||
summary = "Purchase additional credits by Supabase ID",
|
||||
description = "Add bought credits to user account using Supabase ID (admin only)")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@ApiResponse(responseCode = "200", description = "Credits purchased successfully")
|
||||
public ResponseEntity<Map<String, Object>> purchaseCreditsBySupabaseId(
|
||||
@RequestParam("supabaseId") String supabaseId, @RequestParam("credits") int credits) {
|
||||
|
||||
if (credits <= 0) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Credits must be positive"));
|
||||
}
|
||||
|
||||
try {
|
||||
creditService.addBoughtCreditsBySupabaseId(supabaseId, credits);
|
||||
log.info(
|
||||
"Admin added {} credits to user with Supabase ID: {}",
|
||||
credits,
|
||||
LogRedactionUtils.redactSupabaseId(supabaseId));
|
||||
return ResponseEntity.ok(Map.of("success", true, "creditsAdded", credits));
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("purchaseCreditsBySupabaseId rejected: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid request"));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/user/{username}")
|
||||
@Hidden
|
||||
@Operation(
|
||||
summary = "Get credit information for specific user",
|
||||
description = "Retrieve credit information for a specific user (admin only)")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "User credit information retrieved successfully",
|
||||
content = @Content(schema = @Schema(implementation = CreditSummary.class)))
|
||||
public ResponseEntity<CreditSummary> getUserCreditsAdmin(
|
||||
@PathVariable("username") String username) {
|
||||
CreditSummary summary = creditService.getCreditSummary(username);
|
||||
return ResponseEntity.ok(summary);
|
||||
}
|
||||
|
||||
@GetMapping("/user-by-supabase-id/{supabaseId}")
|
||||
@Hidden
|
||||
@Operation(
|
||||
summary = "Get credit information for specific user by Supabase ID",
|
||||
description =
|
||||
"Retrieve credit information for a specific user using Supabase ID (admin only)")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "User credit information retrieved successfully",
|
||||
content = @Content(schema = @Schema(implementation = CreditSummary.class)))
|
||||
public ResponseEntity<CreditSummary> getUserCreditsAdminBySupabaseId(
|
||||
@PathVariable("supabaseId") String supabaseId) {
|
||||
CreditSummary summary = creditService.getCreditSummaryBySupabaseId(supabaseId);
|
||||
return ResponseEntity.ok(summary);
|
||||
}
|
||||
|
||||
@PostMapping("/reset-cycle")
|
||||
@Hidden
|
||||
@Operation(
|
||||
summary = "Reset cycle credits for all users",
|
||||
description = "Manually trigger cycle credit reset for all users (admin only)")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@ApiResponse(responseCode = "200", description = "Cycle credits reset successfully")
|
||||
public ResponseEntity<String> resetCycleCredits() {
|
||||
creditService.resetCycleCreditsForAllUsers();
|
||||
log.info("Manual cycle credit reset triggered by admin");
|
||||
return ResponseEntity.ok("Cycle credits reset successfully for all users");
|
||||
}
|
||||
|
||||
@PostMapping("/set-bought-credits")
|
||||
@Hidden
|
||||
@Operation(
|
||||
summary = "Set user's bought credits to a specific amount",
|
||||
description =
|
||||
"Hard set the bought credits balance for a specific user to an exact amount (admin only)")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@ApiResponse(responseCode = "200", description = "Bought credits set successfully")
|
||||
public ResponseEntity<Map<String, Object>> setBoughtCredits(
|
||||
@RequestParam("username") String username, @RequestParam("credits") int credits) {
|
||||
|
||||
if (credits < 0) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Credits cannot be negative"));
|
||||
}
|
||||
|
||||
try {
|
||||
creditService.setBoughtCredits(username, credits);
|
||||
log.info("Admin set bought credits to {} for user: {}", credits, username);
|
||||
return ResponseEntity.ok(Map.of("success", true, "boughtCredits", credits));
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("setBoughtCredits rejected: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid request"));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/set-bought-credits-by-supabase-id")
|
||||
@Hidden
|
||||
@Operation(
|
||||
summary = "Set user's bought credits to a specific amount by Supabase ID",
|
||||
description =
|
||||
"Hard set the bought credits balance for a specific user using Supabase ID to an exact amount (admin only)")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@ApiResponse(responseCode = "200", description = "Bought credits set successfully")
|
||||
public ResponseEntity<Map<String, Object>> setBoughtCreditsBySupabaseId(
|
||||
@RequestParam("supabaseId") String supabaseId, @RequestParam("credits") int credits) {
|
||||
|
||||
if (credits < 0) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Credits cannot be negative"));
|
||||
}
|
||||
|
||||
try {
|
||||
creditService.setBoughtCreditsBySupabaseId(supabaseId, credits);
|
||||
log.info(
|
||||
"Admin set bought credits to {} for user with Supabase ID: {}",
|
||||
credits,
|
||||
LogRedactionUtils.redactSupabaseId(supabaseId));
|
||||
return ResponseEntity.ok(Map.of("success", true, "boughtCredits", credits));
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("setBoughtCreditsBySupabaseId rejected: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid request"));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/set-cycle-credits")
|
||||
@Hidden
|
||||
@Operation(
|
||||
summary = "Set user's cycle credits remaining to a specific amount",
|
||||
description =
|
||||
"Hard set the cycle credits remaining balance for a specific user to an exact amount (admin only)")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@ApiResponse(responseCode = "200", description = "Cycle credits set successfully")
|
||||
public ResponseEntity<Map<String, Object>> setCycleCredits(
|
||||
@RequestParam("username") String username, @RequestParam("credits") int credits) {
|
||||
|
||||
if (credits < 0) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Credits cannot be negative"));
|
||||
}
|
||||
|
||||
try {
|
||||
creditService.setCycleCredits(username, credits);
|
||||
log.info("Admin set cycle credits to {} for user: {}", credits, username);
|
||||
return ResponseEntity.ok(Map.of("success", true, "cycleCredits", credits));
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("setCycleCredits rejected: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid request"));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/set-cycle-credits-by-supabase-id")
|
||||
@Hidden
|
||||
@Operation(
|
||||
summary = "Set user's cycle credits remaining to a specific amount by Supabase ID",
|
||||
description =
|
||||
"Hard set the cycle credits remaining balance for a specific user using Supabase ID to an exact amount (admin only)")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@ApiResponse(responseCode = "200", description = "Cycle credits set successfully")
|
||||
public ResponseEntity<Map<String, Object>> setCycleCreditsBySupabaseId(
|
||||
@RequestParam("supabaseId") String supabaseId, @RequestParam("credits") int credits) {
|
||||
|
||||
if (credits < 0) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Credits cannot be negative"));
|
||||
}
|
||||
|
||||
try {
|
||||
creditService.setCycleCreditsBySupabaseId(supabaseId, credits);
|
||||
log.info(
|
||||
"Admin set cycle credits to {} for user with Supabase ID: {}",
|
||||
credits,
|
||||
LogRedactionUtils.redactSupabaseId(supabaseId));
|
||||
return ResponseEntity.ok(Map.of("success", true, "cycleCredits", credits));
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("setCycleCreditsBySupabaseId rejected: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid request"));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/usage")
|
||||
@Operation(
|
||||
summary = "Get credit usage summary",
|
||||
description = "Get overview of credit usage (for authenticated user or admin view)")
|
||||
public ResponseEntity<UsageSummary> getCreditUsage(Authentication authentication) {
|
||||
CreditSummary summary = getCreditSummaryForAuthentication(authentication);
|
||||
|
||||
// For unlimited users, don't show meaningless huge usage numbers
|
||||
int cycleCreditsUsed =
|
||||
summary.unlimited
|
||||
? 0
|
||||
: (summary.cycleCreditsAllocated - summary.cycleCreditsRemaining);
|
||||
|
||||
UsageSummary usage =
|
||||
new UsageSummary(
|
||||
cycleCreditsUsed,
|
||||
summary.totalBoughtCredits - summary.boughtCreditsRemaining,
|
||||
summary.totalAvailableCredits,
|
||||
summary.unlimited);
|
||||
|
||||
return ResponseEntity.ok(usage);
|
||||
}
|
||||
|
||||
/** Resolves the current authentication to a credit summary, handling JWT and API-key auth. */
|
||||
private CreditSummary getCreditSummaryForAuthentication(Authentication authentication) {
|
||||
if (authentication instanceof EnhancedJwtAuthenticationToken enhancedJwt) {
|
||||
return creditService.getCreditSummaryBySupabaseId(enhancedJwt.getSupabaseId());
|
||||
}
|
||||
if (authentication instanceof ApiKeyAuthenticationToken apiKeyToken) {
|
||||
String apiKey = (String) apiKeyToken.getCredentials();
|
||||
// Principal is the resolved User entity (per SupabaseAuthenticationFilter). Prefer the
|
||||
// linked Supabase ID; fall back to API-key-keyed credits if there's no supabase link
|
||||
// or no User row (e.g. legacy API-key-only deployments).
|
||||
if (apiKeyToken.getPrincipal() instanceof User user && user.getSupabaseId() != null) {
|
||||
return creditService.getCreditSummaryBySupabaseId(user.getSupabaseId().toString());
|
||||
}
|
||||
return creditService.getCreditSummaryByApiKey(apiKey);
|
||||
}
|
||||
return creditService.getCreditSummaryBySupabaseId(authentication.getName());
|
||||
}
|
||||
|
||||
public static class UsageSummary {
|
||||
public final int cycleCreditsUsed;
|
||||
public final int boughtCreditsUsed;
|
||||
public final int creditsRemaining;
|
||||
public final boolean unlimited;
|
||||
|
||||
public UsageSummary(
|
||||
int cycleCreditsUsed,
|
||||
int boughtCreditsUsed,
|
||||
int creditsRemaining,
|
||||
boolean unlimited) {
|
||||
this.cycleCreditsUsed = cycleCreditsUsed;
|
||||
this.boughtCreditsUsed = boughtCreditsUsed;
|
||||
this.creditsRemaining = creditsRemaining;
|
||||
this.unlimited = unlimited;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
package stirling.software.saas.controller;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.transaction.interceptor.TransactionAspectSupport;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import jakarta.transaction.Transactional;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.api.TeamApi;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.saas.model.TeamInvitation;
|
||||
import stirling.software.saas.model.TeamMembership;
|
||||
import stirling.software.saas.repository.TeamInvitationRepository;
|
||||
import stirling.software.saas.repository.TeamMembershipRepository;
|
||||
import stirling.software.saas.security.TeamSecurityExpressions;
|
||||
import stirling.software.saas.service.SaasTeamExtensionService;
|
||||
import stirling.software.saas.service.SaasTeamService;
|
||||
|
||||
/** SaaS-only team endpoints: invitations, personal teams, billing-aware lookups. */
|
||||
@TeamApi
|
||||
@Profile("saas")
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class SaasTeamController {
|
||||
|
||||
private final TeamRepository teamRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final TeamService teamService;
|
||||
private final SaasTeamService saasTeamService;
|
||||
private final SaasTeamExtensionService saasTeamExtensionService;
|
||||
private final TeamMembershipRepository membershipRepository;
|
||||
private final TeamInvitationRepository invitationRepository;
|
||||
private final UserService userService;
|
||||
private final TeamSecurityExpressions teamSecurityExpressions;
|
||||
|
||||
// ========== NEW TEAM INVITATION ENDPOINTS ==========
|
||||
|
||||
/** Invite user to team (team leader only) */
|
||||
@PostMapping("/invite")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<?> inviteUser(@RequestBody InviteUserRequest request) {
|
||||
try {
|
||||
User currentUser = getCurrentUser();
|
||||
|
||||
// Verify user is team leader before proceeding
|
||||
// Note: Cannot use @PreAuthorize with #request.teamId as @RequestBody is not yet
|
||||
// deserialized at annotation evaluation time
|
||||
if (!teamSecurityExpressions.isTeamLeader(request.teamId)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(Map.of("error", "Only team leaders can invite members"));
|
||||
}
|
||||
|
||||
TeamInvitation invitation =
|
||||
saasTeamService.inviteUserToTeam(request.teamId, request.email, currentUser);
|
||||
return ResponseEntity.ok(toInvitationDTO(invitation));
|
||||
} catch (SecurityException | IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("Error inviting user", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to send invitation"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Accept team invitation */
|
||||
@PostMapping("/invitations/{token}/accept")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
@Transactional
|
||||
public ResponseEntity<?> acceptInvitation(@PathVariable String token) {
|
||||
try {
|
||||
User currentUser = getCurrentUser();
|
||||
saasTeamService.acceptInvitationAndGrantRole(token, currentUser);
|
||||
return ResponseEntity.ok(Map.of("message", "Invitation accepted", "success", true));
|
||||
} catch (SecurityException | IllegalArgumentException | IllegalStateException e) {
|
||||
// Caller-fixable failures (already-accepted, expired, email mismatch, etc.).
|
||||
// Mark the transaction for rollback so anything the service did is reversed even
|
||||
// though we don't propagate the exception out of the @Transactional method.
|
||||
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("Error accepting invitation", e);
|
||||
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to accept invitation"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject team invitation */
|
||||
@PostMapping("/invitations/{token}/reject")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<?> rejectInvitation(@PathVariable String token) {
|
||||
try {
|
||||
User currentUser = getCurrentUser();
|
||||
TeamInvitation invitation =
|
||||
invitationRepository
|
||||
.findByInvitationToken(token)
|
||||
.orElseThrow(
|
||||
() -> new IllegalArgumentException("Invitation not found"));
|
||||
|
||||
// Security check: verify invitation belongs to current user
|
||||
if (!invitation.getInviteeEmail().equalsIgnoreCase(currentUser.getEmail())
|
||||
&& !invitation.getInviteeEmail().equalsIgnoreCase(currentUser.getUsername())) {
|
||||
throw new SecurityException(
|
||||
"You cannot reject an invitation that was not sent to you");
|
||||
}
|
||||
|
||||
// Only allow rejecting pending invitations
|
||||
if (invitation.getStatus()
|
||||
!= stirling.software.common.model.enumeration.InvitationStatus.PENDING) {
|
||||
throw new IllegalStateException("Can only reject pending invitations");
|
||||
}
|
||||
|
||||
invitation.setStatus(
|
||||
stirling.software.common.model.enumeration.InvitationStatus.REJECTED);
|
||||
invitationRepository.save(invitation);
|
||||
|
||||
return ResponseEntity.ok(Map.of("message", "Invitation rejected"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", e.getMessage()));
|
||||
} catch (SecurityException | IllegalStateException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(Map.of("error", e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("Error rejecting invitation", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to reject invitation"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancel team invitation (team leader only) */
|
||||
@DeleteMapping("/invitations/{invitationId}")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<?> cancelInvitation(@PathVariable Long invitationId) {
|
||||
try {
|
||||
User currentUser = getCurrentUser();
|
||||
TeamInvitation invitation =
|
||||
invitationRepository
|
||||
.findById(invitationId)
|
||||
.orElseThrow(
|
||||
() -> new IllegalArgumentException("Invitation not found"));
|
||||
|
||||
// Security check: verify current user is team leader
|
||||
Team team = invitation.getTeam();
|
||||
TeamMembership membership =
|
||||
membershipRepository
|
||||
.findByTeamIdAndUserId(team.getId(), currentUser.getId())
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new SecurityException(
|
||||
"You are not a member of this team"));
|
||||
|
||||
if (!membership.isLeader()) {
|
||||
throw new SecurityException("Only team leaders can cancel invitations");
|
||||
}
|
||||
|
||||
// Only allow canceling pending invitations
|
||||
if (invitation.getStatus()
|
||||
!= stirling.software.common.model.enumeration.InvitationStatus.PENDING) {
|
||||
throw new IllegalStateException("Can only cancel pending invitations");
|
||||
}
|
||||
|
||||
invitation.setStatus(
|
||||
stirling.software.common.model.enumeration.InvitationStatus.CANCELLED);
|
||||
invitationRepository.save(invitation);
|
||||
|
||||
return ResponseEntity.ok(Map.of("message", "Invitation cancelled"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", e.getMessage()));
|
||||
} catch (SecurityException | IllegalStateException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(Map.of("error", e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("Error cancelling invitation", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to cancel invitation"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Get pending invitations for current user */
|
||||
@GetMapping("/invitations/pending")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<?> getPendingInvitations() {
|
||||
try {
|
||||
User currentUser = getCurrentUser();
|
||||
List<TeamInvitation> invitations =
|
||||
invitationRepository.findPendingInvitationsByEmail(
|
||||
currentUser.getEmail(), LocalDateTime.now());
|
||||
|
||||
List<InvitationDTO> dtos =
|
||||
invitations.stream().map(this::toInvitationDTO).collect(Collectors.toList());
|
||||
|
||||
return ResponseEntity.ok(dtos);
|
||||
} catch (Exception e) {
|
||||
log.error("Error fetching pending invitations", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to fetch invitations"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Get all teams for current user */
|
||||
@GetMapping("/my")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<?> getMyTeams() {
|
||||
try {
|
||||
User currentUser = getCurrentUser();
|
||||
List<TeamMembership> memberships =
|
||||
membershipRepository.findByUserId(currentUser.getId());
|
||||
|
||||
// Migrate users from old Default team system to personal teams
|
||||
boolean needsPersonalTeam = false;
|
||||
|
||||
if (memberships.isEmpty()) {
|
||||
// Case 1: User has no team memberships at all
|
||||
needsPersonalTeam = true;
|
||||
} else {
|
||||
// Case 2: Check if user is only on Default/Internal team (legacy users)
|
||||
boolean hasPersonalTeam =
|
||||
memberships.stream()
|
||||
.anyMatch(m -> saasTeamExtensionService.isPersonal(m.getTeam()));
|
||||
|
||||
boolean onlyOnSystemTeams =
|
||||
memberships.stream()
|
||||
.allMatch(
|
||||
m -> {
|
||||
String teamName = m.getTeam().getName();
|
||||
return "Default".equals(teamName)
|
||||
|| "Internal".equals(teamName);
|
||||
});
|
||||
|
||||
if (!hasPersonalTeam && onlyOnSystemTeams) {
|
||||
needsPersonalTeam = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (needsPersonalTeam) {
|
||||
try {
|
||||
saasTeamService.createPersonalTeam(currentUser);
|
||||
// Fetch memberships again after creating personal team
|
||||
memberships = membershipRepository.findByUserId(currentUser.getId());
|
||||
log.info("Created personal team for user {}", currentUser.getId());
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"Failed to create personal team for user {}: {}",
|
||||
currentUser.getId(),
|
||||
e.getMessage(),
|
||||
e);
|
||||
// Rethrow to let outer catch block return proper error response
|
||||
throw new IllegalStateException(
|
||||
"Failed to initialize personal team for user", e);
|
||||
}
|
||||
}
|
||||
|
||||
List<TeamDetailsDTO> dtos =
|
||||
memberships.stream()
|
||||
.map(
|
||||
m ->
|
||||
toTeamDetailsDTO(
|
||||
m.getTeam(),
|
||||
m.getRole()
|
||||
== stirling.software.common.model
|
||||
.enumeration.TeamRole.LEADER))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
log.info("[TEAM-FETCH] Returning {} teams to client", dtos.size());
|
||||
return ResponseEntity.ok(dtos);
|
||||
} catch (Exception e) {
|
||||
log.error("[TEAM-FETCH] Error fetching user teams: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to fetch teams"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Get team members (team members only) */
|
||||
@GetMapping("/{teamId}/members")
|
||||
@PreAuthorize("@teamSecurity.isTeamMember(#teamId)")
|
||||
public ResponseEntity<?> getTeamMembers(@PathVariable Long teamId) {
|
||||
try {
|
||||
List<TeamMembership> memberships = membershipRepository.findByTeamId(teamId);
|
||||
List<TeamMemberDTO> dtos =
|
||||
memberships.stream().map(this::toTeamMemberDTO).collect(Collectors.toList());
|
||||
return ResponseEntity.ok(dtos);
|
||||
} catch (Exception e) {
|
||||
log.error("Error fetching team members", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to fetch team members"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Get team invitations (team leaders only) */
|
||||
@GetMapping("/{teamId}/invitations")
|
||||
@PreAuthorize("@teamSecurity.isTeamLeader(#teamId)")
|
||||
public ResponseEntity<?> getTeamInvitations(@PathVariable Long teamId) {
|
||||
try {
|
||||
List<TeamInvitation> invitations = invitationRepository.findByTeamId(teamId);
|
||||
List<InvitationDTO> dtos =
|
||||
invitations.stream().map(this::toInvitationDTO).collect(Collectors.toList());
|
||||
return ResponseEntity.ok(dtos);
|
||||
} catch (Exception e) {
|
||||
log.error("Error fetching team invitations", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to fetch invitations"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove team member (team leader only) */
|
||||
@DeleteMapping("/{teamId}/members/{memberId}")
|
||||
@PreAuthorize("@teamSecurity.isTeamLeader(#teamId)")
|
||||
@Transactional
|
||||
public ResponseEntity<?> removeTeamMember(
|
||||
@PathVariable Long teamId, @PathVariable Long memberId) {
|
||||
try {
|
||||
User currentUser = getCurrentUser();
|
||||
|
||||
// Get the user being removed before removing them
|
||||
User userToRemove =
|
||||
userRepository
|
||||
.findById(memberId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Member not found"));
|
||||
Team oldTeam = teamRepository.findById(teamId).orElseThrow();
|
||||
|
||||
// Remove the user from the team
|
||||
saasTeamService.removeTeamMember(teamId, memberId, currentUser);
|
||||
|
||||
// Revoke PRO role when removing from any non-personal team
|
||||
String currentRole = userToRemove.getRolesAsString();
|
||||
if (stirling.software.common.model.enumeration.Role.PRO_USER
|
||||
.getRoleId()
|
||||
.equals(currentRole)) {
|
||||
log.info(
|
||||
"Revoking ROLE_PRO_USER from user {} removed from team {}",
|
||||
userToRemove.getUsername(),
|
||||
oldTeam.getName());
|
||||
userService.changeRole(
|
||||
userToRemove,
|
||||
stirling.software.common.model.enumeration.Role.USER.getRoleId());
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(Map.of("message", "Member removed successfully"));
|
||||
} catch (SecurityException | IllegalArgumentException | IllegalStateException e) {
|
||||
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("Error removing team member", e);
|
||||
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to remove member"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Leave team (self-removal) */
|
||||
@PostMapping("/{teamId}/leave")
|
||||
@PreAuthorize("@teamSecurity.isTeamMember(#teamId)")
|
||||
@Transactional
|
||||
public ResponseEntity<?> leaveTeam(@PathVariable Long teamId) {
|
||||
try {
|
||||
User currentUser = getCurrentUser();
|
||||
|
||||
// Get the team before leaving
|
||||
Team oldTeam = teamRepository.findById(teamId).orElseThrow();
|
||||
|
||||
// Leave the team
|
||||
saasTeamService.leaveTeam(teamId, currentUser);
|
||||
|
||||
// Revoke PRO role when leaving any non-personal team
|
||||
String currentRole = currentUser.getRolesAsString();
|
||||
if (stirling.software.common.model.enumeration.Role.PRO_USER
|
||||
.getRoleId()
|
||||
.equals(currentRole)) {
|
||||
log.info(
|
||||
"Revoking ROLE_PRO_USER from user {} who left team {}",
|
||||
currentUser.getUsername(),
|
||||
oldTeam.getName());
|
||||
userService.changeRole(
|
||||
currentUser,
|
||||
stirling.software.common.model.enumeration.Role.USER.getRoleId());
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(Map.of("message", "Left team successfully"));
|
||||
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("Error leaving team", e);
|
||||
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to leave team"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Rename team (team leader only) */
|
||||
@PostMapping("/{teamId}/rename")
|
||||
@PreAuthorize("@teamSecurity.isTeamLeader(#teamId)")
|
||||
public ResponseEntity<?> renameTeamByLeader(
|
||||
@PathVariable Long teamId, @RequestBody RenameTeamRequest request) {
|
||||
try {
|
||||
if (request.newName == null || request.newName.trim().isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Team name cannot be empty"));
|
||||
}
|
||||
|
||||
Team team =
|
||||
teamRepository
|
||||
.findById(teamId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Team not found"));
|
||||
|
||||
// Prevent renaming personal teams
|
||||
if (saasTeamExtensionService.isPersonal(team)) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot rename personal team"));
|
||||
}
|
||||
|
||||
// Prevent renaming the Internal team
|
||||
if (TeamService.INTERNAL_TEAM_NAME.equals(team.getName())) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot rename Internal team"));
|
||||
}
|
||||
|
||||
team.setName(request.newName.trim());
|
||||
teamRepository.save(team);
|
||||
|
||||
log.info(
|
||||
"Team {} renamed to {} by leader {}",
|
||||
teamId,
|
||||
request.newName,
|
||||
getCurrentUser().getUsername());
|
||||
|
||||
return ResponseEntity.ok(
|
||||
Map.of("message", "Team renamed successfully", "newName", team.getName()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("Error renaming team", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to rename team"));
|
||||
}
|
||||
}
|
||||
|
||||
// ========== HELPER METHODS ==========
|
||||
|
||||
private User getCurrentUser() {
|
||||
String username = userService.getCurrentUsername();
|
||||
return userService
|
||||
.findByUsername(username)
|
||||
.orElseThrow(() -> new SecurityException("User not found: " + username));
|
||||
}
|
||||
|
||||
private TeamMemberDTO toTeamMemberDTO(TeamMembership membership) {
|
||||
User user = membership.getUser();
|
||||
return new TeamMemberDTO(
|
||||
user.getId(),
|
||||
user.getUsername(),
|
||||
user.getEmail(),
|
||||
membership.getRole().name(),
|
||||
membership.getAcceptedAt());
|
||||
}
|
||||
|
||||
private TeamDetailsDTO toTeamDetailsDTO(Team team, boolean isLeader) {
|
||||
long memberCount = membershipRepository.countByTeamId(team.getId());
|
||||
int maxSeats = saasTeamExtensionService.getMaxSeats(team);
|
||||
return new TeamDetailsDTO(
|
||||
team.getId(),
|
||||
team.getName(),
|
||||
saasTeamExtensionService.getTeamType(team),
|
||||
saasTeamExtensionService.isPersonal(team),
|
||||
(int) memberCount,
|
||||
// seatCount and maxSeats now share the same backing field on the extension.
|
||||
maxSeats,
|
||||
saasTeamExtensionService.getSeatsUsed(team),
|
||||
maxSeats,
|
||||
isLeader);
|
||||
}
|
||||
|
||||
private InvitationDTO toInvitationDTO(TeamInvitation invitation) {
|
||||
return new InvitationDTO(
|
||||
invitation.getInvitationId(),
|
||||
invitation.getTeam().getName(),
|
||||
invitation.getInviter().getEmail(),
|
||||
invitation.getInviteeEmail(),
|
||||
invitation.getInvitationToken(),
|
||||
invitation.getStatus().name(),
|
||||
invitation.getExpiresAt());
|
||||
}
|
||||
|
||||
// ========== DTOs ==========
|
||||
|
||||
@Data
|
||||
public static class InviteUserRequest {
|
||||
private Long teamId;
|
||||
private String email;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class TeamMemberDTO {
|
||||
private final Long id;
|
||||
private final String username;
|
||||
private final String email;
|
||||
private final String role;
|
||||
private final LocalDateTime joinedAt;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class TeamDetailsDTO {
|
||||
private final Long teamId;
|
||||
private final String name;
|
||||
private final String teamType;
|
||||
private final Boolean isPersonal;
|
||||
private final Integer memberCount;
|
||||
private final Integer seatCount;
|
||||
private final Integer seatsUsed;
|
||||
private final Integer maxSeats;
|
||||
private final Boolean isLeader;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class InvitationDTO {
|
||||
private final Long invitationId;
|
||||
private final String teamName;
|
||||
private final String inviterEmail;
|
||||
private final String inviteeEmail;
|
||||
private final String invitationToken;
|
||||
private final String status;
|
||||
private final LocalDateTime expiresAt;
|
||||
}
|
||||
|
||||
// ========== BILLING/SUPABASE INTEGRATION ENDPOINTS ==========
|
||||
|
||||
/**
|
||||
* Update team seat allocation (called by Supabase webhooks)
|
||||
*
|
||||
* <p>Requires ADMIN_API_KEY authentication
|
||||
*/
|
||||
@PostMapping("/{teamId}/seats")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<?> updateTeamSeats(
|
||||
@PathVariable Long teamId, @RequestBody UpdateSeatsRequest request) {
|
||||
try {
|
||||
saasTeamService.updateTeamSeats(teamId, request.maxSeats);
|
||||
|
||||
Team team = teamRepository.findById(teamId).orElseThrow();
|
||||
int maxSeats = saasTeamExtensionService.getMaxSeats(team);
|
||||
int seatsUsed = saasTeamExtensionService.getSeatsUsed(team);
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"success",
|
||||
true,
|
||||
"teamId",
|
||||
team.getId(),
|
||||
"maxSeats",
|
||||
maxSeats,
|
||||
"seatsUsed",
|
||||
seatsUsed,
|
||||
"availableSeats",
|
||||
maxSeats - seatsUsed));
|
||||
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("Error updating team seats", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to update team seats"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's primary team by Supabase UUID (called by Supabase when creating subscriptions)
|
||||
*
|
||||
* <p>Requires ADMIN_API_KEY or service role authentication
|
||||
*
|
||||
* <p>Accepts Supabase auth user ID (UUID) and returns the user's primary team information.
|
||||
*/
|
||||
@GetMapping("/user/supabase/{supabaseUserId}/primary")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<?> getUserPrimaryTeamBySupabaseId(@PathVariable String supabaseUserId) {
|
||||
try {
|
||||
java.util.UUID uuid = java.util.UUID.fromString(supabaseUserId);
|
||||
User user =
|
||||
userRepository
|
||||
.findBySupabaseId(uuid)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
|
||||
Team primaryTeam = user.getTeam();
|
||||
if (primaryTeam == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "User has no primary team"));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"teamId", primaryTeam.getId(),
|
||||
"userId", user.getId(),
|
||||
"supabaseUserId", user.getSupabaseId().toString(),
|
||||
"isPersonal", saasTeamExtensionService.isPersonal(primaryTeam),
|
||||
"maxSeats", saasTeamExtensionService.getMaxSeats(primaryTeam)));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Invalid UUID format or user not found"));
|
||||
} catch (Exception e) {
|
||||
log.error("Error fetching user primary team", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to fetch primary team"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed team information (for billing dashboard)
|
||||
*
|
||||
* <p>Requires team membership or admin role
|
||||
*/
|
||||
@GetMapping("/{teamId}")
|
||||
@PreAuthorize("@teamSecurity.isTeamMember(#teamId) or hasRole('ADMIN')")
|
||||
public ResponseEntity<?> getTeamInfo(@PathVariable Long teamId) {
|
||||
try {
|
||||
Team team =
|
||||
teamRepository
|
||||
.findById(teamId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Team not found"));
|
||||
|
||||
List<TeamMembership> memberships = membershipRepository.findByTeamId(teamId);
|
||||
List<TeamMemberDTO> members =
|
||||
memberships.stream().map(this::toTeamMemberDTO).collect(Collectors.toList());
|
||||
|
||||
// Check if current user is team leader
|
||||
User currentUser = getCurrentUser();
|
||||
boolean isLeader =
|
||||
membershipRepository
|
||||
.findByTeamIdAndUserId(teamId, currentUser.getId())
|
||||
.map(
|
||||
m ->
|
||||
m.getRole()
|
||||
== stirling.software.common.model.enumeration
|
||||
.TeamRole.LEADER)
|
||||
.orElse(false);
|
||||
|
||||
int maxSeats = saasTeamExtensionService.getMaxSeats(team);
|
||||
int seatsUsed = saasTeamExtensionService.getSeatsUsed(team);
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"teamId",
|
||||
team.getId(),
|
||||
"name",
|
||||
team.getName(),
|
||||
"isPersonal",
|
||||
saasTeamExtensionService.isPersonal(team),
|
||||
"maxSeats",
|
||||
maxSeats,
|
||||
"seatsUsed",
|
||||
seatsUsed,
|
||||
"availableSeats",
|
||||
maxSeats - seatsUsed,
|
||||
"isLeader",
|
||||
isLeader,
|
||||
"members",
|
||||
members));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("Error fetching team info", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to fetch team info"));
|
||||
}
|
||||
}
|
||||
|
||||
// ========== REQUEST DTOs ==========
|
||||
|
||||
@Data
|
||||
public static class UpdateSeatsRequest {
|
||||
private Integer maxSeats;
|
||||
private String reason;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class RenameTeamRequest {
|
||||
private String newName;
|
||||
}
|
||||
}
|
||||
+298
@@ -0,0 +1,298 @@
|
||||
package stirling.software.saas.controller;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.saas.model.SupabaseUser;
|
||||
import stirling.software.saas.service.SaasUserAccountService;
|
||||
import stirling.software.saas.service.SupabaseUserService;
|
||||
import stirling.software.saas.util.LogRedactionUtils;
|
||||
|
||||
/**
|
||||
* Controller for handling user role upgrades/downgrades via webhooks. These endpoints are
|
||||
* authenticated via X-API-KEY header with admin privileges. Configure external services (Stripe,
|
||||
* etc.) to call these endpoints with your admin API key.
|
||||
*/
|
||||
@Hidden
|
||||
@RestController
|
||||
@Profile("saas")
|
||||
@RequestMapping("/api/v1/user-role")
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class UserRoleWebhookController {
|
||||
|
||||
private final UserService userService;
|
||||
private final SaasUserAccountService saasUserAccountService;
|
||||
private final SupabaseUserService supabaseUserService;
|
||||
|
||||
/**
|
||||
* Webhook endpoint to handle user upgrade to PRO plan. Called by Stripe when a subscription is
|
||||
* successfully created or a payment succeeds. Requires ROLE_ADMIN via X-API-KEY authentication.
|
||||
*
|
||||
* @param supabaseId The Supabase user ID to upgrade
|
||||
* @return ResponseEntity with appropriate status
|
||||
*/
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@PostMapping("/upgrade")
|
||||
public ResponseEntity<String> handleUpgrade(@RequestParam("supabaseId") String supabaseId) {
|
||||
|
||||
log.info(
|
||||
"Received upgrade request for Supabase ID: {}",
|
||||
LogRedactionUtils.redactSupabaseId(supabaseId));
|
||||
|
||||
try {
|
||||
boolean upgraded = saasUserAccountService.handleUpgrade(supabaseId);
|
||||
if (upgraded) {
|
||||
return ResponseEntity.ok("User upgraded to PRO successfully");
|
||||
} else {
|
||||
return ResponseEntity.ok("User is already PRO");
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("handleUpgrade rejected: {}", e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid request");
|
||||
} catch (Exception e) {
|
||||
log.error("Error processing upgrade webhook", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body("Error processing webhook");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook endpoint to handle user downgrade from PRO plan. Called by Stripe when a subscription
|
||||
* is canceled or expires. Requires ROLE_ADMIN via X-API-KEY authentication.
|
||||
*
|
||||
* @param supabaseId The Supabase user ID to downgrade
|
||||
* @return ResponseEntity with appropriate status
|
||||
*/
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@PostMapping("/downgrade")
|
||||
public ResponseEntity<String> handleDowngrade(@RequestParam("supabaseId") String supabaseId) {
|
||||
|
||||
log.info(
|
||||
"Received downgrade request for Supabase ID: {}",
|
||||
LogRedactionUtils.redactSupabaseId(supabaseId));
|
||||
|
||||
try {
|
||||
boolean downgraded = saasUserAccountService.handleDowngrade(supabaseId);
|
||||
if (downgraded) {
|
||||
return ResponseEntity.ok("User downgraded to FREE successfully");
|
||||
} else {
|
||||
return ResponseEntity.ok("User is already on FREE tier");
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("handleDowngrade rejected: {}", e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid request");
|
||||
} catch (Exception e) {
|
||||
log.error("Error processing downgrade webhook", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body("Error processing webhook");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook endpoint to enable metered billing (pay-what-you-use) for a user. Called by Stripe
|
||||
* when a user subscribes to the metered billing plan. Can be combined with Pro subscription.
|
||||
* Requires ROLE_ADMIN via X-API-KEY authentication.
|
||||
*
|
||||
* @param supabaseId The Supabase user ID
|
||||
* @return ResponseEntity with appropriate status
|
||||
*/
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@PostMapping("/enable-metered-billing")
|
||||
public ResponseEntity<String> enableMeteredBilling(
|
||||
@RequestParam("supabaseId") String supabaseId) {
|
||||
|
||||
log.info(
|
||||
"Received request to enable metered billing for Supabase ID: {}",
|
||||
LogRedactionUtils.redactSupabaseId(supabaseId));
|
||||
|
||||
try {
|
||||
boolean enabled = saasUserAccountService.enableMeteredBilling(supabaseId);
|
||||
if (enabled) {
|
||||
return ResponseEntity.ok("Metered billing enabled successfully");
|
||||
} else {
|
||||
return ResponseEntity.ok("User already has metered billing enabled");
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("enableMeteredBilling rejected: {}", e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid request");
|
||||
} catch (Exception e) {
|
||||
log.error("Error enabling metered billing", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body("Error processing webhook");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook endpoint to disable metered billing for a user. Called by Stripe when the metered
|
||||
* subscription is canceled. Requires ROLE_ADMIN via X-API-KEY authentication.
|
||||
*
|
||||
* @param supabaseId The Supabase user ID
|
||||
* @return ResponseEntity with appropriate status
|
||||
*/
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@PostMapping("/disable-metered-billing")
|
||||
public ResponseEntity<String> disableMeteredBilling(
|
||||
@RequestParam("supabaseId") String supabaseId) {
|
||||
|
||||
log.info(
|
||||
"Received request to disable metered billing for Supabase ID: {}",
|
||||
LogRedactionUtils.redactSupabaseId(supabaseId));
|
||||
|
||||
try {
|
||||
boolean disabled = saasUserAccountService.disableMeteredBilling(supabaseId);
|
||||
if (disabled) {
|
||||
return ResponseEntity.ok("Metered billing disabled successfully");
|
||||
} else {
|
||||
return ResponseEntity.ok("User does not have metered billing enabled");
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("disableMeteredBilling rejected: {}", e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid request");
|
||||
} catch (Exception e) {
|
||||
log.error("Error disabling metered billing", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body("Error processing webhook");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes the current user's upgrade from anonymous to authenticated status. This endpoint
|
||||
* is called after Supabase has successfully upgraded the user. It ensures the local database is
|
||||
* synchronized with the Supabase auth state.
|
||||
*
|
||||
* <p>Only allows users to upgrade their own account; the user is determined from the
|
||||
* SecurityContext. The email is derived from the SupabaseUser to prevent client tampering.
|
||||
*
|
||||
* @param authMethod the authentication method used (e.g., "email", "google", "github")
|
||||
* @return ResponseEntity with success or error message
|
||||
*/
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
@PostMapping("/promptToAuthUser")
|
||||
public ResponseEntity<Map<String, String>> promptToAuthUser(
|
||||
@RequestParam(value = "authMethod", required = false) String authMethod,
|
||||
Principal principal) {
|
||||
|
||||
try {
|
||||
// Principal is guaranteed to be non-null due to @PreAuthorize
|
||||
String currentUsername = principal.getName();
|
||||
|
||||
// Normalize and validate authMethod
|
||||
String normalizedAuthMethod = normalizeAuthMethod(authMethod);
|
||||
if (normalizedAuthMethod != null && !isValidAuthMethod(normalizedAuthMethod)) {
|
||||
log.warn("Invalid auth method provided: {}", authMethod);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Invalid authentication method"));
|
||||
}
|
||||
|
||||
log.debug(
|
||||
"User {} attempting to synchronize upgrade using method: {}",
|
||||
currentUsername,
|
||||
normalizedAuthMethod);
|
||||
|
||||
// Find the current user
|
||||
User currentUser =
|
||||
userService
|
||||
.findByUsername(currentUsername)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IllegalStateException(
|
||||
"Current user not found: " + currentUsername));
|
||||
|
||||
// Get the SupabaseUser linked to current user. consolidation stores the linkage as a
|
||||
// plain UUID column on User (no JPA relationship), so we resolve via
|
||||
// SupabaseUserService.
|
||||
UUID supabaseId = currentUser.getSupabaseId();
|
||||
if (supabaseId == null) {
|
||||
log.error("Current user {} has no linked Supabase ID", currentUsername);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "No Supabase account linked to current user"));
|
||||
}
|
||||
SupabaseUser supabaseUser = supabaseUserService.getUser(supabaseId);
|
||||
|
||||
// Verify this is an anonymous user trying to upgrade
|
||||
if (!AuthenticationType.ANONYMOUS
|
||||
.name()
|
||||
.equalsIgnoreCase(currentUser.getAuthenticationType())) {
|
||||
log.warn("User {} is not anonymous, cannot upgrade", currentUsername);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Only anonymous users can be upgraded"));
|
||||
}
|
||||
|
||||
// Derive the canonical email from SupabaseUser
|
||||
String canonicalEmail = supabaseUser.getEmail();
|
||||
if (canonicalEmail == null || canonicalEmail.isBlank()) {
|
||||
// Fall back to current user's email if available
|
||||
canonicalEmail = currentUser.getEmail();
|
||||
if (canonicalEmail == null || canonicalEmail.isBlank()) {
|
||||
log.error(
|
||||
"No email found for user {} in Supabase or local DB", currentUsername);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "No email associated with user account"));
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("Using canonical email {} for user upgrade", canonicalEmail);
|
||||
|
||||
User upgradedUser =
|
||||
saasUserAccountService.synchronizeUserUpgrade(
|
||||
supabaseUser, canonicalEmail, normalizedAuthMethod);
|
||||
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"message",
|
||||
"User upgrade synchronized successfully",
|
||||
"userId",
|
||||
upgradedUser.getId().toString(),
|
||||
"email",
|
||||
upgradedUser.getEmail() != null
|
||||
? upgradedUser.getEmail()
|
||||
: upgradedUser.getUsername()));
|
||||
|
||||
} catch (IllegalStateException e) {
|
||||
log.error("User not found for upgrade: {}", e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "User not found"));
|
||||
} catch (Exception e) {
|
||||
log.error("Error synchronizing user upgrade", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to synchronize user upgrade"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalizes the auth method string to lowercase and trims whitespace. */
|
||||
private String normalizeAuthMethod(String authMethod) {
|
||||
return authMethod == null ? null : authMethod.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
/** Validates that the auth method is one of the allowed values. */
|
||||
private boolean isValidAuthMethod(String authMethod) {
|
||||
if (authMethod == null) {
|
||||
return true; // null is valid (will default to email/web auth)
|
||||
}
|
||||
|
||||
// Define allowed auth methods
|
||||
return Set.of("email", "oauth", "google", "github", "apple", "azure", "linkedin_oidc")
|
||||
.contains(authMethod);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package stirling.software.saas.interceptor;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.model.CreditConsumptionResult;
|
||||
import stirling.software.saas.service.CreditService;
|
||||
import stirling.software.saas.service.ErrorTrackingService;
|
||||
import stirling.software.saas.service.SaasTeamExtensionService;
|
||||
import stirling.software.saas.service.TeamCreditService;
|
||||
import stirling.software.saas.util.AuthenticationUtils;
|
||||
import stirling.software.saas.util.CreditHeaderUtils;
|
||||
|
||||
/**
|
||||
* Scoped to controllers annotated with {@link AutoJobPostMapping} so it doesn't hijack the global
|
||||
* exception flow.
|
||||
*/
|
||||
@RestControllerAdvice(annotations = AutoJobPostMapping.class)
|
||||
@Profile("saas")
|
||||
@Slf4j
|
||||
@Order(1)
|
||||
public class CreditErrorAdvice {
|
||||
|
||||
private static final String ATTR_ELIGIBLE = "CREDIT_ELIGIBLE";
|
||||
private static final String ATTR_APIKEY = "CREDIT_API_KEY";
|
||||
private static final String ATTR_CHARGED = "CREDIT_CHARGED";
|
||||
private static final String ATTR_RESOURCE_WEIGHT = "CREDIT_RESOURCE_WEIGHT";
|
||||
|
||||
private final CreditService creditService;
|
||||
private final TeamCreditService teamCreditService;
|
||||
private final UserRepository userRepository;
|
||||
private final ErrorTrackingService errorTrackingService;
|
||||
private final SaasTeamExtensionService saasTeamExtensionService;
|
||||
private final CreditHeaderUtils creditHeaderUtils;
|
||||
private final Counter creditsConsumedCounter;
|
||||
// Inlined: Stirling's parent build uses Jackson 3 (tools.jackson), no Jackson 2 ObjectMapper
|
||||
// bean in the context. Stateless usage, so a fresh instance is fine.
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
public CreditErrorAdvice(
|
||||
CreditService creditService,
|
||||
TeamCreditService teamCreditService,
|
||||
UserRepository userRepository,
|
||||
ErrorTrackingService errorTrackingService,
|
||||
SaasTeamExtensionService saasTeamExtensionService,
|
||||
CreditHeaderUtils creditHeaderUtils,
|
||||
MeterRegistry meterRegistry) {
|
||||
this.creditService = creditService;
|
||||
this.teamCreditService = teamCreditService;
|
||||
this.userRepository = userRepository;
|
||||
this.errorTrackingService = errorTrackingService;
|
||||
this.saasTeamExtensionService = saasTeamExtensionService;
|
||||
this.creditHeaderUtils = creditHeaderUtils;
|
||||
this.creditsConsumedCounter =
|
||||
Counter.builder("credits.consumed")
|
||||
.description("Number of credits actually consumed")
|
||||
.tag("source", "error")
|
||||
.register(meterRegistry);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Throwable.class)
|
||||
public ResponseEntity<Object> handleThrowable(HttpServletRequest request, Throwable ex) {
|
||||
HttpStatus status = determineHttpStatus(ex);
|
||||
log.debug(
|
||||
"[CREDIT-DEBUG] CreditErrorAdvice: Handling exception: {} -> {}",
|
||||
ex.getClass().getSimpleName(),
|
||||
status);
|
||||
|
||||
String message = Optional.ofNullable(ex.getMessage()).orElse("An error occurred");
|
||||
// Build error body
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("error", ex.getClass().getSimpleName());
|
||||
body.put("message", message);
|
||||
body.put("status", status.value());
|
||||
|
||||
var builder = ResponseEntity.status(status);
|
||||
|
||||
// Handle credit consumption for errors
|
||||
if (Boolean.TRUE.equals(request.getAttribute(ATTR_ELIGIBLE))
|
||||
&& request.getAttribute(ATTR_CHARGED) == null) {
|
||||
|
||||
var apiKey = (String) request.getAttribute(ATTR_APIKEY);
|
||||
var resourceWeight = (Integer) request.getAttribute(ATTR_RESOURCE_WEIGHT);
|
||||
var isApiRequest = (Boolean) request.getAttribute("IS_API_REQUEST");
|
||||
int creditAmount = resourceWeight != null ? resourceWeight : 1;
|
||||
|
||||
String identifierForErrorTracking =
|
||||
apiKey; // Keep using apiKey/username for error tracking
|
||||
if (apiKey != null
|
||||
&& errorTrackingService.recordErrorAndShouldConsumeCredit(
|
||||
identifierForErrorTracking,
|
||||
request.getRequestURI(),
|
||||
ex,
|
||||
status.value())) {
|
||||
|
||||
// Get current user
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
User user = null;
|
||||
try {
|
||||
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"[CREDIT-DEBUG] CreditErrorAdvice: Could not get user for team check: {}",
|
||||
e.getMessage());
|
||||
}
|
||||
|
||||
if (user == null) {
|
||||
log.error(
|
||||
"[CREDIT-DEBUG] CreditErrorAdvice: Unable to resolve user - skipping credit consumption");
|
||||
} else {
|
||||
// Check if user is in a non-personal team (must match UnifiedCreditInterceptor
|
||||
// logic)
|
||||
Long targetTeamId = null;
|
||||
if (user.getTeam() != null
|
||||
&& !saasTeamExtensionService.isPersonal(user.getTeam())) {
|
||||
targetTeamId = user.getTeam().getId();
|
||||
}
|
||||
|
||||
boolean consumed = false;
|
||||
String creditSource = null;
|
||||
|
||||
if (targetTeamId != null) {
|
||||
// User is in a non-personal team - consume from team credit pool
|
||||
consumed = teamCreditService.consumeCredit(targetTeamId, creditAmount);
|
||||
creditSource = "TEAM_CREDITS";
|
||||
log.debug(
|
||||
"[CREDIT-DEBUG] CreditErrorAdvice: Consumed {} credits from team {}",
|
||||
creditAmount,
|
||||
targetTeamId);
|
||||
} else {
|
||||
// No team - use waterfall logic for individual credits
|
||||
boolean isApiRequestFlag = Boolean.TRUE.equals(isApiRequest);
|
||||
CreditConsumptionResult result =
|
||||
creditService.consumeCreditWithWaterfall(
|
||||
user, creditAmount, isApiRequestFlag);
|
||||
consumed = result.isSuccess();
|
||||
creditSource = result.getSource();
|
||||
|
||||
if (!consumed) {
|
||||
log.error(
|
||||
"[CREDIT-DEBUG] CreditErrorAdvice: Credit consumption failed for user: {} - {}",
|
||||
user.getUsername(),
|
||||
result.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (consumed) {
|
||||
request.setAttribute(ATTR_CHARGED, Boolean.TRUE);
|
||||
creditsConsumedCounter.increment();
|
||||
|
||||
// Set remaining credits header
|
||||
int remainingCredits =
|
||||
creditHeaderUtils.getRemainingCredits(
|
||||
user, creditService, teamCreditService);
|
||||
if (remainingCredits >= 0) {
|
||||
builder.header(
|
||||
"X-Credits-Remaining", Integer.toString(remainingCredits));
|
||||
log.warn(
|
||||
"[CREDIT-HEADER] Added X-Credits-Remaining header: {}",
|
||||
remainingCredits);
|
||||
}
|
||||
if (creditSource != null) {
|
||||
builder.header("X-Credit-Source", creditSource);
|
||||
}
|
||||
|
||||
log.info(
|
||||
"[CREDIT-DEBUG] CreditErrorAdvice: {} credits consumed from {} for user: {} (error case)",
|
||||
creditAmount,
|
||||
creditSource,
|
||||
user.getUsername());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.debug(
|
||||
"[CREDIT-DEBUG] CreditErrorAdvice: ErrorTrackingService says do NOT consume credit for this error");
|
||||
}
|
||||
} else if (request.getAttribute(ATTR_CHARGED) != null) {
|
||||
// Already charged, set header if user is authenticated
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null && auth.isAuthenticated()) {
|
||||
try {
|
||||
User user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
||||
int remainingCredits =
|
||||
creditHeaderUtils.getRemainingCredits(
|
||||
user, creditService, teamCreditService);
|
||||
if (remainingCredits >= 0) {
|
||||
builder.header("X-Credits-Remaining", Integer.toString(remainingCredits));
|
||||
log.warn(
|
||||
"[CREDIT-HEADER] Added X-Credits-Remaining header: {}",
|
||||
remainingCredits);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"[CREDIT-HEADER] Could not add credits header for already charged error: {}",
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
log.debug("[CREDIT-DEBUG] CreditErrorAdvice: Header set for already charged error");
|
||||
}
|
||||
|
||||
if (isSseRequest(request)) {
|
||||
String payload = toJsonPayload(body);
|
||||
String sseBody = "event: error\ndata: " + payload + "\n\n";
|
||||
return builder.contentType(MediaType.TEXT_EVENT_STREAM).body(sseBody);
|
||||
}
|
||||
|
||||
return builder.body(body);
|
||||
}
|
||||
|
||||
private String maskApiKey(String apiKey) {
|
||||
if (apiKey == null || apiKey.length() < 8) {
|
||||
return "***";
|
||||
}
|
||||
return apiKey.substring(0, 4) + "***" + apiKey.substring(apiKey.length() - 4);
|
||||
}
|
||||
|
||||
private HttpStatus determineHttpStatus(Throwable throwable) {
|
||||
// Map common exceptions to HTTP status codes
|
||||
String exceptionClass = throwable.getClass().getSimpleName();
|
||||
switch (exceptionClass) {
|
||||
case "IllegalArgumentException":
|
||||
case "ValidationException":
|
||||
case "MethodArgumentNotValidException":
|
||||
return HttpStatus.BAD_REQUEST;
|
||||
case "AccessDeniedException":
|
||||
return HttpStatus.FORBIDDEN;
|
||||
case "UsernameNotFoundException":
|
||||
return HttpStatus.UNAUTHORIZED;
|
||||
case "HttpMessageNotReadableException":
|
||||
return HttpStatus.BAD_REQUEST;
|
||||
case "MaxUploadSizeExceededException":
|
||||
return HttpStatus.PAYLOAD_TOO_LARGE;
|
||||
case "UnsupportedOperationException":
|
||||
return HttpStatus.NOT_IMPLEMENTED;
|
||||
default:
|
||||
// Check error message for clues
|
||||
String message = throwable.getMessage();
|
||||
if (message != null) {
|
||||
if (message.toLowerCase().contains("validation")
|
||||
|| message.toLowerCase().contains("invalid parameter")) {
|
||||
return HttpStatus.BAD_REQUEST;
|
||||
}
|
||||
if (message.toLowerCase().contains("not found")) {
|
||||
return HttpStatus.NOT_FOUND;
|
||||
}
|
||||
}
|
||||
return HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isSseRequest(HttpServletRequest request) {
|
||||
String accept = request.getHeader("Accept");
|
||||
if (accept != null && accept.contains(MediaType.TEXT_EVENT_STREAM_VALUE)) {
|
||||
return true;
|
||||
}
|
||||
String contentType = request.getContentType();
|
||||
return contentType != null && contentType.contains(MediaType.TEXT_EVENT_STREAM_VALUE);
|
||||
}
|
||||
|
||||
private String toJsonPayload(Map<String, Object> payload) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(payload);
|
||||
} catch (JsonProcessingException exc) {
|
||||
log.warn("Failed to serialize SSE error payload, falling back to string", exc);
|
||||
String message = payload.getOrDefault("message", "An error occurred").toString();
|
||||
return "{\"error\":\"Error\",\"message\":\"" + message + "\",\"status\":500}";
|
||||
}
|
||||
}
|
||||
|
||||
public static class ErrorResponse {
|
||||
public final String error;
|
||||
public final String message;
|
||||
public final int status;
|
||||
|
||||
public ErrorResponse(String error, String message, int status) {
|
||||
this.error = error;
|
||||
this.message = message;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package stirling.software.saas.interceptor;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.converter.HttpMessageConverter;
|
||||
import org.springframework.http.server.ServerHttpRequest;
|
||||
import org.springframework.http.server.ServerHttpResponse;
|
||||
import org.springframework.http.server.ServletServerHttpRequest;
|
||||
import org.springframework.http.server.ServletServerHttpResponse;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
|
||||
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.model.CreditConsumptionResult;
|
||||
import stirling.software.saas.service.CreditService;
|
||||
import stirling.software.saas.service.SaasTeamExtensionService;
|
||||
import stirling.software.saas.service.TeamCreditService;
|
||||
import stirling.software.saas.util.AuthenticationUtils;
|
||||
import stirling.software.saas.util.CreditHeaderUtils;
|
||||
|
||||
@RestControllerAdvice
|
||||
@Profile("saas")
|
||||
@Slf4j
|
||||
public class CreditSuccessAdvice implements ResponseBodyAdvice<Object> {
|
||||
|
||||
private static final String ATTR_ELIGIBLE = "CREDIT_ELIGIBLE";
|
||||
private static final String ATTR_APIKEY = "CREDIT_API_KEY";
|
||||
private static final String ATTR_CHARGED = "CREDIT_CHARGED";
|
||||
private static final String ATTR_RESOURCE_WEIGHT = "CREDIT_RESOURCE_WEIGHT";
|
||||
|
||||
private final CreditService creditService;
|
||||
private final TeamCreditService teamCreditService;
|
||||
private final UserRepository userRepository;
|
||||
private final SaasTeamExtensionService saasTeamExtensionService;
|
||||
private final CreditHeaderUtils creditHeaderUtils;
|
||||
private final Counter creditsConsumedCounter;
|
||||
|
||||
public CreditSuccessAdvice(
|
||||
CreditService creditService,
|
||||
TeamCreditService teamCreditService,
|
||||
UserRepository userRepository,
|
||||
SaasTeamExtensionService saasTeamExtensionService,
|
||||
CreditHeaderUtils creditHeaderUtils,
|
||||
MeterRegistry meterRegistry) {
|
||||
this.creditService = creditService;
|
||||
this.teamCreditService = teamCreditService;
|
||||
this.userRepository = userRepository;
|
||||
this.saasTeamExtensionService = saasTeamExtensionService;
|
||||
this.creditHeaderUtils = creditHeaderUtils;
|
||||
this.creditsConsumedCounter =
|
||||
Counter.builder("credits.consumed")
|
||||
.description("Number of credits actually consumed")
|
||||
.tag("source", "success")
|
||||
.register(meterRegistry);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(
|
||||
MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
|
||||
// Only REST bodies; this covers @ResponseBody and ResponseEntity
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object beforeBodyWrite(
|
||||
Object body,
|
||||
MethodParameter returnType,
|
||||
MediaType selectedContentType,
|
||||
Class<? extends HttpMessageConverter<?>> selectedConverterType,
|
||||
ServerHttpRequest request,
|
||||
ServerHttpResponse response) {
|
||||
|
||||
if (!(request instanceof ServletServerHttpRequest)) {
|
||||
return body;
|
||||
}
|
||||
|
||||
var servletReq = ((ServletServerHttpRequest) request).getServletRequest();
|
||||
if (!Boolean.TRUE.equals(servletReq.getAttribute(ATTR_ELIGIBLE))) {
|
||||
return body;
|
||||
}
|
||||
|
||||
if (servletReq.getAttribute(ATTR_CHARGED) != null) {
|
||||
return body;
|
||||
}
|
||||
|
||||
// If the handler returned an error ResponseEntity (>=400) without throwing,
|
||||
// don't spend here; the error advice will decide.
|
||||
int status = 200;
|
||||
if (response instanceof ServletServerHttpResponse) {
|
||||
status = ((ServletServerHttpResponse) response).getServletResponse().getStatus();
|
||||
}
|
||||
if (status >= 400) {
|
||||
log.debug(
|
||||
"[CREDIT-DEBUG] CreditSuccessAdvice: Error status {} detected, skipping credit consumption",
|
||||
status);
|
||||
return body;
|
||||
}
|
||||
|
||||
var apiKey = (String) servletReq.getAttribute(ATTR_APIKEY);
|
||||
var resourceWeight = (Integer) servletReq.getAttribute(ATTR_RESOURCE_WEIGHT);
|
||||
var isApiRequest = (Boolean) servletReq.getAttribute("IS_API_REQUEST");
|
||||
int creditAmount = resourceWeight != null ? resourceWeight : 1;
|
||||
|
||||
if (apiKey != null) {
|
||||
// Get current user
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
User user = null;
|
||||
try {
|
||||
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"[CREDIT-DEBUG] CreditSuccessAdvice: Could not get user for team check: {}",
|
||||
e.getMessage());
|
||||
}
|
||||
|
||||
if (user == null) {
|
||||
log.error(
|
||||
"[CREDIT-DEBUG] CreditSuccessAdvice: Unable to resolve user - skipping credit consumption");
|
||||
return body;
|
||||
}
|
||||
|
||||
// Check if user is in a non-personal team (must match UnifiedCreditInterceptor logic)
|
||||
// IMPORTANT: Limited API users (anonymous, extra limited) always use personal credits,
|
||||
// never team credits
|
||||
boolean isLimitedApiUser =
|
||||
auth.getAuthorities().stream()
|
||||
.anyMatch(
|
||||
authority ->
|
||||
"ROLE_LIMITED_API_USER".equals(authority.getAuthority())
|
||||
|| "ROLE_EXTRA_LIMITED_API_USER"
|
||||
.equals(authority.getAuthority()));
|
||||
Long targetTeamId = null;
|
||||
if (!isLimitedApiUser
|
||||
&& user.getTeam() != null
|
||||
&& !saasTeamExtensionService.isPersonal(user.getTeam())) {
|
||||
targetTeamId = user.getTeam().getId();
|
||||
}
|
||||
|
||||
final boolean consumed;
|
||||
final String creditSource;
|
||||
|
||||
if (targetTeamId != null) {
|
||||
// User is in a non-personal team - use waterfall with leader overage
|
||||
CreditConsumptionResult result =
|
||||
teamCreditService.consumeCreditWithWaterfall(targetTeamId, creditAmount);
|
||||
consumed = result.isSuccess();
|
||||
creditSource = result.getSource();
|
||||
|
||||
if (!consumed) {
|
||||
log.error(
|
||||
"[CREDIT-DEBUG] CreditSuccessAdvice: Team credit consumption failed:"
|
||||
+ " {}",
|
||||
result.getMessage());
|
||||
} else {
|
||||
log.debug(
|
||||
"[CREDIT-DEBUG] CreditSuccessAdvice: Consumed {} credits from team {}"
|
||||
+ " via {}",
|
||||
creditAmount,
|
||||
targetTeamId,
|
||||
creditSource);
|
||||
}
|
||||
} else {
|
||||
// No team - use waterfall logic for individual credits
|
||||
boolean isApiRequestFlag = Boolean.TRUE.equals(isApiRequest);
|
||||
CreditConsumptionResult result =
|
||||
creditService.consumeCreditWithWaterfall(
|
||||
user, creditAmount, isApiRequestFlag);
|
||||
consumed = result.isSuccess();
|
||||
creditSource = result.getSource();
|
||||
|
||||
if (!consumed) {
|
||||
log.error(
|
||||
"[CREDIT-DEBUG] CreditSuccessAdvice: Credit consumption failed for user: {} - {}",
|
||||
user.getUsername(),
|
||||
result.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (consumed) {
|
||||
servletReq.setAttribute(ATTR_CHARGED, Boolean.TRUE);
|
||||
creditsConsumedCounter.increment();
|
||||
|
||||
// Set remaining credits header
|
||||
int remainingCredits =
|
||||
creditHeaderUtils.getRemainingCredits(
|
||||
user, creditService, teamCreditService);
|
||||
if (remainingCredits >= 0) {
|
||||
response.getHeaders()
|
||||
.set("X-Credits-Remaining", Integer.toString(remainingCredits));
|
||||
log.warn(
|
||||
"[CREDIT-HEADER] Added X-Credits-Remaining header: {}",
|
||||
remainingCredits);
|
||||
}
|
||||
if (creditSource != null) {
|
||||
response.getHeaders().set("X-Credit-Source", creditSource);
|
||||
}
|
||||
|
||||
log.info(
|
||||
"[CREDIT-DEBUG] CreditSuccessAdvice: {} credits consumed from {} for user: {}",
|
||||
creditAmount,
|
||||
creditSource,
|
||||
user.getUsername());
|
||||
}
|
||||
} else {
|
||||
log.warn("[CREDIT-DEBUG] CreditSuccessAdvice: No apiKey attribute found");
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
private String maskApiKey(String apiKey) {
|
||||
if (apiKey == null || apiKey.length() < 8) {
|
||||
return "***";
|
||||
}
|
||||
return apiKey.substring(0, 4) + "***" + apiKey.substring(apiKey.length() - 4);
|
||||
}
|
||||
}
|
||||
+491
@@ -0,0 +1,491 @@
|
||||
package stirling.software.saas.interceptor;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
import org.springframework.web.servlet.AsyncHandlerInterceptor;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.Timer;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.config.CreditsProperties;
|
||||
import stirling.software.saas.model.TeamCredit;
|
||||
import stirling.software.saas.model.UserCredit;
|
||||
import stirling.software.saas.repository.TeamMembershipRepository;
|
||||
import stirling.software.saas.service.CreditService;
|
||||
import stirling.software.saas.service.ErrorTrackingService;
|
||||
import stirling.software.saas.service.SaasTeamExtensionService;
|
||||
import stirling.software.saas.service.SaasUserExtensionService;
|
||||
import stirling.software.saas.service.TeamCreditService;
|
||||
import stirling.software.saas.util.AuthenticationUtils;
|
||||
|
||||
@Component
|
||||
@Profile("saas")
|
||||
@Slf4j
|
||||
public class UnifiedCreditInterceptor implements AsyncHandlerInterceptor {
|
||||
|
||||
private final CreditService creditService;
|
||||
private final ErrorTrackingService errorTrackingService;
|
||||
private final CreditsProperties creditsProperties;
|
||||
private final UserRepository userRepository;
|
||||
private final TeamCreditService teamCreditService;
|
||||
private final TeamMembershipRepository membershipRepository;
|
||||
private final SaasUserExtensionService saasUserExtensionService;
|
||||
private final SaasTeamExtensionService saasTeamExtensionService;
|
||||
|
||||
private final Counter creditsCheckedCounter;
|
||||
private final Counter creditsRejectedCounter;
|
||||
private final Counter jwtBypassCounter;
|
||||
private final Timer creditCheckTimer;
|
||||
|
||||
private static final String ATTR_CREDIT_ELIGIBLE = "CREDIT_ELIGIBLE";
|
||||
private static final String ATTR_API_KEY = "CREDIT_API_KEY";
|
||||
private static final String ATTR_RESOURCE_WEIGHT = "CREDIT_RESOURCE_WEIGHT";
|
||||
private static final String ATTR_CHARGED = "CREDIT_CHARGED";
|
||||
|
||||
public UnifiedCreditInterceptor(
|
||||
CreditService creditService,
|
||||
ErrorTrackingService errorTrackingService,
|
||||
CreditsProperties creditsProperties,
|
||||
UserRepository userRepository,
|
||||
TeamCreditService teamCreditService,
|
||||
TeamMembershipRepository membershipRepository,
|
||||
SaasUserExtensionService saasUserExtensionService,
|
||||
SaasTeamExtensionService saasTeamExtensionService,
|
||||
MeterRegistry meterRegistry) {
|
||||
this.creditService = creditService;
|
||||
this.errorTrackingService = errorTrackingService;
|
||||
this.creditsProperties = creditsProperties;
|
||||
this.userRepository = userRepository;
|
||||
this.teamCreditService = teamCreditService;
|
||||
this.membershipRepository = membershipRepository;
|
||||
this.saasUserExtensionService = saasUserExtensionService;
|
||||
this.saasTeamExtensionService = saasTeamExtensionService;
|
||||
|
||||
this.creditsCheckedCounter =
|
||||
Counter.builder("credits.validation.checked")
|
||||
.description("Number of requests that had credit validation performed")
|
||||
.register(meterRegistry);
|
||||
this.creditsRejectedCounter =
|
||||
Counter.builder("credits.validation.rejected")
|
||||
.description("Number of requests rejected due to insufficient credits")
|
||||
.register(meterRegistry);
|
||||
this.jwtBypassCounter =
|
||||
Counter.builder("credits.validation.jwt_bypass")
|
||||
.description("Number of JWT requests that bypassed credit validation")
|
||||
.register(meterRegistry);
|
||||
this.creditCheckTimer =
|
||||
Timer.builder("credits.validation.duration")
|
||||
.description("Time taken to validate credits")
|
||||
.register(meterRegistry);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean preHandle(
|
||||
HttpServletRequest request, HttpServletResponse response, Object handler)
|
||||
throws Exception {
|
||||
|
||||
log.debug(
|
||||
"[CREDIT-DEBUG] UnifiedCreditInterceptor.preHandle() - handler: {}",
|
||||
handler.getClass().getSimpleName());
|
||||
|
||||
// Credits system disabled - allow all requests
|
||||
if (!creditsProperties.isEnabled()) {
|
||||
log.debug("[CREDIT-DEBUG] Credits system disabled - allowing request");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Only apply to @AutoJobPostMapping endpoints and extract resource weight
|
||||
if (!(handler instanceof HandlerMethod hm)
|
||||
|| !hm.getMethod().isAnnotationPresent(AutoJobPostMapping.class)) {
|
||||
log.debug(
|
||||
"[CREDIT-DEBUG] Handler not eligible for credit validation (no @AutoJobPostMapping)");
|
||||
return true;
|
||||
}
|
||||
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
|
||||
log.debug(
|
||||
"[CREDIT-DEBUG] Authentication: {}",
|
||||
auth != null ? auth.getClass().getSimpleName() : "null");
|
||||
|
||||
User currentUser = null;
|
||||
|
||||
// API key authentication always needs credit validation
|
||||
if (auth instanceof ApiKeyAuthenticationToken) {
|
||||
// API key users - proceed with normal credit validation
|
||||
currentUser = (User) auth.getPrincipal();
|
||||
} else if (auth != null && auth.isAuthenticated()) {
|
||||
// JWT users - get user from authentication details
|
||||
// JwtAuthenticationToken.getPrincipal() might not be a User object
|
||||
// so we need to look up the user by the Supabase ID from auth.getName()
|
||||
|
||||
String supabaseId = AuthenticationUtils.extractSupabaseId(auth);
|
||||
log.debug("[CREDIT-DEBUG] JWT authentication detected, Supabase ID: {}", supabaseId);
|
||||
|
||||
// Look up the User object that should exist (authentication succeeded)
|
||||
try {
|
||||
java.util.UUID supabaseUuid = java.util.UUID.fromString(supabaseId);
|
||||
java.util.Optional<User> userOpt = userRepository.findBySupabaseId(supabaseUuid);
|
||||
if (userOpt.isEmpty()) {
|
||||
log.error(
|
||||
"[CREDIT-DEBUG] JWT authenticated but no User found for Supabase ID: {}",
|
||||
supabaseId);
|
||||
response.setStatus(500);
|
||||
response.setContentType("application/json");
|
||||
response.getWriter()
|
||||
.write(
|
||||
"{\"error\":\"USER_NOT_FOUND\",\"message\":\"Authenticated user not found in database\",\"status\":500}");
|
||||
return false;
|
||||
}
|
||||
currentUser = userOpt.get();
|
||||
|
||||
if (shouldApplyCreditsToJwtUser(currentUser)) {
|
||||
// Anonymous users or other limited JWT users should consume credits
|
||||
log.debug(
|
||||
"[CREDIT-DEBUG] JWT user {} subject to credit validation due to limited role",
|
||||
currentUser.getUsername());
|
||||
} else {
|
||||
jwtBypassCounter.increment();
|
||||
log.debug(
|
||||
"[CREDIT-DEBUG] JWT user {} bypassing credit validation (unlimited role)",
|
||||
currentUser.getUsername());
|
||||
return true;
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.error("[CREDIT-DEBUG] Invalid Supabase ID format: {}", supabaseId);
|
||||
response.setStatus(400);
|
||||
response.setContentType("application/json");
|
||||
response.getWriter()
|
||||
.write(
|
||||
"{\"error\":\"INVALID_USER_ID\",\"message\":\"Invalid user identifier format\",\"status\":400}");
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// SECURITY: Block all non-authenticated requests
|
||||
log.warn(
|
||||
"[CREDIT-DEBUG] Non-authenticated request blocked - authentication required for credit-controlled endpoints");
|
||||
response.setStatus(401); // 401 Unauthorized
|
||||
response.setContentType("application/json");
|
||||
response.getWriter()
|
||||
.write(
|
||||
"{\"error\":\"AUTHENTICATION_REQUIRED\",\"message\":\"Authentication required to access this endpoint\",\"status\":401}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract resource weight from annotation
|
||||
AutoJobPostMapping annotation = hm.getMethod().getAnnotation(AutoJobPostMapping.class);
|
||||
int resourceWeight =
|
||||
Math.max(1, Math.min(100, annotation.resourceWeight())); // Clamp to 1-100
|
||||
|
||||
String apiKey = getApiKeyForUser(auth, currentUser);
|
||||
String maskedApiKey = maskApiKey(apiKey);
|
||||
|
||||
log.debug(
|
||||
"[CREDIT-DEBUG] Credit validation for user: {}, API key: {}, resource weight: {}",
|
||||
currentUser.getUsername(),
|
||||
maskedApiKey,
|
||||
resourceWeight);
|
||||
|
||||
// Track that we're performing credit validation
|
||||
creditsCheckedCounter.increment();
|
||||
|
||||
// Check if user has SUFFICIENT credits for this operation (with timing)
|
||||
Timer.Sample sample = Timer.start();
|
||||
boolean hasSufficientCredits;
|
||||
int availableCredits = 0;
|
||||
|
||||
// Check if user is a limited API user (anonymous, extra limited)
|
||||
// Limited API users always use personal credits, never team credits
|
||||
boolean isLimitedApiUser =
|
||||
currentUser.getAuthorities().stream()
|
||||
.anyMatch(
|
||||
authority ->
|
||||
"ROLE_LIMITED_API_USER".equals(authority.getAuthority())
|
||||
|| "ROLE_EXTRA_LIMITED_API_USER"
|
||||
.equals(authority.getAuthority()));
|
||||
|
||||
if (auth instanceof ApiKeyAuthenticationToken) {
|
||||
// API key auth - get credit balance
|
||||
java.util.Optional<UserCredit> userCreditsOpt =
|
||||
creditService.getUserCreditsByApiKey(apiKey);
|
||||
availableCredits = userCreditsOpt.map(UserCredit::getTotalAvailableCredits).orElse(0);
|
||||
hasSufficientCredits = availableCredits >= resourceWeight;
|
||||
} else {
|
||||
// JWT user - check team credits if user is in a non-personal team, otherwise personal
|
||||
// credits
|
||||
Long teamId = null;
|
||||
if (!isLimitedApiUser
|
||||
&& currentUser.getTeam() != null
|
||||
&& !saasTeamExtensionService.isPersonal(currentUser.getTeam())) {
|
||||
teamId = currentUser.getTeam().getId();
|
||||
}
|
||||
|
||||
if (teamId != null) {
|
||||
// User is in a non-personal team - check team credits + leader overage billing
|
||||
java.util.Optional<TeamCredit> teamCredits =
|
||||
teamCreditService.getTeamCredits(teamId);
|
||||
availableCredits = teamCredits.map(TeamCredit::getTotalAvailableCredits).orElse(0);
|
||||
|
||||
// Check if sufficient credits OR team leader has metered billing
|
||||
boolean hasTeamCredits = availableCredits >= resourceWeight;
|
||||
boolean leaderHasMetered = checkTeamLeaderMeteredBilling(currentUser.getTeam());
|
||||
|
||||
hasSufficientCredits = hasTeamCredits || leaderHasMetered;
|
||||
|
||||
log.debug(
|
||||
"[CREDIT-DEBUG] Checking team {} credits for user {}: available={}"
|
||||
+ " required={} hasCredits={} leaderMetered={} sufficient={}",
|
||||
teamId,
|
||||
currentUser.getUsername(),
|
||||
availableCredits,
|
||||
resourceWeight,
|
||||
hasTeamCredits,
|
||||
leaderHasMetered,
|
||||
hasSufficientCredits);
|
||||
} else {
|
||||
// Personal team or no team - check personal credits
|
||||
UserCredit userCredits = creditService.getOrCreateUserCredits(currentUser);
|
||||
availableCredits = userCredits.getTotalAvailableCredits();
|
||||
hasSufficientCredits = availableCredits >= resourceWeight;
|
||||
log.debug(
|
||||
"[CREDIT-DEBUG] Checking personal credits for user {}: available={} required={} sufficient={}",
|
||||
currentUser.getUsername(),
|
||||
availableCredits,
|
||||
resourceWeight,
|
||||
hasSufficientCredits);
|
||||
}
|
||||
}
|
||||
sample.stop(creditCheckTimer);
|
||||
|
||||
// Check if user has metered billing enabled (they can use overage credits even with
|
||||
// insufficient free credits)
|
||||
boolean hasMeteredBilling = saasUserExtensionService.isMeteredBillingEnabled(currentUser);
|
||||
|
||||
if (!hasSufficientCredits && !hasMeteredBilling) {
|
||||
creditsRejectedCounter.increment();
|
||||
|
||||
// Enhanced message for team members
|
||||
// Note: Limited API users always use personal credits, so they get personal message
|
||||
String message;
|
||||
if (!isLimitedApiUser
|
||||
&& currentUser.getTeam() != null
|
||||
&& !saasTeamExtensionService.isPersonal(currentUser.getTeam())) {
|
||||
message =
|
||||
"Insufficient team credits. Team leader must enable overage billing for"
|
||||
+ " uninterrupted service.";
|
||||
} else {
|
||||
message =
|
||||
"Insufficient API credits. Please purchase more credits or wait for your"
|
||||
+ " monthly cycle credits to reset.";
|
||||
}
|
||||
|
||||
log.warn(
|
||||
"[CREDIT-DEBUG] Credit validation rejected - Method: {}, URI: {}, IP: {},"
|
||||
+ " User-Agent: {}, User: {}, Supabase ID: {}, Reason: {}",
|
||||
request.getMethod(),
|
||||
request.getRequestURI(),
|
||||
getClientIpAddress(request),
|
||||
request.getHeader("User-Agent"),
|
||||
currentUser.getUsername(),
|
||||
currentUser.getSupabaseId(),
|
||||
message);
|
||||
|
||||
response.setStatus(429); // 429 Too Many Requests
|
||||
response.setContentType("application/json");
|
||||
response.getWriter()
|
||||
.write(
|
||||
String.format(
|
||||
"{\"error\":\"INSUFFICIENT_CREDITS\",\"message\":\"%s\",\"status\":429}",
|
||||
message));
|
||||
response.getWriter().flush();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Log when metered billing users are using overage credits
|
||||
if (!hasSufficientCredits && hasMeteredBilling) {
|
||||
log.info(
|
||||
"[CREDIT-DEBUG] Metered billing user {} proceeding with insufficient free credits (have: {}, need: {}) - will use overage credits (billed monthly)",
|
||||
currentUser.getUsername(),
|
||||
availableCredits,
|
||||
resourceWeight);
|
||||
}
|
||||
|
||||
// Mark request as eligible for credit consumption
|
||||
request.setAttribute(ATTR_CREDIT_ELIGIBLE, Boolean.TRUE);
|
||||
request.setAttribute(ATTR_API_KEY, apiKey);
|
||||
request.setAttribute(ATTR_RESOURCE_WEIGHT, resourceWeight);
|
||||
|
||||
// Store whether this is API key or JWT authentication for advice classes
|
||||
boolean isApiKeyAuth = auth instanceof ApiKeyAuthenticationToken;
|
||||
request.setAttribute("IS_API_KEY_AUTH", isApiKeyAuth);
|
||||
|
||||
// Store IS_API_REQUEST for waterfall logic (API key requests always consume credits)
|
||||
request.setAttribute("IS_API_REQUEST", isApiKeyAuth);
|
||||
|
||||
log.debug(
|
||||
"[CREDIT-DEBUG] Credit validation passed - request marked as eligible for consumption (will consume after success/error)");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postHandle(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Object handler,
|
||||
ModelAndView modelAndView)
|
||||
throws Exception {
|
||||
// Success path now handled by CreditSuccessAdvice - no spending in postHandle anymore
|
||||
log.debug("[CREDIT-DEBUG] postHandle: Success path will be handled by CreditSuccessAdvice");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterCompletion(
|
||||
HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
|
||||
throws Exception {
|
||||
// Error path now handled by CreditErrorAdvice - no spending in afterCompletion anymore
|
||||
if (ex != null) {
|
||||
log.debug(
|
||||
"[CREDIT-DEBUG] afterCompletion: Error path will be handled by CreditErrorAdvice: {}",
|
||||
ex.getClass().getSimpleName());
|
||||
} else {
|
||||
log.debug(
|
||||
"[CREDIT-DEBUG] afterCompletion: Success path already handled by CreditSuccessAdvice");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterConcurrentHandlingStarted(
|
||||
HttpServletRequest request, HttpServletResponse response, Object handler)
|
||||
throws Exception {
|
||||
// For async requests (Callable, DeferredResult, etc.), prevent duplicate processing
|
||||
// The actual postHandle/afterCompletion will be called when async processing completes
|
||||
log.debug(
|
||||
"[CREDIT-DEBUG] afterConcurrentHandlingStarted: Async processing started - skipping interceptor logic");
|
||||
}
|
||||
|
||||
private String maskApiKey(String apiKey) {
|
||||
if (apiKey == null || apiKey.length() < 8) {
|
||||
return "***";
|
||||
}
|
||||
return apiKey.substring(0, 4) + "***" + apiKey.substring(apiKey.length() - 4);
|
||||
}
|
||||
|
||||
private String getClientIpAddress(HttpServletRequest request) {
|
||||
String xForwardedFor = request.getHeader("X-Forwarded-For");
|
||||
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
|
||||
return xForwardedFor.split(",")[0].trim();
|
||||
}
|
||||
|
||||
String xRealIp = request.getHeader("X-Real-IP");
|
||||
if (xRealIp != null && !xRealIp.isEmpty()) {
|
||||
return xRealIp;
|
||||
}
|
||||
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if credit limits should apply to a JWT user.
|
||||
*
|
||||
* <p>Rules:
|
||||
*
|
||||
* <ul>
|
||||
* <li>Metered billing users: always consume (free tier first, then report overage to Stripe)
|
||||
* <li>Anonymous users: consume credits (web/API)
|
||||
* <li>Regular users: consume credits (web/API)
|
||||
* <li>Pro users: unlimited on web UI (waterfall logic handles this), but subject to checks
|
||||
* <li>API users: always consume credits
|
||||
* <li>Internal API users: unlimited everywhere
|
||||
* <li>Admin users: unlimited everywhere
|
||||
* </ul>
|
||||
*/
|
||||
private boolean shouldApplyCreditsToJwtUser(User user) {
|
||||
String roles = user.getRolesAsString();
|
||||
|
||||
// Internal API users are unlimited everywhere (for backend internal operations)
|
||||
if (roles.contains("STIRLING-PDF-BACKEND-API-USER")) {
|
||||
log.debug("[CREDIT-DEBUG] Internal API user {} - unlimited usage", user.getUsername());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Pro users: Let them through to waterfall logic
|
||||
// (Pro gets unlimited UI but API still consumes credits)
|
||||
if (roles.contains("ROLE_PRO_USER")) {
|
||||
log.debug(
|
||||
"[CREDIT-DEBUG] Pro user {} - will be handled by waterfall logic",
|
||||
user.getUsername());
|
||||
return true; // Changed from false - let waterfall handle Pro exemption
|
||||
}
|
||||
|
||||
// Admin users are unlimited everywhere
|
||||
if (roles.contains("ROLE_ADMIN")) {
|
||||
log.debug("[CREDIT-DEBUG] Admin user {} - unlimited usage", user.getUsername());
|
||||
return false;
|
||||
}
|
||||
|
||||
// All other users (anonymous, regular, limited API users, metered billing) consume credits
|
||||
log.debug(
|
||||
"[CREDIT-DEBUG] User {} with roles {} - subject to credit limits",
|
||||
user.getUsername(),
|
||||
roles);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the identifier for credit consumption. For API key users, use their actual API key. For
|
||||
* JWT users, use the Supabase ID as identifier (auth.getName() returns Supabase ID).
|
||||
*/
|
||||
private String getApiKeyForUser(Authentication auth, User user) {
|
||||
if (auth instanceof ApiKeyAuthenticationToken) {
|
||||
return user.getApiKey();
|
||||
} else {
|
||||
// For JWT users, return Supabase ID as the credit consumption identifier
|
||||
return AuthenticationUtils.extractSupabaseId(auth);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if team leader has metered billing enabled. This allows teams to use overage billing
|
||||
* when team credits are exhausted.
|
||||
*
|
||||
* @param team the team to check
|
||||
* @return true if team leader has metered billing enabled
|
||||
*/
|
||||
private boolean checkTeamLeaderMeteredBilling(stirling.software.proprietary.model.Team team) {
|
||||
if (team == null || team.getId() == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
java.util.List<stirling.software.saas.model.TeamMembership> leaders =
|
||||
membershipRepository.findByTeamIdAndRole(
|
||||
team.getId(),
|
||||
stirling.software.common.model.enumeration.TeamRole.LEADER);
|
||||
|
||||
if (leaders.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
User leader = leaders.get(0).getUser();
|
||||
return saasUserExtensionService.isMeteredBillingEnabled(leader);
|
||||
} catch (Exception e) {
|
||||
log.error("Error checking team leader metered billing: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package stirling.software.saas.model;
|
||||
|
||||
/**
|
||||
* Supabase JWT {@code amr} claim values: the authentication methods Supabase records when a user
|
||||
* logs in. Used during anonymous-to-authenticated upgrades to record how the user upgraded.
|
||||
*/
|
||||
public enum AmrMethod {
|
||||
OAUTH("oauth"),
|
||||
PASSWORD("password"),
|
||||
OTP("otp"),
|
||||
TOTP("totp"),
|
||||
RECOVERY("recovery"),
|
||||
INVITE("invite"),
|
||||
SSO_SAML("sso/saml"),
|
||||
MAGICLINK("magiclink"),
|
||||
EMAIL_SIGNUP("email/signup"),
|
||||
EMAIL_CHANGE("email_change"),
|
||||
TOKEN_REFRESH("token_refresh"),
|
||||
ANONYMOUS("anonymous");
|
||||
|
||||
private final String method;
|
||||
|
||||
AmrMethod(String method) {
|
||||
this.method = method;
|
||||
}
|
||||
|
||||
public String getMethod() {
|
||||
return method;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package stirling.software.saas.model;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* Result of a credit consumption attempt with explicit waterfall logic. Indicates whether the
|
||||
* operation succeeded and which credit source was used.
|
||||
*/
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public class CreditConsumptionResult {
|
||||
|
||||
/** Whether the credit consumption succeeded */
|
||||
private boolean success;
|
||||
|
||||
/**
|
||||
* The credit source used for this operation. Possible values: "PRO_PLAN" (Pro user with
|
||||
* unlimited UI access, no credits consumed); "CYCLE_CREDITS" (free monthly cycle credit
|
||||
* allocation); "BOUGHT_CREDITS" (one-time purchased credits); "METERED_SUBSCRIPTION"
|
||||
* (pay-what-you-use metered billing, reported to Stripe); null (operation failed; see message
|
||||
* for reason).
|
||||
*/
|
||||
private String source;
|
||||
|
||||
/** Human-readable message about the result */
|
||||
private String message;
|
||||
|
||||
/**
|
||||
* Creates a successful result for unlimited access (Pro plan UI requests).
|
||||
*
|
||||
* @param source The credit source (typically "PRO_PLAN")
|
||||
* @return CreditConsumptionResult indicating unlimited access
|
||||
*/
|
||||
public static CreditConsumptionResult unlimited(String source) {
|
||||
return new CreditConsumptionResult(true, source, "Unlimited access");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a successful result for credit consumption.
|
||||
*
|
||||
* @param source The credit source used
|
||||
* @return CreditConsumptionResult indicating success
|
||||
*/
|
||||
public static CreditConsumptionResult success(String source) {
|
||||
return new CreditConsumptionResult(true, source, "Credits consumed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a failure result.
|
||||
*
|
||||
* @param reason The reason for failure
|
||||
* @return CreditConsumptionResult indicating failure
|
||||
*/
|
||||
public static CreditConsumptionResult failure(String reason) {
|
||||
return new CreditConsumptionResult(false, null, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a failure result with custom message.
|
||||
*
|
||||
* @param reason The reason code
|
||||
* @param message Custom human-readable message
|
||||
* @return CreditConsumptionResult indicating failure
|
||||
*/
|
||||
public static CreditConsumptionResult failure(String reason, String message) {
|
||||
return new CreditConsumptionResult(false, null, message != null ? message : reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package stirling.software.saas.model;
|
||||
|
||||
public enum ProcessingErrorType {
|
||||
|
||||
/**
|
||||
* Validation errors. should never cost credits. Examples: missing parameters, invalid file
|
||||
* types, size limits exceeded, malformed requests, authentication failures
|
||||
*/
|
||||
VALIDATION_ERROR,
|
||||
|
||||
/**
|
||||
* Processing errors. should cost credits after 3rd attempt per user/endpoint. Examples: corrupt
|
||||
* PDF files, unsupported PDF features, memory issues during processing, OCR failures on valid
|
||||
* PDFs, conversion errors on valid files
|
||||
*/
|
||||
PROCESSING_ERROR,
|
||||
|
||||
/**
|
||||
* System errors. should not cost credits (our fault). Examples: database connection issues,
|
||||
* filesystem problems, service unavailable, internal server errors
|
||||
*/
|
||||
SYSTEM_ERROR;
|
||||
|
||||
/** Determine error type from exception and HTTP status */
|
||||
public static ProcessingErrorType classifyError(
|
||||
Throwable throwable, int httpStatus, String endpoint) {
|
||||
if (throwable == null) {
|
||||
return classifyByHttpStatus(httpStatus);
|
||||
}
|
||||
|
||||
String errorMessage = throwable.getMessage();
|
||||
String exceptionClass = throwable.getClass().getSimpleName();
|
||||
|
||||
// Validation errors (client-side issues)
|
||||
if (httpStatus == 400 || httpStatus == 422) {
|
||||
if (isValidationError(errorMessage, exceptionClass)) {
|
||||
return VALIDATION_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
// Authentication/Authorization errors
|
||||
if (httpStatus == 401 || httpStatus == 403) {
|
||||
return VALIDATION_ERROR;
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
if (httpStatus == 429) {
|
||||
return VALIDATION_ERROR;
|
||||
}
|
||||
|
||||
// System errors (our fault)
|
||||
if (httpStatus >= 500 || isSystemError(errorMessage, exceptionClass)) {
|
||||
return SYSTEM_ERROR;
|
||||
}
|
||||
|
||||
// Processing errors (user's data issue but valid request)
|
||||
if (isProcessingError(errorMessage, exceptionClass, endpoint)) {
|
||||
return PROCESSING_ERROR;
|
||||
}
|
||||
|
||||
// Default to validation error to be safe
|
||||
return VALIDATION_ERROR;
|
||||
}
|
||||
|
||||
private static ProcessingErrorType classifyByHttpStatus(int httpStatus) {
|
||||
if (httpStatus >= 400 && httpStatus < 500) {
|
||||
return VALIDATION_ERROR;
|
||||
} else if (httpStatus >= 500) {
|
||||
return SYSTEM_ERROR;
|
||||
}
|
||||
return VALIDATION_ERROR;
|
||||
}
|
||||
|
||||
private static boolean isValidationError(String errorMessage, String exceptionClass) {
|
||||
if (errorMessage == null && exceptionClass == null) return false;
|
||||
|
||||
String[] validationKeywords = {
|
||||
"validation", "invalid parameter", "missing parameter", "malformed",
|
||||
"bad request", "illegal argument", "file too large", "unsupported file type",
|
||||
"empty file", "no file provided", "invalid format"
|
||||
};
|
||||
|
||||
String[] validationExceptions = {
|
||||
"IllegalArgumentException",
|
||||
"ValidationException",
|
||||
"BindException",
|
||||
"MethodArgumentNotValidException",
|
||||
"MissingServletRequestParameterException",
|
||||
"HttpMessageNotReadableException",
|
||||
"MaxUploadSizeExceededException"
|
||||
};
|
||||
|
||||
return containsAny(errorMessage, validationKeywords)
|
||||
|| containsAny(exceptionClass, validationExceptions);
|
||||
}
|
||||
|
||||
private static boolean isSystemError(String errorMessage, String exceptionClass) {
|
||||
if (errorMessage == null && exceptionClass == null) return false;
|
||||
|
||||
String[] systemExceptions = {
|
||||
"SQLException",
|
||||
"IOException",
|
||||
"OutOfMemoryError",
|
||||
"TimeoutException",
|
||||
"ConnectException",
|
||||
"UnknownHostException",
|
||||
"ServiceUnavailableException"
|
||||
};
|
||||
|
||||
return containsAny(exceptionClass, systemExceptions);
|
||||
}
|
||||
|
||||
private static boolean isProcessingError(
|
||||
String errorMessage, String exceptionClass, String endpoint) {
|
||||
if (errorMessage == null && exceptionClass == null) return false;
|
||||
|
||||
String[] processingExceptions = {
|
||||
"PDFException", "COSVisitorException", "InvalidPDFException",
|
||||
"ConversionException", "OCRException", "ParseException"
|
||||
};
|
||||
|
||||
// If we're checking errors for an endpoint, it's already been identified as a tracked
|
||||
// endpoint
|
||||
// through @AutoJobPostMapping annotation, so we can assume it's a PDF processing endpoint
|
||||
return containsAny(exceptionClass, processingExceptions)
|
||||
|| (endpoint != null && !isValidationError(errorMessage, exceptionClass));
|
||||
}
|
||||
|
||||
private static boolean containsAny(String text, String[] keywords) {
|
||||
if (text == null) return false;
|
||||
String lowerText = text.toLowerCase();
|
||||
for (String keyword : keywords) {
|
||||
if (lowerText.contains(keyword.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package stirling.software.saas.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.OnDelete;
|
||||
import org.hibernate.annotations.OnDeleteAction;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.MapsId;
|
||||
import jakarta.persistence.OneToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.proprietary.model.Team;
|
||||
|
||||
/**
|
||||
* Saas-only sidecar that holds the seat / billing / personal-team metadata for a {@link Team}.
|
||||
* Keeping these off the proprietary {@link Team} entity prevents the {@code teams} table from
|
||||
* acquiring saas-only columns under OSS Hibernate {@code ddl-auto=update}.
|
||||
*
|
||||
* <p>1:1 with {@link Team}; team_id is both PK and FK. Created lazily on first saas-mode access via
|
||||
* {@code SaasTeamExtensionService.getOrCreate(Team)}.
|
||||
*
|
||||
* <p>{@link Version} column on this entity lets seat increments stay atomic without a row lock.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "saas_team_extensions")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class SaasTeamExtensions implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public static final String TEAM_TYPE_PERSONAL = "PERSONAL";
|
||||
public static final String TEAM_TYPE_STANDARD = "STANDARD";
|
||||
|
||||
@Id
|
||||
@Column(name = "team_id")
|
||||
private Long teamId;
|
||||
|
||||
// @MapsId binds this side's PK to the Team PK, so Hibernate populates teamId from the
|
||||
// team reference on persist (no manual setter race). insertable/updatable=false is no
|
||||
// longer needed because @MapsId owns the column.
|
||||
@OneToOne(fetch = FetchType.LAZY)
|
||||
@MapsId
|
||||
@JoinColumn(name = "team_id")
|
||||
@OnDelete(action = OnDeleteAction.CASCADE)
|
||||
private Team team;
|
||||
|
||||
@Column(name = "team_type", nullable = false)
|
||||
private String teamType = TEAM_TYPE_STANDARD;
|
||||
|
||||
@Column(name = "is_personal", nullable = false)
|
||||
private Boolean isPersonal = Boolean.FALSE;
|
||||
|
||||
@Column(name = "seat_count", nullable = false)
|
||||
private Integer seatCount = 1;
|
||||
|
||||
@Column(name = "seats_used", nullable = false)
|
||||
private Integer seatsUsed = 0;
|
||||
|
||||
@Column(name = "max_seats", nullable = false)
|
||||
private Integer maxSeats = 1;
|
||||
|
||||
@Column(name = "created_by_user_id")
|
||||
private Long createdByUserId;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@Version
|
||||
@Column(name = "version")
|
||||
private Long version;
|
||||
|
||||
public SaasTeamExtensions(Team team) {
|
||||
this.team = team;
|
||||
this.teamId = team.getId();
|
||||
}
|
||||
|
||||
/** Convenience boolean accessor. */
|
||||
public boolean isPersonal() {
|
||||
return Boolean.TRUE.equals(isPersonal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this team has unused seats. Personal teams enforce a 1-seat limit; standard teams are
|
||||
* unlimited.
|
||||
*/
|
||||
public boolean hasAvailableSeats() {
|
||||
if (isPersonal()) {
|
||||
return seatsUsed != null && maxSeats != null && seatsUsed < maxSeats;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this team accepts new invitations. Personal teams (1 seat, owned by one user) never
|
||||
* do; standard teams always do.
|
||||
*/
|
||||
public boolean canInviteMembers() {
|
||||
return !isPersonal();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package stirling.software.saas.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.OnDelete;
|
||||
import org.hibernate.annotations.OnDeleteAction;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.MapsId;
|
||||
import jakarta.persistence.OneToOne;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/**
|
||||
* Saas-only sidecar that holds user-level fields irrelevant to OSS / proprietary deployments
|
||||
* (metered-billing flag, API-key first-use audit timestamp). Keeping these off the proprietary
|
||||
* {@link User} entity prevents the {@code users} table from acquiring saas-only columns under OSS
|
||||
* Hibernate {@code ddl-auto=update}.
|
||||
*
|
||||
* <p>1:1 with {@link User}; user_id is both PK and FK. Created lazily on first saas-mode access via
|
||||
* {@code SaasUserExtensionService.getOrCreate(User)}.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "saas_user_extensions")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class SaasUserExtensions implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "user_id")
|
||||
private Long userId;
|
||||
|
||||
@OneToOne(fetch = FetchType.LAZY)
|
||||
@MapsId
|
||||
@JoinColumn(name = "user_id")
|
||||
@OnDelete(action = OnDeleteAction.CASCADE)
|
||||
private User user;
|
||||
|
||||
@Column(name = "has_metered_billing_enabled", nullable = false)
|
||||
private Boolean hasMeteredBillingEnabled = Boolean.FALSE;
|
||||
|
||||
@Column(name = "api_key_first_used_at")
|
||||
private LocalDateTime apiKeyFirstUsedAt;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public SaasUserExtensions(User user) {
|
||||
// @MapsId derives userId from user.id at persist time; setting both keeps in-memory
|
||||
// reads consistent before flush.
|
||||
this.user = user;
|
||||
this.userId = user.getId();
|
||||
}
|
||||
|
||||
public boolean isMeteredBillingEnabled() {
|
||||
return Boolean.TRUE.equals(hasMeteredBillingEnabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package stirling.software.saas.model;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** Read-only mirror of Supabase's {@code auth.users} table. */
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "users", schema = "auth")
|
||||
public class SupabaseUser {
|
||||
|
||||
@Id
|
||||
@Column(name = "id", unique = true, nullable = false, updatable = false)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "email", unique = true)
|
||||
private String email;
|
||||
|
||||
@Column(name = "is_sso_user")
|
||||
private boolean isSSOUser;
|
||||
|
||||
@Column(name = "is_anonymous")
|
||||
private boolean isAnonymous;
|
||||
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package stirling.software.saas.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.OnDelete;
|
||||
import org.hibernate.annotations.OnDeleteAction;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.OneToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.proprietary.model.Team;
|
||||
|
||||
/** Shared credit pool for multi-member teams; see {@link UserCredit} for the per-user variant. */
|
||||
@Entity
|
||||
@Table(name = "team_credits")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class TeamCredit implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "credit_id")
|
||||
private Long id;
|
||||
|
||||
@OneToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "team_id", nullable = false, unique = true)
|
||||
@OnDelete(action = OnDeleteAction.CASCADE)
|
||||
private Team team;
|
||||
|
||||
@Column(name = "cycle_credits_remaining")
|
||||
private Integer cycleCreditsRemaining = 0;
|
||||
|
||||
@Column(name = "cycle_credits_allocated")
|
||||
private Integer cycleCreditsAllocated = 0;
|
||||
|
||||
@Column(name = "bought_credits_remaining")
|
||||
private Integer boughtCreditsRemaining = 0;
|
||||
|
||||
@Column(name = "total_bought_credits")
|
||||
private Integer totalBoughtCredits = 0;
|
||||
|
||||
@Column(name = "last_cycle_reset_at")
|
||||
private LocalDateTime lastCycleResetAt;
|
||||
|
||||
@Column(name = "last_api_usage")
|
||||
private LocalDateTime lastApiUsage;
|
||||
|
||||
@Column(name = "total_api_calls_made")
|
||||
private Long totalApiCallsMade = 0L;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@Version
|
||||
@Column(name = "version")
|
||||
private Long version;
|
||||
|
||||
public TeamCredit(Team team) {
|
||||
this.team = team;
|
||||
}
|
||||
|
||||
public int getTotalAvailableCredits() {
|
||||
return (cycleCreditsRemaining != null ? cycleCreditsRemaining : 0)
|
||||
+ (boughtCreditsRemaining != null ? boughtCreditsRemaining : 0);
|
||||
}
|
||||
|
||||
public boolean hasCreditsAvailable() {
|
||||
return getTotalAvailableCredits() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume a credit from the team pool. Consumes cycle credits first, then bought credits.
|
||||
*
|
||||
* @return true if a credit was consumed, false if no credits available
|
||||
*/
|
||||
public boolean consumeCredit() {
|
||||
if (cycleCreditsRemaining != null && cycleCreditsRemaining > 0) {
|
||||
cycleCreditsRemaining--;
|
||||
totalApiCallsMade++;
|
||||
lastApiUsage = LocalDateTime.now();
|
||||
return true;
|
||||
} else if (boughtCreditsRemaining != null && boughtCreditsRemaining > 0) {
|
||||
boughtCreditsRemaining--;
|
||||
totalApiCallsMade++;
|
||||
lastApiUsage = LocalDateTime.now();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public void addBoughtCredits(int credits) {
|
||||
if (credits > 0) {
|
||||
boughtCreditsRemaining =
|
||||
(boughtCreditsRemaining != null ? boughtCreditsRemaining : 0) + credits;
|
||||
totalBoughtCredits = (totalBoughtCredits != null ? totalBoughtCredits : 0) + credits;
|
||||
}
|
||||
}
|
||||
|
||||
public void resetCycleCredits(int cycleAllocation, LocalDateTime resetTime) {
|
||||
this.cycleCreditsAllocated = cycleAllocation;
|
||||
this.cycleCreditsRemaining = cycleAllocation;
|
||||
this.lastCycleResetAt = resetTime;
|
||||
}
|
||||
|
||||
public boolean isCycleResetDue(LocalDateTime lastScheduledReset) {
|
||||
return lastCycleResetAt == null || lastCycleResetAt.isBefore(lastScheduledReset);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package stirling.software.saas.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
|
||||
import stirling.software.common.model.enumeration.InvitationStatus;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/**
|
||||
* Team invitation entity tracking email-based team invitations with unique tokens. Invitations
|
||||
* expire after 7 days and can be in PENDING, ACCEPTED, REJECTED, or EXPIRED status.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "team_invitations")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
|
||||
@ToString(onlyExplicitlyIncluded = true)
|
||||
public class TeamInvitation implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "invitation_id")
|
||||
@EqualsAndHashCode.Include
|
||||
@ToString.Include
|
||||
private Long invitationId;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "team_id", nullable = false)
|
||||
private Team team;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "inviter_user_id", nullable = false)
|
||||
private User inviter;
|
||||
|
||||
@Column(name = "invitee_email", nullable = false)
|
||||
@ToString.Include
|
||||
private String inviteeEmail;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "invitee_user_id")
|
||||
private User inviteeUser;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false)
|
||||
@ToString.Include
|
||||
private InvitationStatus status = InvitationStatus.PENDING;
|
||||
|
||||
@Column(name = "invitation_token", unique = true, nullable = false)
|
||||
@EqualsAndHashCode.Include
|
||||
@ToString.Include
|
||||
private String invitationToken;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private LocalDateTime expiresAt;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* Check if the invitation has expired
|
||||
*
|
||||
* @return true if current time is after expiration time
|
||||
*/
|
||||
public boolean isExpired() {
|
||||
return expiresAt != null && LocalDateTime.now().isAfter(expiresAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the invitation is still pending and not expired
|
||||
*
|
||||
* @return true if status is PENDING and not expired
|
||||
*/
|
||||
public boolean isPending() {
|
||||
return status == InvitationStatus.PENDING && !isExpired();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the invitation was accepted
|
||||
*
|
||||
* @return true if status is ACCEPTED
|
||||
*/
|
||||
public boolean isAccepted() {
|
||||
return status == InvitationStatus.ACCEPTED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the invitation was rejected
|
||||
*
|
||||
* @return true if status is REJECTED
|
||||
*/
|
||||
public boolean isRejected() {
|
||||
return status == InvitationStatus.REJECTED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package stirling.software.saas.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
|
||||
import stirling.software.common.model.enumeration.TeamRole;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/**
|
||||
* Team membership entity tracking which users belong to which teams and their roles. Supports
|
||||
* LEADER (can invite/remove members, manage settings) and MEMBER (regular user) roles.
|
||||
*/
|
||||
@Entity
|
||||
@Table(
|
||||
name = "team_memberships",
|
||||
uniqueConstraints = {@UniqueConstraint(columnNames = {"team_id", "user_id"})})
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
|
||||
@ToString(onlyExplicitlyIncluded = true)
|
||||
public class TeamMembership implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "membership_id")
|
||||
@EqualsAndHashCode.Include
|
||||
@ToString.Include
|
||||
private Long membershipId;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "team_id", nullable = false)
|
||||
private Team team;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User user;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "role", nullable = false)
|
||||
@ToString.Include
|
||||
private TeamRole role = TeamRole.MEMBER;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "invited_by_user_id")
|
||||
private User invitedBy;
|
||||
|
||||
@Column(name = "invited_at", nullable = false)
|
||||
private LocalDateTime invitedAt;
|
||||
|
||||
@Column(name = "accepted_at")
|
||||
private LocalDateTime acceptedAt;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public boolean isLeader() {
|
||||
return role == TeamRole.LEADER;
|
||||
}
|
||||
|
||||
public boolean isMember() {
|
||||
return role == TeamRole.MEMBER;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package stirling.software.saas.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.OnDelete;
|
||||
import org.hibernate.annotations.OnDeleteAction;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/**
|
||||
* Per-user credit pool. Layers a renewable monthly cycle pool ({@code cycleCreditsRemaining}) over
|
||||
* a non-expiring purchased pool ({@code boughtCreditsRemaining}); cycle credits consume first.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "user_credits")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class UserCredit implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "credit_id")
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
@OnDelete(action = OnDeleteAction.CASCADE)
|
||||
private User user;
|
||||
|
||||
@Column(name = "cycle_credits_remaining")
|
||||
private Integer cycleCreditsRemaining = 0;
|
||||
|
||||
@Column(name = "cycle_credits_allocated")
|
||||
private Integer cycleCreditsAllocated = 0;
|
||||
|
||||
@Column(name = "bought_credits_remaining")
|
||||
private Integer boughtCreditsRemaining = 0;
|
||||
|
||||
@Column(name = "total_bought_credits")
|
||||
private Integer totalBoughtCredits = 0;
|
||||
|
||||
@Column(name = "last_cycle_reset_at")
|
||||
private LocalDateTime lastCycleResetAt;
|
||||
|
||||
@Column(name = "last_api_usage")
|
||||
private LocalDateTime lastApiUsage;
|
||||
|
||||
@Column(name = "total_api_calls_made")
|
||||
private Long totalApiCallsMade = 0L;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@Version
|
||||
@Column(name = "version")
|
||||
private Long version;
|
||||
|
||||
public UserCredit(User user) {
|
||||
this.user = user;
|
||||
// Cycle credits are initialized by CreditService after this object is created,
|
||||
// typically during user registration or at the start of a new billing cycle,
|
||||
// using values from the application configuration.
|
||||
}
|
||||
|
||||
public int getTotalAvailableCredits() {
|
||||
return (cycleCreditsRemaining != null ? cycleCreditsRemaining : 0)
|
||||
+ (boughtCreditsRemaining != null ? boughtCreditsRemaining : 0);
|
||||
}
|
||||
|
||||
public boolean hasCreditsAvailable() {
|
||||
return getTotalAvailableCredits() > 0;
|
||||
}
|
||||
|
||||
public boolean consumeCredit() {
|
||||
// Consume cycle credits first, then bought credits.
|
||||
if (cycleCreditsRemaining != null && cycleCreditsRemaining > 0) {
|
||||
cycleCreditsRemaining--;
|
||||
totalApiCallsMade++;
|
||||
lastApiUsage = LocalDateTime.now();
|
||||
return true;
|
||||
} else if (boughtCreditsRemaining != null && boughtCreditsRemaining > 0) {
|
||||
boughtCreditsRemaining--;
|
||||
totalApiCallsMade++;
|
||||
lastApiUsage = LocalDateTime.now();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public void addBoughtCredits(int credits) {
|
||||
if (credits > 0) {
|
||||
boughtCreditsRemaining =
|
||||
(boughtCreditsRemaining != null ? boughtCreditsRemaining : 0) + credits;
|
||||
totalBoughtCredits = (totalBoughtCredits != null ? totalBoughtCredits : 0) + credits;
|
||||
}
|
||||
}
|
||||
|
||||
public void resetCycleCredits(int cycleAllocation, LocalDateTime resetTime) {
|
||||
this.cycleCreditsAllocated = cycleAllocation;
|
||||
this.cycleCreditsRemaining = cycleAllocation;
|
||||
this.lastCycleResetAt = resetTime;
|
||||
}
|
||||
|
||||
public boolean isCycleResetDue(LocalDateTime lastScheduledReset) {
|
||||
return lastCycleResetAt == null || lastCycleResetAt.isBefore(lastScheduledReset);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package stirling.software.saas.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
@Entity
|
||||
@Table(name = "user_error_tracker")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class UserErrorTracker implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "error_tracker_id")
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User user;
|
||||
|
||||
@Column(name = "endpoint")
|
||||
private String endpoint;
|
||||
|
||||
@Column(name = "processing_error_count")
|
||||
private Integer processingErrorCount = 0;
|
||||
|
||||
@Column(name = "last_processing_error")
|
||||
private LocalDateTime lastProcessingError;
|
||||
|
||||
@Column(name = "reset_after")
|
||||
private LocalDateTime resetAfter;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public UserErrorTracker(User user, String endpoint, int ttlMinutes) {
|
||||
this.user = user;
|
||||
this.endpoint = endpoint;
|
||||
this.resetAfter = LocalDateTime.now().plusMinutes(ttlMinutes);
|
||||
}
|
||||
|
||||
public boolean shouldChargeForProcessingError(int freeProcessingErrors) {
|
||||
return processingErrorCount != null && processingErrorCount > freeProcessingErrors;
|
||||
}
|
||||
|
||||
public void recordProcessingError(int ttlMinutes) {
|
||||
this.processingErrorCount = (processingErrorCount != null ? processingErrorCount : 0) + 1;
|
||||
this.lastProcessingError = LocalDateTime.now();
|
||||
|
||||
// Refresh TTL on each error
|
||||
this.resetAfter = LocalDateTime.now().plusMinutes(ttlMinutes);
|
||||
}
|
||||
|
||||
public void resetErrorCount(int ttlMinutes) {
|
||||
this.processingErrorCount = 0;
|
||||
this.lastProcessingError = null;
|
||||
this.resetAfter = LocalDateTime.now().plusMinutes(ttlMinutes);
|
||||
}
|
||||
|
||||
public boolean isExpired() {
|
||||
return resetAfter != null && LocalDateTime.now().isAfter(resetAfter);
|
||||
}
|
||||
|
||||
public int getErrorsUntilCharged(int freeProcessingErrors) {
|
||||
int current = processingErrorCount != null ? processingErrorCount : 0;
|
||||
return Math.max(0, freeProcessingErrors + 1 - current);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package stirling.software.saas.model.exception;
|
||||
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
|
||||
public class AuthenticationFailureException extends AuthenticationException {
|
||||
|
||||
public AuthenticationFailureException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public AuthenticationFailureException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package stirling.software.saas.model.exception;
|
||||
|
||||
public class UserNotFoundException extends RuntimeException {
|
||||
|
||||
public UserNotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Stirling-PDF SaaS module: Supabase-backed authentication, Stripe metered billing, and SaaS-only
|
||||
* audit/aspect components.
|
||||
*/
|
||||
package stirling.software.saas;
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package stirling.software.saas.repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.saas.model.SaasTeamExtensions;
|
||||
|
||||
@Repository
|
||||
public interface SaasTeamExtensionsRepository extends JpaRepository<SaasTeamExtensions, Long> {
|
||||
|
||||
Optional<SaasTeamExtensions> findByTeamId(Long teamId);
|
||||
|
||||
/**
|
||||
* Atomic seat increment. Personal teams enforce a strict {@code seatsUsed < maxSeats}
|
||||
* ceiling; standard (non-personal) teams have no cap (mirrors {@link
|
||||
* SaasTeamExtensions#hasAvailableSeats()}). Returns 1 on success, 0 if the cap was hit.
|
||||
*/
|
||||
@Modifying
|
||||
@Query(
|
||||
"UPDATE SaasTeamExtensions e SET e.seatsUsed = e.seatsUsed + 1 "
|
||||
+ "WHERE e.teamId = :teamId AND "
|
||||
+ "(e.isPersonal = TRUE AND e.seatsUsed < e.maxSeats OR e.isPersonal = FALSE)")
|
||||
int incrementSeatsUsed(@Param("teamId") Long teamId);
|
||||
|
||||
/** Atomic seat decrement. Floor at 0. Returns 1 on a real decrement, 0 if already at 0. */
|
||||
@Modifying
|
||||
@Query(
|
||||
"UPDATE SaasTeamExtensions e SET e.seatsUsed = e.seatsUsed - 1 "
|
||||
+ "WHERE e.teamId = :teamId AND e.seatsUsed > 0")
|
||||
int decrementSeatsUsed(@Param("teamId") Long teamId);
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package stirling.software.saas.repository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.saas.model.SaasUserExtensions;
|
||||
|
||||
@Repository
|
||||
public interface SaasUserExtensionsRepository extends JpaRepository<SaasUserExtensions, Long> {
|
||||
|
||||
Optional<SaasUserExtensions> findByUserId(Long userId);
|
||||
|
||||
@Query("SELECT e FROM SaasUserExtensions e WHERE e.user.supabaseId = :supabaseId")
|
||||
Optional<SaasUserExtensions> findBySupabaseId(@Param("supabaseId") UUID supabaseId);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package stirling.software.saas.repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.saas.model.SupabaseUser;
|
||||
|
||||
@Repository
|
||||
public interface SupabaseUserRepository extends JpaRepository<SupabaseUser, UUID> {
|
||||
|
||||
/**
|
||||
* Anonymous users created before the cut-off date. Used by the cleanup job to drop stale
|
||||
* anonymous sessions in batch (avoids long-running transactions on a single big delete).
|
||||
*/
|
||||
@Query(
|
||||
"SELECT s.id FROM SupabaseUser s WHERE s.isAnonymous = true AND s.createdAt < :cutoffDate")
|
||||
Stream<UUID> findByCreatedAtBeforeAndIsAnonymousTrue(
|
||||
@Param("cutoffDate") LocalDateTime cutoffDate);
|
||||
|
||||
@Modifying(clearAutomatically = true)
|
||||
@Query("DELETE FROM SupabaseUser u WHERE u.id IN :ids")
|
||||
void deleteAllByIdInBatch(@Param("ids") List<UUID> ids);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package stirling.software.saas.repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.saas.model.TeamCredit;
|
||||
|
||||
@Repository
|
||||
public interface TeamCreditRepository extends JpaRepository<TeamCredit, Long> {
|
||||
|
||||
/** Find team credits by team ID. */
|
||||
@Query("SELECT tc FROM TeamCredit tc WHERE tc.team.id = :teamId")
|
||||
Optional<TeamCredit> findByTeamId(@Param("teamId") Long teamId);
|
||||
|
||||
/**
|
||||
* Atomically consume credits from the team pool. Uses the {@code @Version} column on {@link
|
||||
* TeamCredit} for optimistic locking - concurrent attempts will fail-fast rather than
|
||||
* over-deduct. Returns 1 on success, 0 if insufficient balance or version conflict.
|
||||
*/
|
||||
@Modifying
|
||||
@Query(
|
||||
value =
|
||||
"""
|
||||
UPDATE team_credits
|
||||
SET cycle_credits_remaining = CASE
|
||||
WHEN cycle_credits_remaining >= :amount THEN cycle_credits_remaining - :amount
|
||||
WHEN cycle_credits_remaining > 0 AND bought_credits_remaining >= (:amount - cycle_credits_remaining)
|
||||
THEN 0
|
||||
ELSE cycle_credits_remaining
|
||||
END,
|
||||
bought_credits_remaining = CASE
|
||||
WHEN cycle_credits_remaining >= :amount THEN bought_credits_remaining
|
||||
WHEN cycle_credits_remaining > 0 AND bought_credits_remaining >= (:amount - cycle_credits_remaining)
|
||||
THEN bought_credits_remaining - (:amount - cycle_credits_remaining)
|
||||
WHEN cycle_credits_remaining = 0 AND bought_credits_remaining >= :amount
|
||||
THEN bought_credits_remaining - :amount
|
||||
ELSE bought_credits_remaining
|
||||
END,
|
||||
total_api_calls_made = total_api_calls_made + :amount,
|
||||
last_api_usage = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP,
|
||||
version = version + 1
|
||||
WHERE team_id = :teamId
|
||||
AND (cycle_credits_remaining + bought_credits_remaining) >= :amount
|
||||
""",
|
||||
nativeQuery = true)
|
||||
int consumeCredit(@Param("teamId") Long teamId, @Param("amount") int amount);
|
||||
|
||||
@Query(
|
||||
"SELECT CASE WHEN COUNT(tc) > 0 THEN true ELSE false END FROM TeamCredit tc WHERE tc.team.id = :teamId")
|
||||
boolean existsByTeamId(@Param("teamId") Long teamId);
|
||||
|
||||
@Modifying
|
||||
@Query("DELETE FROM TeamCredit tc WHERE tc.team.id = :teamId")
|
||||
void deleteByTeamId(@Param("teamId") Long teamId);
|
||||
|
||||
@Query(
|
||||
"SELECT tc FROM TeamCredit tc WHERE tc.lastCycleResetAt IS NULL OR tc.lastCycleResetAt < :lastScheduledReset")
|
||||
List<TeamCredit> findCreditsNeedingCycleReset(
|
||||
@Param("lastScheduledReset") LocalDateTime lastScheduledReset);
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package stirling.software.saas.repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.common.model.enumeration.InvitationStatus;
|
||||
import stirling.software.saas.model.TeamInvitation;
|
||||
|
||||
@Repository
|
||||
public interface TeamInvitationRepository extends JpaRepository<TeamInvitation, Long> {
|
||||
|
||||
/**
|
||||
* Find invitation by unique token
|
||||
*
|
||||
* @param token the invitation token
|
||||
* @return Optional of TeamInvitation if found
|
||||
*/
|
||||
@Query(
|
||||
"SELECT ti FROM TeamInvitation ti JOIN FETCH ti.team JOIN FETCH ti.inviter WHERE ti.invitationToken = :token")
|
||||
Optional<TeamInvitation> findByInvitationToken(@Param("token") String token);
|
||||
|
||||
/**
|
||||
* Find all invitations sent to an email address
|
||||
*
|
||||
* @param email the invitee email
|
||||
* @return List of invitations
|
||||
*/
|
||||
@Query(
|
||||
"SELECT ti FROM TeamInvitation ti JOIN FETCH ti.team JOIN FETCH ti.inviter WHERE ti.inviteeEmail = :email")
|
||||
List<TeamInvitation> findByInviteeEmail(@Param("email") String email);
|
||||
|
||||
/**
|
||||
* Find pending invitations for an email address (not expired)
|
||||
*
|
||||
* @param email the invitee email
|
||||
* @return List of pending invitations
|
||||
*/
|
||||
@Query(
|
||||
"SELECT ti FROM TeamInvitation ti JOIN FETCH ti.team JOIN FETCH ti.inviter "
|
||||
+ "WHERE ti.inviteeEmail = :email AND ti.status = 'PENDING' AND ti.expiresAt > :now")
|
||||
List<TeamInvitation> findPendingInvitationsByEmail(
|
||||
@Param("email") String email, @Param("now") LocalDateTime now);
|
||||
|
||||
/**
|
||||
* Find all invitations for a team
|
||||
*
|
||||
* @param teamId the team ID
|
||||
* @return List of invitations
|
||||
*/
|
||||
@Query("SELECT ti FROM TeamInvitation ti JOIN FETCH ti.inviter WHERE ti.team.id = :teamId")
|
||||
List<TeamInvitation> findByTeamId(@Param("teamId") Long teamId);
|
||||
|
||||
/**
|
||||
* Find all invitations sent by a user
|
||||
*
|
||||
* @param inviterUserId the inviter user ID
|
||||
* @return List of invitations
|
||||
*/
|
||||
@Query(
|
||||
"SELECT ti FROM TeamInvitation ti JOIN FETCH ti.team WHERE ti.inviter.id = :inviterUserId")
|
||||
List<TeamInvitation> findByInviterUserId(@Param("inviterUserId") Long inviterUserId);
|
||||
|
||||
/**
|
||||
* Mark expired invitations as EXPIRED
|
||||
*
|
||||
* @param now current timestamp
|
||||
* @return number of invitations marked as expired
|
||||
*/
|
||||
@Modifying
|
||||
@Query(
|
||||
"UPDATE TeamInvitation ti SET ti.status = 'EXPIRED' WHERE ti.status = 'PENDING' AND ti.expiresAt < :now")
|
||||
int markExpiredInvitations(@Param("now") LocalDateTime now);
|
||||
|
||||
/**
|
||||
* Check if there's already a pending invitation for this email to this team
|
||||
*
|
||||
* @param teamId the team ID
|
||||
* @param email the invitee email
|
||||
* @return true if pending invitation exists
|
||||
*/
|
||||
@Query(
|
||||
"SELECT COUNT(ti) > 0 FROM TeamInvitation ti WHERE ti.team.id = :teamId "
|
||||
+ "AND ti.inviteeEmail = :email AND ti.status = 'PENDING'")
|
||||
boolean existsPendingInvitationByTeamIdAndEmail(
|
||||
@Param("teamId") Long teamId, @Param("email") String email);
|
||||
|
||||
/**
|
||||
* Find invitations by status
|
||||
*
|
||||
* @param status the invitation status
|
||||
* @return List of invitations with given status
|
||||
*/
|
||||
List<TeamInvitation> findByStatus(InvitationStatus status);
|
||||
|
||||
/**
|
||||
* Find invitations by status that expired before a given date
|
||||
*
|
||||
* @param status the invitation status
|
||||
* @param cutoffDate the cutoff expiration date
|
||||
* @return List of invitations
|
||||
*/
|
||||
List<TeamInvitation> findByStatusAndExpiresAtBefore(
|
||||
InvitationStatus status, LocalDateTime cutoffDate);
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package stirling.software.saas.repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.common.model.enumeration.TeamRole;
|
||||
import stirling.software.saas.model.TeamMembership;
|
||||
|
||||
@Repository
|
||||
public interface TeamMembershipRepository extends JpaRepository<TeamMembership, Long> {
|
||||
|
||||
/**
|
||||
* Find team membership by team ID and user ID
|
||||
*
|
||||
* @param teamId the team ID
|
||||
* @param userId the user ID
|
||||
* @return Optional of TeamMembership if found
|
||||
*/
|
||||
Optional<TeamMembership> findByTeamIdAndUserId(Long teamId, Long userId);
|
||||
|
||||
/**
|
||||
* Find all memberships for a team
|
||||
*
|
||||
* @param teamId the team ID
|
||||
* @return List of team memberships
|
||||
*/
|
||||
@Query("SELECT tm FROM TeamMembership tm JOIN FETCH tm.user WHERE tm.team.id = :teamId")
|
||||
List<TeamMembership> findByTeamId(@Param("teamId") Long teamId);
|
||||
|
||||
/**
|
||||
* Find all memberships for a user (typically just one for personal team, but can be multiple if
|
||||
* invited to other teams)
|
||||
*
|
||||
* @param userId the user ID
|
||||
* @return List of team memberships
|
||||
*/
|
||||
@Query("SELECT tm FROM TeamMembership tm JOIN FETCH tm.team WHERE tm.user.id = :userId")
|
||||
List<TeamMembership> findByUserId(@Param("userId") Long userId);
|
||||
|
||||
/**
|
||||
* Find all members with a specific role in a team
|
||||
*
|
||||
* @param teamId the team ID
|
||||
* @param role the team role (LEADER or MEMBER)
|
||||
* @return List of team memberships
|
||||
*/
|
||||
@Query(
|
||||
"SELECT tm FROM TeamMembership tm JOIN FETCH tm.user WHERE tm.team.id = :teamId AND tm.role = :role")
|
||||
List<TeamMembership> findByTeamIdAndRole(
|
||||
@Param("teamId") Long teamId, @Param("role") TeamRole role);
|
||||
|
||||
/**
|
||||
* Check if a user is a member of a team
|
||||
*
|
||||
* @param teamId the team ID
|
||||
* @param userId the user ID
|
||||
* @return true if user is a member
|
||||
*/
|
||||
boolean existsByTeamIdAndUserId(Long teamId, Long userId);
|
||||
|
||||
/**
|
||||
* Count members in a team
|
||||
*
|
||||
* @param teamId the team ID
|
||||
* @return number of members
|
||||
*/
|
||||
long countByTeamId(Long teamId);
|
||||
|
||||
/**
|
||||
* Delete membership by team ID and user ID
|
||||
*
|
||||
* @param teamId the team ID
|
||||
* @param userId the user ID
|
||||
*/
|
||||
void deleteByTeamIdAndUserId(Long teamId, Long userId);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package stirling.software.saas.repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.model.UserCredit;
|
||||
|
||||
/**
|
||||
* JPA repository for {@link UserCredit}. Includes JPQL queries for the common read paths and native
|
||||
* SQL for atomic credit-consumption updates (avoids select-then-update races).
|
||||
*
|
||||
* <p>Native queries reference {@code user_credits} and {@code users} unqualified — they pick up
|
||||
* Hibernate's {@code default_schema} (set to {@code stirling_pdf} in {@code
|
||||
* application-saas.properties}). Keeping the schema out of the SQL means a future schema rename is
|
||||
* a one-property change instead of a sweep of native SQL.
|
||||
*/
|
||||
@Repository
|
||||
public interface UserCreditRepository extends JpaRepository<UserCredit, Long> {
|
||||
|
||||
Optional<UserCredit> findByUser(User user);
|
||||
|
||||
Optional<UserCredit> findByUserId(Long userId);
|
||||
|
||||
@Query(
|
||||
"SELECT uc FROM UserCredit uc WHERE uc.lastCycleResetAt IS NULL OR uc.lastCycleResetAt < :lastScheduledReset")
|
||||
List<UserCredit> findCreditsNeedingCycleReset(
|
||||
@Param("lastScheduledReset") LocalDateTime lastScheduledReset);
|
||||
|
||||
@Query("SELECT SUM(uc.totalApiCallsMade) FROM UserCredit uc")
|
||||
Long getTotalApiCallsAcrossAllUsers();
|
||||
|
||||
@Query("SELECT SUM(uc.cycleCreditsRemaining + uc.boughtCreditsRemaining) FROM UserCredit uc")
|
||||
Long getTotalAvailableCreditsAcrossAllUsers();
|
||||
|
||||
@Query("SELECT uc FROM UserCredit uc WHERE uc.user.apiKey = :apiKey")
|
||||
Optional<UserCredit> findByUserApiKey(@Param("apiKey") String apiKey);
|
||||
|
||||
@Query("SELECT uc FROM UserCredit uc WHERE uc.user.supabaseId = :supabaseId")
|
||||
Optional<UserCredit> findBySupabaseId(@Param("supabaseId") UUID supabaseId);
|
||||
|
||||
@Query("SELECT COUNT(uc) FROM UserCredit uc WHERE uc.lastApiUsage >= :since")
|
||||
Long countActiveUsersInPeriod(@Param("since") LocalDateTime since);
|
||||
|
||||
@Modifying
|
||||
@Query(
|
||||
value =
|
||||
"UPDATE user_credits "
|
||||
+ "SET "
|
||||
+ " cycle_credits_remaining = "
|
||||
+ " CASE "
|
||||
+ " WHEN cycle_credits_remaining >= :creditAmount THEN cycle_credits_remaining - :creditAmount "
|
||||
+ " ELSE 0 "
|
||||
+ " END, "
|
||||
+ " bought_credits_remaining = "
|
||||
+ " CASE "
|
||||
+ " WHEN cycle_credits_remaining < :creditAmount "
|
||||
+ " THEN GREATEST(0, bought_credits_remaining - (:creditAmount - cycle_credits_remaining)) "
|
||||
+ " ELSE bought_credits_remaining "
|
||||
+ " END, "
|
||||
+ " total_api_calls_made = total_api_calls_made + 1, "
|
||||
+ " last_api_usage = now() "
|
||||
+ "WHERE user_id = (SELECT user_id FROM users WHERE api_key = :apiKey) "
|
||||
+ " AND (cycle_credits_remaining + bought_credits_remaining >= :creditAmount)",
|
||||
nativeQuery = true)
|
||||
int consumeCredit(@Param("apiKey") String apiKey, @Param("creditAmount") int creditAmount);
|
||||
|
||||
@Modifying
|
||||
@Query(
|
||||
value =
|
||||
"UPDATE user_credits "
|
||||
+ "SET "
|
||||
+ " cycle_credits_remaining = "
|
||||
+ " CASE "
|
||||
+ " WHEN cycle_credits_remaining >= :creditAmount THEN cycle_credits_remaining - :creditAmount "
|
||||
+ " ELSE 0 "
|
||||
+ " END, "
|
||||
+ " bought_credits_remaining = "
|
||||
+ " CASE "
|
||||
+ " WHEN cycle_credits_remaining < :creditAmount "
|
||||
+ " THEN GREATEST(0, bought_credits_remaining - (:creditAmount - cycle_credits_remaining)) "
|
||||
+ " ELSE bought_credits_remaining "
|
||||
+ " END, "
|
||||
+ " total_api_calls_made = total_api_calls_made + 1, "
|
||||
+ " last_api_usage = now() "
|
||||
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId) "
|
||||
+ " AND (cycle_credits_remaining + bought_credits_remaining >= :creditAmount)",
|
||||
nativeQuery = true)
|
||||
int consumeCreditBySupabaseId(
|
||||
@Param("supabaseId") UUID supabaseId, @Param("creditAmount") int creditAmount);
|
||||
|
||||
/**
|
||||
* Consumes ONLY cycle credits (does not touch bought credits). Used in explicit waterfall
|
||||
* logic.
|
||||
*/
|
||||
@Modifying
|
||||
@Query(
|
||||
value =
|
||||
"UPDATE user_credits "
|
||||
+ "SET "
|
||||
+ " cycle_credits_remaining = cycle_credits_remaining - :amount, "
|
||||
+ " total_api_calls_made = total_api_calls_made + 1, "
|
||||
+ " last_api_usage = now() "
|
||||
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId) "
|
||||
+ " AND cycle_credits_remaining >= :amount",
|
||||
nativeQuery = true)
|
||||
int consumeCycleCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
|
||||
|
||||
/** Consumes ONLY bought credits (does not touch cycle credits). */
|
||||
@Modifying
|
||||
@Query(
|
||||
value =
|
||||
"UPDATE user_credits "
|
||||
+ "SET "
|
||||
+ " bought_credits_remaining = bought_credits_remaining - :amount, "
|
||||
+ " total_api_calls_made = total_api_calls_made + 1, "
|
||||
+ " last_api_usage = now() "
|
||||
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId) "
|
||||
+ " AND bought_credits_remaining >= :amount",
|
||||
nativeQuery = true)
|
||||
int consumeBoughtCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
|
||||
|
||||
/** Checks if user has sufficient cycle credits (does NOT consume them). */
|
||||
@Query(
|
||||
value =
|
||||
"SELECT CASE WHEN uc.cycle_credits_remaining >= :amount THEN TRUE ELSE FALSE END "
|
||||
+ "FROM user_credits uc "
|
||||
+ "WHERE uc.user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId)",
|
||||
nativeQuery = true)
|
||||
Boolean hasCycleCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
|
||||
|
||||
/** Checks if user has sufficient bought credits (does NOT consume them). */
|
||||
@Query(
|
||||
value =
|
||||
"SELECT CASE WHEN uc.bought_credits_remaining >= :amount THEN TRUE ELSE FALSE END "
|
||||
+ "FROM user_credits uc "
|
||||
+ "WHERE uc.user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId)",
|
||||
nativeQuery = true)
|
||||
Boolean hasBoughtCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package stirling.software.saas.repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.model.UserErrorTracker;
|
||||
|
||||
public interface UserErrorTrackerRepository extends JpaRepository<UserErrorTracker, Long> {
|
||||
|
||||
Optional<UserErrorTracker> findByUserAndEndpoint(User user, String endpoint);
|
||||
|
||||
Optional<UserErrorTracker> findByUserIdAndEndpoint(Long userId, String endpoint);
|
||||
|
||||
@Query(
|
||||
"SELECT uet FROM UserErrorTracker uet WHERE uet.user.apiKey = :apiKey AND uet.endpoint = :endpoint")
|
||||
Optional<UserErrorTracker> findByUserApiKeyAndEndpoint(
|
||||
@Param("apiKey") String apiKey, @Param("endpoint") String endpoint);
|
||||
|
||||
@Query("SELECT uet FROM UserErrorTracker uet WHERE uet.resetAfter <= :currentDateTime")
|
||||
List<UserErrorTracker> findExpiredErrorTrackers(
|
||||
@Param("currentDateTime") LocalDateTime currentDateTime);
|
||||
|
||||
@Modifying
|
||||
@Query("DELETE FROM UserErrorTracker uet WHERE uet.resetAfter <= :currentDateTime")
|
||||
int deleteExpiredErrorTrackers(@Param("currentDateTime") LocalDateTime currentDateTime);
|
||||
|
||||
@Query(
|
||||
"SELECT uet FROM UserErrorTracker uet WHERE uet.user = :user AND uet.processingErrorCount >= 3")
|
||||
List<UserErrorTracker> findHighErrorCountForUser(@Param("user") User user);
|
||||
|
||||
@Query(
|
||||
"SELECT COUNT(uet) FROM UserErrorTracker uet WHERE uet.processingErrorCount >= :threshold")
|
||||
Long countUsersWithHighErrorCount(@Param("threshold") int threshold);
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package stirling.software.saas.security;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||
|
||||
/**
|
||||
* JWT auth token that exposes the Supabase subject UUID and email alongside the standard claims, so
|
||||
* downstream code (audit, credit accounting) can avoid re-parsing the JWT every request.
|
||||
*/
|
||||
public class EnhancedJwtAuthenticationToken extends JwtAuthenticationToken {
|
||||
|
||||
private final String supabaseId;
|
||||
private final String email;
|
||||
|
||||
public EnhancedJwtAuthenticationToken(
|
||||
Jwt jwt,
|
||||
Collection<? extends GrantedAuthority> authorities,
|
||||
String email,
|
||||
String supabaseId) {
|
||||
super(jwt, authorities, email);
|
||||
this.email = email;
|
||||
this.supabaseId = supabaseId;
|
||||
}
|
||||
|
||||
public String getSupabaseId() {
|
||||
return supabaseId;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "EnhancedJwtAuthenticationToken[email="
|
||||
+ email
|
||||
+ ", supabaseId="
|
||||
+ supabaseId
|
||||
+ ", authorities="
|
||||
+ getAuthorities()
|
||||
+ "]";
|
||||
}
|
||||
}
|
||||
+429
@@ -0,0 +1,429 @@
|
||||
package stirling.software.saas.security;
|
||||
|
||||
import static org.apache.commons.lang3.StringUtils.isBlank;
|
||||
import static org.apache.commons.lang3.StringUtils.isNotBlank;
|
||||
import static stirling.software.common.model.enumeration.Role.LIMITED_API_USER;
|
||||
import static stirling.software.common.model.enumeration.Role.USER;
|
||||
import static stirling.software.common.util.RequestUriUtils.isPublicAuthEndpoint;
|
||||
import static stirling.software.common.util.RequestUriUtils.isStaticResource;
|
||||
import static stirling.software.proprietary.security.model.AuthenticationType.ANONYMOUS;
|
||||
import static stirling.software.proprietary.security.model.AuthenticationType.OAUTH2;
|
||||
import static stirling.software.proprietary.security.model.AuthenticationType.WEB;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtException;
|
||||
import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException;
|
||||
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.util.RequestUriUtils;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.Authority;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.saas.model.AmrMethod;
|
||||
import stirling.software.saas.model.SupabaseUser;
|
||||
import stirling.software.saas.model.exception.AuthenticationFailureException;
|
||||
import stirling.software.saas.model.exception.UserNotFoundException;
|
||||
import stirling.software.saas.service.SaasTeamService;
|
||||
import stirling.software.saas.service.SupabaseUserService;
|
||||
import stirling.software.saas.util.LogRedactionUtils;
|
||||
|
||||
/** Stateless JWT authentication filter for the saas profile. */
|
||||
@Slf4j
|
||||
public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
public static final String BEARER_PREFIX = "Bearer ";
|
||||
public static final String ANON_PREFIX = "anon_";
|
||||
|
||||
private final TeamService teamService;
|
||||
private final UserService userService;
|
||||
private final SupabaseUserService supabaseUserService;
|
||||
private final stirling.software.saas.service.CreditService creditService;
|
||||
private final SaasTeamService saasTeamService;
|
||||
private final JwtDecoder jwtDecoder;
|
||||
private final AuthenticationEntryPoint authenticationEntryPoint =
|
||||
new BearerTokenAuthenticationEntryPoint();
|
||||
|
||||
public SupabaseAuthenticationFilter(
|
||||
TeamService teamService,
|
||||
UserService userService,
|
||||
SupabaseUserService supabaseUserService,
|
||||
stirling.software.saas.service.CreditService creditService,
|
||||
SaasTeamService saasTeamService,
|
||||
JwtDecoder jwtDecoder) {
|
||||
this.teamService = teamService;
|
||||
this.userService = userService;
|
||||
this.supabaseUserService = supabaseUserService;
|
||||
this.creditService = creditService;
|
||||
this.saasTeamService = saasTeamService;
|
||||
this.jwtDecoder = jwtDecoder;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
if (isStaticResource(request.getContextPath(), request.getRequestURI())) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPublicAuthEndpoint(request.getRequestURI(), request.getContextPath())) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
Authentication existingAuth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (existingAuth != null && existingAuth.isAuthenticated()) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (apiKeyAuthenticated(request)) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
processJwtAuthentication(request);
|
||||
} catch (AuthenticationException e) {
|
||||
SecurityContextHolder.clearContext();
|
||||
authenticationEntryPoint.commence(request, response, e);
|
||||
return;
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
String uri = request.getRequestURI();
|
||||
String contextPath = request.getContextPath();
|
||||
|
||||
if ("GET".equalsIgnoreCase(request.getMethod())
|
||||
|| "HEAD".equalsIgnoreCase(request.getMethod())) {
|
||||
if (RequestUriUtils.isStaticResource(contextPath, uri)
|
||||
|| RequestUriUtils.isFrontendRoute(contextPath, uri)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return isPublicAuthEndpoint(uri, contextPath);
|
||||
}
|
||||
|
||||
private void processJwtAuthentication(HttpServletRequest request)
|
||||
throws AuthenticationException {
|
||||
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
if (authHeader == null || !authHeader.startsWith(BEARER_PREFIX)) {
|
||||
return;
|
||||
}
|
||||
|
||||
String token = authHeader.substring(BEARER_PREFIX.length()).trim();
|
||||
|
||||
try {
|
||||
Jwt jwt = jwtDecoder.decode(token);
|
||||
String supabaseId = jwt.getSubject();
|
||||
|
||||
if (!validateRequiredClaims(jwt)) {
|
||||
throw new InvalidBearerTokenException("Invalid JWT: missing required claims");
|
||||
}
|
||||
|
||||
User user = getOrCreateUser(jwt);
|
||||
|
||||
EnhancedJwtAuthenticationToken authToken =
|
||||
new EnhancedJwtAuthenticationToken(
|
||||
jwt, user.getAuthorities(), user.getUsername(), supabaseId);
|
||||
SecurityContextHolder.getContext().setAuthentication(authToken);
|
||||
|
||||
// Hot path: runs on every authenticated request (>10 per page on a typical SPA),
|
||||
// so keep at DEBUG to avoid log-stream spam. Redact identifiers regardless so they
|
||||
// don't leak even when an operator dials logging up. The same outcome (auth granted)
|
||||
// is observable from the request/response logs of the controller chain.
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug(
|
||||
"User {} authenticated via JWT (principal: {})",
|
||||
LogRedactionUtils.redactSupabaseId(supabaseId),
|
||||
LogRedactionUtils.redactEmail(user.getUsername()));
|
||||
}
|
||||
} catch (JwtException e) {
|
||||
throw new InvalidBearerTokenException("Invalid JWT", e);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean validateRequiredClaims(Jwt jwt) {
|
||||
boolean isAnonymous = isAnonymous(jwt);
|
||||
if (!isAnonymous && isBlank(jwt.getClaimAsString("email"))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String[] requiredClaims = {"iss", "aud", "exp", "iat", "sub", "role", "aal", "session_id"};
|
||||
for (String claim : requiredClaims) {
|
||||
switch (claim) {
|
||||
case "iss", "sub", "role", "aal", "session_id" -> {
|
||||
if (isBlank(jwt.getClaimAsString(claim))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
case "aud" -> {
|
||||
List<String> audience = jwt.getClaimAsStringList(claim);
|
||||
if (audience == null || audience.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
case "exp", "iat" -> {
|
||||
Instant timestamp = jwt.getClaimAsInstant(claim);
|
||||
if (timestamp == null) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private User getOrCreateUser(Jwt jwt) throws AuthenticationException {
|
||||
UUID supabaseId = UUID.fromString(jwt.getSubject());
|
||||
String email = jwt.getClaimAsString("email");
|
||||
Object metaObj = jwt.getClaims().get("app_metadata");
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> appMetadata =
|
||||
(metaObj instanceof Map<?, ?>) ? (Map<String, Object>) metaObj : null;
|
||||
|
||||
try {
|
||||
// First confirm the SupabaseUser row exists (this is the auth.users mirror).
|
||||
// If not present, the JWT references a Supabase user this server hasn't synced.
|
||||
SupabaseUser supabaseUser = supabaseUserService.getUser(supabaseId);
|
||||
|
||||
// Resolve to a local User by supabase_id.
|
||||
Optional<User> linkedUser = userService.findBySupabaseId(supabaseId);
|
||||
if (linkedUser.isPresent()) {
|
||||
User user = linkedUser.get();
|
||||
if (ANONYMOUS.toString().equalsIgnoreCase(user.getAuthenticationType())
|
||||
&& !supabaseUser.isAnonymous()) {
|
||||
user = upgradeAnonymousUser(user, supabaseUser, jwt);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
return createUser(jwt, supabaseId, email, appMetadata);
|
||||
} catch (UserNotFoundException e) {
|
||||
throw new InvalidBearerTokenException("User not found", e);
|
||||
} catch (InvalidBearerTokenException e) {
|
||||
throw e;
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new InvalidBearerTokenException(
|
||||
"Invalid authentication method: " + e.getMessage(), e);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to process user authentication for {}", supabaseId, e);
|
||||
throw new AuthenticationFailureException("Failed to process user authentication", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Promote a local anonymous user to the real provider+email carried on the JWT. */
|
||||
@Transactional
|
||||
protected User upgradeAnonymousUser(User user, SupabaseUser supabaseUser, Jwt jwt) {
|
||||
AuthenticationType newType = resolveUpgradedAuthType(jwt);
|
||||
log.info(
|
||||
"Upgrading anonymous user {} to {} (Supabase email: {})",
|
||||
user.getId(),
|
||||
newType,
|
||||
LogRedactionUtils.redactEmail(supabaseUser.getEmail()));
|
||||
user.setAuthenticationType(newType);
|
||||
if (isNotBlank(supabaseUser.getEmail())) {
|
||||
user.setEmail(supabaseUser.getEmail());
|
||||
user.setUsername(supabaseUser.getEmail());
|
||||
}
|
||||
try {
|
||||
return userService.saveUser(user);
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
log.warn(
|
||||
"Email collision upgrading anonymous user {} to {}: {}",
|
||||
user.getId(),
|
||||
LogRedactionUtils.redactEmail(supabaseUser.getEmail()),
|
||||
e.getMessage());
|
||||
throw new AuthenticationFailureException(
|
||||
"Cannot upgrade anonymous account: email already in use", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Maps Supabase's {@code amr} claim to an {@link AuthenticationType}; defaults to WEB. */
|
||||
private AuthenticationType resolveUpgradedAuthType(Jwt jwt) {
|
||||
try {
|
||||
Object raw = jwt.getClaims().get("amr");
|
||||
if (!(raw instanceof List<?> amrList) || amrList.isEmpty()) {
|
||||
return WEB;
|
||||
}
|
||||
Object first = amrList.get(0);
|
||||
if (!(first instanceof Map<?, ?> entry)) {
|
||||
return WEB;
|
||||
}
|
||||
Object methodObj = entry.get("method");
|
||||
if (methodObj == null) {
|
||||
return WEB;
|
||||
}
|
||||
String method = methodObj.toString().toLowerCase(Locale.ROOT);
|
||||
for (AmrMethod amr : AmrMethod.values()) {
|
||||
if (amr.getMethod().equals(method)) {
|
||||
return switch (amr) {
|
||||
case OAUTH, SSO_SAML -> OAUTH2;
|
||||
default -> WEB;
|
||||
};
|
||||
}
|
||||
}
|
||||
return WEB;
|
||||
} catch (Exception e) {
|
||||
log.debug("Could not resolve upgraded auth type from amr claim; defaulting to WEB", e);
|
||||
return WEB;
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
protected User createUser(
|
||||
Jwt jwt, UUID supabaseId, String email, Map<String, Object> appMetadata) {
|
||||
|
||||
User newUser = new User();
|
||||
AuthenticationType authenticationType = WEB;
|
||||
String roleId = USER.getRoleId();
|
||||
|
||||
if (isAnonymous(jwt)) {
|
||||
email = ANON_PREFIX + jwt.getSubject();
|
||||
authenticationType = ANONYMOUS;
|
||||
roleId = LIMITED_API_USER.getRoleId();
|
||||
} else {
|
||||
if (appMetadata == null || !appMetadata.containsKey("provider")) {
|
||||
throw new AuthenticationFailureException(
|
||||
"Missing provider in app_metadata for non-anonymous user");
|
||||
}
|
||||
|
||||
String provider = String.valueOf(appMetadata.get("provider"));
|
||||
// "email" is the password / magic-link flow; everything else Supabase exposes is an
|
||||
// external IdP (OAuth or SAML). Treat unknown non-email providers as OAUTH2 rather
|
||||
// than silently downgrading to WEB.
|
||||
if (provider != null && !provider.isBlank() && !"email".equalsIgnoreCase(provider)) {
|
||||
authenticationType = OAUTH2;
|
||||
}
|
||||
|
||||
if (isNotBlank(email)) {
|
||||
newUser.setEmail(email);
|
||||
}
|
||||
if (email != null && email.startsWith(ANON_PREFIX)) {
|
||||
throw new AuthenticationFailureException(
|
||||
"Invalid email format for non-anonymous user");
|
||||
}
|
||||
}
|
||||
|
||||
newUser.setUsername(email);
|
||||
newUser.setEnabled(true);
|
||||
newUser.setFirstLogin(true);
|
||||
newUser.setRoleName(roleId);
|
||||
newUser.setTeam(teamService.getOrCreateDefaultTeam());
|
||||
newUser.setAuthenticationType(authenticationType);
|
||||
newUser.setSupabaseId(supabaseId);
|
||||
newUser.addAuthority(new Authority(roleId, newUser));
|
||||
|
||||
// Create or fetch the auth.users mirror row.
|
||||
try {
|
||||
boolean isAnon = isAnonymous(jwt);
|
||||
supabaseUserService.createSupabaseUser(supabaseId, isAnon ? null : email, isAnon);
|
||||
} catch (DataIntegrityViolationException ignored) {
|
||||
// Concurrent creation; fall through, the row exists.
|
||||
} catch (Exception e) {
|
||||
throw new AuthenticationFailureException("Failed to create SupabaseUser", e);
|
||||
}
|
||||
|
||||
User savedUser;
|
||||
boolean weCreatedThisUser = true;
|
||||
try {
|
||||
savedUser = userService.saveUser(newUser);
|
||||
} catch (DataIntegrityViolationException dup) {
|
||||
// Parallel filter won the race; fetch the winning row.
|
||||
weCreatedThisUser = false;
|
||||
savedUser =
|
||||
userService
|
||||
.findBySupabaseId(supabaseId)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new AuthenticationFailureException(
|
||||
"User creation conflict, but unable to find existing user",
|
||||
dup));
|
||||
}
|
||||
|
||||
// Only the DB-race winner runs first-time init; the losers skip it.
|
||||
if (weCreatedThisUser) {
|
||||
try {
|
||||
creditService.getOrCreateUserCredits(savedUser);
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to initialize credits for new user {} ({}): {}",
|
||||
LogRedactionUtils.redactSupabaseId(supabaseId),
|
||||
LogRedactionUtils.redactEmail(savedUser.getUsername()),
|
||||
e.getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
saasTeamService.createPersonalTeam(savedUser);
|
||||
savedUser = userService.findBySupabaseId(supabaseId).orElse(savedUser);
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to create personal team for new user {} ({}): {}",
|
||||
LogRedactionUtils.redactSupabaseId(supabaseId),
|
||||
LogRedactionUtils.redactEmail(savedUser.getUsername()),
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
return savedUser;
|
||||
}
|
||||
|
||||
private boolean apiKeyAuthenticated(HttpServletRequest request) throws AuthenticationException {
|
||||
Authentication existing = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (existing != null && existing.isAuthenticated()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String apiKey = request.getHeader("X-API-KEY");
|
||||
if (isBlank(apiKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Optional<User> user = userService.getUserByApiKey(apiKey);
|
||||
if (user.isEmpty()) {
|
||||
throw new InvalidBearerTokenException("Invalid API Key.");
|
||||
}
|
||||
|
||||
userService.trackApiKeyFirstUse(user.get());
|
||||
|
||||
ApiKeyAuthenticationToken authToken =
|
||||
new ApiKeyAuthenticationToken(user.get(), apiKey, user.get().getAuthorities());
|
||||
SecurityContextHolder.getContext().setAuthentication(authToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean isAnonymous(Jwt jwt) {
|
||||
return Boolean.TRUE.equals(jwt.getClaimAsBoolean("is_anonymous"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package stirling.software.saas.security;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
|
||||
import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler;
|
||||
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.RequestUriUtils;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.saas.service.CreditService;
|
||||
import stirling.software.saas.service.SaasTeamService;
|
||||
import stirling.software.saas.service.SupabaseUserService;
|
||||
|
||||
/** Stateless Supabase-JWT security chain. */
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
@Profile("saas")
|
||||
@Order(1)
|
||||
@RequiredArgsConstructor
|
||||
public class SupabaseSecurityConfig {
|
||||
|
||||
private final UserService userService;
|
||||
private final TeamService teamService;
|
||||
private final SupabaseUserService supabaseUserService;
|
||||
private final CreditService creditService;
|
||||
private final SaasTeamService saasTeamService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@Value("${app.supabase.issuer:}")
|
||||
private String issuer;
|
||||
|
||||
/** Optional audience claim to enforce. Empty means do not validate the {@code aud} claim. */
|
||||
@Value("${app.supabase.expected-aud:}")
|
||||
private String expectedAud;
|
||||
|
||||
/** Clock skew tolerance (seconds) applied to the {@code exp} claim. */
|
||||
@Value("${app.supabase.clock-skew-seconds:120}")
|
||||
private long clockSkewSeconds;
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain saasSecurityFilterChain(HttpSecurity http, JwtDecoder jwtDecoder)
|
||||
throws Exception {
|
||||
// CSRF protection intentionally disabled: this chain is bearer-token only (Supabase JWT in
|
||||
// Authorization header / X-API-KEY) with SessionCreationPolicy.STATELESS, so there is no
|
||||
// cookie- or session-bound credential a cross-site request could ride on. Re-enabling CSRF
|
||||
// would require synchronizer tokens which don't make sense for a stateless JSON API.
|
||||
// lgtm[java/spring-disabled-csrf-protection]
|
||||
http.csrf(AbstractHttpConfigurer::disable)
|
||||
.cors(Customizer.withDefaults())
|
||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.formLogin(AbstractHttpConfigurer::disable)
|
||||
.httpBasic(AbstractHttpConfigurer::disable)
|
||||
.authorizeHttpRequests(
|
||||
auth ->
|
||||
auth.requestMatchers(HttpMethod.OPTIONS, "/**")
|
||||
.permitAll()
|
||||
.requestMatchers("/actuator/health", "/api/v1/config/**")
|
||||
.permitAll()
|
||||
.requestMatchers(
|
||||
req ->
|
||||
RequestUriUtils.isStaticResource(
|
||||
req.getContextPath(),
|
||||
req.getRequestURI())
|
||||
|| RequestUriUtils
|
||||
.isPublicAuthEndpoint(
|
||||
req.getRequestURI(),
|
||||
req
|
||||
.getContextPath())
|
||||
|| RequestUriUtils.isFrontendRoute(
|
||||
req.getContextPath(),
|
||||
req.getRequestURI()))
|
||||
.permitAll()
|
||||
.anyRequest()
|
||||
.authenticated())
|
||||
.addFilterBefore(
|
||||
new SupabaseAuthenticationFilter(
|
||||
teamService,
|
||||
userService,
|
||||
supabaseUserService,
|
||||
creditService,
|
||||
saasTeamService,
|
||||
jwtDecoder),
|
||||
BearerTokenAuthenticationFilter.class)
|
||||
.exceptionHandling(
|
||||
ex ->
|
||||
ex.authenticationEntryPoint(
|
||||
new BearerTokenAuthenticationEntryPoint())
|
||||
.accessDeniedHandler(new BearerTokenAccessDeniedHandler()))
|
||||
.oauth2ResourceServer(
|
||||
oauth ->
|
||||
oauth.jwt(
|
||||
jwt ->
|
||||
jwt.decoder(jwtDecoder)
|
||||
.jwtAuthenticationConverter(
|
||||
SupabaseSecurityConfig
|
||||
::toAuthentication)));
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
JwtDecoder jwtDecoder() {
|
||||
String issuerError = validateIssuer(issuer);
|
||||
if (issuerError != null) {
|
||||
log.warn(
|
||||
"{} saas profile is active but JWTs cannot be validated. Set SAAS_DB_PROJECT_REF"
|
||||
+ " (or app.supabase.issuer) in application-saas.properties or via env.",
|
||||
issuerError);
|
||||
// Build a decoder that will reject every token; failing closed is safer than failing
|
||||
// open when configuration is incomplete.
|
||||
final String reason = issuerError;
|
||||
return token -> {
|
||||
throw new org.springframework.security.oauth2.jwt.JwtException(reason);
|
||||
};
|
||||
}
|
||||
String jwks = issuer + "/.well-known/jwks.json";
|
||||
log.info("Configuring JWT decoder with JWKS: {}", jwks);
|
||||
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwks).build();
|
||||
// Defence-in-depth: signature verification already binds the token to this Supabase
|
||||
// project's JWKS, but enforcing iss/exp/aud explicitly catches tokens that smuggle
|
||||
// through (e.g. JWKS reuse across projects, clock-skew abuse, missing aud).
|
||||
decoder.setJwtValidator(
|
||||
new SupabaseTokenValidator(
|
||||
issuer, expectedAud, Duration.ofSeconds(clockSkewSeconds)));
|
||||
if (expectedAud == null || expectedAud.isBlank()) {
|
||||
log.info(
|
||||
"JWT validation: enforcing issuer='{}' and exp (skew={}s); aud check disabled",
|
||||
issuer,
|
||||
clockSkewSeconds);
|
||||
} else {
|
||||
log.info(
|
||||
"JWT validation: enforcing issuer='{}', aud='{}', and exp (skew={}s)",
|
||||
issuer,
|
||||
expectedAud,
|
||||
clockSkewSeconds);
|
||||
}
|
||||
return decoder;
|
||||
}
|
||||
|
||||
/** Returns {@code null} if the issuer URL is usable, otherwise a short reason string. */
|
||||
static String validateIssuer(String issuer) {
|
||||
if (issuer == null || issuer.isBlank()) {
|
||||
return "app.supabase.issuer is not set;";
|
||||
}
|
||||
URI uri;
|
||||
try {
|
||||
uri = new URI(issuer);
|
||||
} catch (URISyntaxException e) {
|
||||
return "app.supabase.issuer is not a valid URI (" + issuer + ");";
|
||||
}
|
||||
String host = uri.getHost();
|
||||
if (host == null || host.isBlank() || host.startsWith(".")) {
|
||||
return "app.supabase.issuer has an empty host ("
|
||||
+ issuer
|
||||
+ "); likely SAAS_DB_PROJECT_REF is unset;";
|
||||
}
|
||||
String scheme = uri.getScheme();
|
||||
if (!"https".equalsIgnoreCase(scheme) && !"http".equalsIgnoreCase(scheme)) {
|
||||
return "app.supabase.issuer must be http(s) (" + issuer + ");";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Validates iss, exp (with clock-skew) and optionally aud on a decoded Supabase JWT. */
|
||||
static final class SupabaseTokenValidator implements OAuth2TokenValidator<Jwt> {
|
||||
private final String expectedIssuer;
|
||||
private final String expectedAudienceOrNull;
|
||||
private final Duration skew;
|
||||
|
||||
SupabaseTokenValidator(
|
||||
String expectedIssuer, String expectedAudienceOrNull, Duration skew) {
|
||||
this.expectedIssuer = Objects.requireNonNull(expectedIssuer, "expectedIssuer");
|
||||
this.expectedAudienceOrNull =
|
||||
(expectedAudienceOrNull != null && !expectedAudienceOrNull.isBlank())
|
||||
? expectedAudienceOrNull
|
||||
: null;
|
||||
this.skew = Objects.requireNonNull(skew, "skew");
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2TokenValidatorResult validate(Jwt token) {
|
||||
List<OAuth2Error> errors = new ArrayList<>();
|
||||
|
||||
String iss = token.getIssuer() != null ? token.getIssuer().toString() : null;
|
||||
if (iss == null || !iss.equals(expectedIssuer)) {
|
||||
errors.add(new OAuth2Error("invalid_token", "Invalid issuer: " + iss, null));
|
||||
}
|
||||
|
||||
Instant exp = token.getExpiresAt();
|
||||
if (exp == null) {
|
||||
errors.add(new OAuth2Error("invalid_token", "Missing exp claim", null));
|
||||
} else if (exp.isBefore(Instant.now().minus(skew))) {
|
||||
errors.add(new OAuth2Error("invalid_token", "Token expired at " + exp, null));
|
||||
}
|
||||
|
||||
if (expectedAudienceOrNull != null) {
|
||||
List<String> aud = token.getAudience();
|
||||
if (aud == null || !aud.contains(expectedAudienceOrNull)) {
|
||||
errors.add(
|
||||
new OAuth2Error(
|
||||
"invalid_token",
|
||||
"Missing/invalid audience: " + expectedAudienceOrNull,
|
||||
null));
|
||||
}
|
||||
}
|
||||
|
||||
return errors.isEmpty()
|
||||
? OAuth2TokenValidatorResult.success()
|
||||
: OAuth2TokenValidatorResult.failure(errors);
|
||||
}
|
||||
}
|
||||
|
||||
@Bean
|
||||
CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration cfg = new CorsConfiguration();
|
||||
boolean operatorOverride =
|
||||
applicationProperties.getSystem() != null
|
||||
&& applicationProperties.getSystem().getCorsAllowedOrigins() != null
|
||||
&& !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty();
|
||||
List<String> origins =
|
||||
operatorOverride
|
||||
? applicationProperties.getSystem().getCorsAllowedOrigins()
|
||||
: List.of(
|
||||
"http://localhost:3000",
|
||||
"http://localhost:5173",
|
||||
"http://localhost:8080",
|
||||
"https://stirling.com",
|
||||
"https://app.stirling.com",
|
||||
"https://api.stirling.com");
|
||||
if (origins.stream().anyMatch(o -> o.contains("*"))) {
|
||||
log.warn(
|
||||
"CORS origins contain a wildcard paired with allowCredentials=true: {}."
|
||||
+ " Wildcard subdomains can be taken over by an attacker (lapsed DNS,"
|
||||
+ " abandoned vhost) and would receive credentialed responses. Pin to"
|
||||
+ " specific hostnames.",
|
||||
origins);
|
||||
}
|
||||
cfg.setAllowedOriginPatterns(origins);
|
||||
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||
cfg.setAllowedHeaders(
|
||||
List.of(
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Requested-With",
|
||||
"Accept",
|
||||
"Origin",
|
||||
"X-API-KEY"));
|
||||
cfg.setExposedHeaders(List.of("WWW-Authenticate", "X-Credits-Remaining"));
|
||||
cfg.setAllowCredentials(true);
|
||||
cfg.setMaxAge(3600L);
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", cfg);
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps Supabase JWT claims onto Spring Security authorities. Package-private static so unit
|
||||
* tests can call it directly without instantiating the full security config.
|
||||
*/
|
||||
static AbstractAuthenticationToken toAuthentication(Jwt jwt) {
|
||||
List<GrantedAuthority> authorities = new ArrayList<>();
|
||||
|
||||
// Transient (non-persisted) authorities for the JWT principal. Use
|
||||
// SimpleGrantedAuthority rather than the @Entity Authority class.
|
||||
boolean isAnonymous = Boolean.TRUE.equals(jwt.getClaimAsBoolean("is_anonymous"));
|
||||
String supabaseRole = jwt.getClaimAsString("role");
|
||||
if (supabaseRole != null && !supabaseRole.isBlank()) {
|
||||
authorities.add(new SimpleGrantedAuthority("ROLE_" + supabaseRole));
|
||||
}
|
||||
String appRole = jwt.getClaimAsString("app_role");
|
||||
if (appRole != null && !appRole.isBlank()) {
|
||||
authorities.add(new SimpleGrantedAuthority("ROLE_" + appRole.toUpperCase(Locale.ROOT)));
|
||||
}
|
||||
authorities.add(
|
||||
new SimpleGrantedAuthority(isAnonymous ? "ROLE_LIMITED_API_USER" : "ROLE_USER"));
|
||||
|
||||
List<String> perms = jwt.getClaimAsStringList("permissions");
|
||||
if (perms != null) {
|
||||
perms.stream()
|
||||
.filter(p -> p != null && !p.isBlank())
|
||||
.map(p -> new SimpleGrantedAuthority("PERM_" + p))
|
||||
.forEach(authorities::add);
|
||||
}
|
||||
|
||||
String email = jwt.getClaimAsString("email");
|
||||
String supabaseId = jwt.getSubject();
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug(
|
||||
"JWT accepted: email='{}', supabaseId='{}', authorities='{}'",
|
||||
email,
|
||||
supabaseId,
|
||||
authorities.stream()
|
||||
.map(GrantedAuthority::getAuthority)
|
||||
.collect(Collectors.joining(",")));
|
||||
}
|
||||
return new EnhancedJwtAuthenticationToken(jwt, authorities, email, supabaseId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package stirling.software.saas.security;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Security expressions for team-based authorization in saas mode. Wired into
|
||||
* {@code @PreAuthorize("@teamSecurity.isTeamLeader(#teamId)")} annotations on {@code
|
||||
* SaasTeamController} endpoints.
|
||||
*
|
||||
* <p>The current-user resolution uses the saas-mode authentication primitives ({@link
|
||||
* EnhancedJwtAuthenticationToken} from a Supabase JWT, or our existing API-key path) and looks the
|
||||
* local {@link User} row up via {@link UserService#findBySupabaseId(UUID)}.
|
||||
*/
|
||||
@Component("teamSecurity")
|
||||
@Profile("saas")
|
||||
@RequiredArgsConstructor
|
||||
public class TeamSecurityExpressions {
|
||||
|
||||
private final TeamMembershipRepository membershipRepository;
|
||||
private final UserService userService;
|
||||
|
||||
/** Whether the current authenticated user is a {@code LEADER} of the given team. */
|
||||
public boolean isTeamLeader(Long teamId) {
|
||||
User currentUser = getCurrentUser();
|
||||
if (currentUser == null) {
|
||||
return false;
|
||||
}
|
||||
return membershipRepository
|
||||
.findByTeamIdAndUserId(teamId, currentUser.getId())
|
||||
.map(membership -> membership.getRole() == TeamRole.LEADER)
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
/** Whether the current authenticated user is any kind of member of the given team. */
|
||||
public boolean isTeamMember(Long teamId) {
|
||||
User currentUser = getCurrentUser();
|
||||
if (currentUser == null) {
|
||||
return false;
|
||||
}
|
||||
return membershipRepository.existsByTeamIdAndUserId(teamId, currentUser.getId());
|
||||
}
|
||||
|
||||
/** Resolve the current user from a saas-mode JWT or API-key authentication. */
|
||||
private User getCurrentUser() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return null;
|
||||
}
|
||||
if (authentication instanceof EnhancedJwtAuthenticationToken jwt) {
|
||||
try {
|
||||
UUID supabaseId = UUID.fromString(jwt.getSupabaseId());
|
||||
return userService.findBySupabaseId(supabaseId).orElse(null);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// API-key path: the principal is the User entity itself.
|
||||
Object principal = authentication.getPrincipal();
|
||||
if (principal instanceof User user) {
|
||||
return user;
|
||||
}
|
||||
// Username fallback.
|
||||
if (principal instanceof String username) {
|
||||
Optional<User> byUsername = userService.findByUsername(username);
|
||||
return byUsername.orElse(null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package stirling.software.saas.service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.saas.repository.SupabaseUserRepository;
|
||||
|
||||
/**
|
||||
* Service to periodically clean up anonymous users older than 30 days. Based on Supabase's
|
||||
* recommendation for anonymous user management.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@RequiredArgsConstructor
|
||||
public class AnonymousUserCleanupService {
|
||||
|
||||
@Value("${app.auth.anonymous.enabled:true}")
|
||||
private boolean anonEnabled;
|
||||
|
||||
@Value("${app.auth.anonymous.retention-days:30}")
|
||||
private int retentionDays;
|
||||
|
||||
@Value("${app.auth.anonymous.cleanup-batch-size:100}")
|
||||
private int batchSize;
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final SupabaseUserRepository supabaseUserRepository;
|
||||
|
||||
/**
|
||||
* Scheduled task that runs daily to clean up anonymous users based on configured retention
|
||||
* policy. This follows Supabase's recommendation for anonymous user cleanup.
|
||||
*/
|
||||
@Transactional
|
||||
@Scheduled(cron = "0 0 2 * * ?")
|
||||
public void cleanup() {
|
||||
if (!anonEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (retentionDays <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
LocalDateTime cutoffDate = LocalDateTime.now().minusDays(retentionDays);
|
||||
log.info("Removing accounts created before {}", cutoffDate);
|
||||
|
||||
batchDeleteSupabaseUsers(cutoffDate, batchSize);
|
||||
batchDeleteUsers(cutoffDate, batchSize);
|
||||
}
|
||||
|
||||
private void batchDeleteSupabaseUsers(LocalDateTime cutoffDate, int batchSize) {
|
||||
try (Stream<UUID> idStream =
|
||||
supabaseUserRepository.findByCreatedAtBeforeAndIsAnonymousTrue(cutoffDate)) {
|
||||
AtomicInteger counter = new AtomicInteger();
|
||||
|
||||
idStream.collect(Collectors.groupingBy(id -> counter.getAndIncrement() / batchSize))
|
||||
.values()
|
||||
.forEach(supabaseUserRepository::deleteAllByIdInBatch);
|
||||
}
|
||||
}
|
||||
|
||||
private void batchDeleteUsers(LocalDateTime cutoffDate, int batchSize) {
|
||||
try (Stream<Long> idStream =
|
||||
userRepository.findByUsernameIsNullAndCreatedAtBefore(cutoffDate)) {
|
||||
AtomicInteger counter = new AtomicInteger();
|
||||
|
||||
idStream.collect(Collectors.groupingBy(id -> counter.getAndIncrement() / batchSize))
|
||||
.values()
|
||||
.forEach(userRepository::deleteAllByIdInBatch);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package stirling.software.saas.service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/**
|
||||
* ApplicationRunner that backfills user_credits table for existing users who don't have credit rows
|
||||
* yet. This prevents existing users from being hard-blocked when the credit system is enabled.
|
||||
*
|
||||
* <p>This runs once at application startup after the database schema is ready.
|
||||
*/
|
||||
@Component
|
||||
@Profile("saas")
|
||||
@ConditionalOnProperty(name = "credits.enabled", havingValue = "true", matchIfMissing = true)
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CreditBackfillRunner implements ApplicationRunner {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final CreditService creditService;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void run(ApplicationArguments args) {
|
||||
try {
|
||||
backfillUserCredits();
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to backfill user credits", e);
|
||||
// Don't throw; this shouldn't prevent app startup
|
||||
}
|
||||
}
|
||||
|
||||
private void backfillUserCredits() {
|
||||
log.info("Starting user credits backfill for existing users...");
|
||||
|
||||
List<User> usersNeedingCredits = userRepository.findUsersWithApiKeyButNoCredits();
|
||||
|
||||
if (usersNeedingCredits.isEmpty()) {
|
||||
log.info(
|
||||
"No users need credit backfill; all users with API keys already have credit rows");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Found {} users with API keys that need credit rows", usersNeedingCredits.size());
|
||||
|
||||
int backfilled = 0;
|
||||
for (User user : usersNeedingCredits) {
|
||||
try {
|
||||
// Use the existing getOrCreateUserCredits method which handles proper allocation
|
||||
creditService.getOrCreateUserCredits(user);
|
||||
backfilled++;
|
||||
|
||||
if (backfilled % 100 == 0) {
|
||||
log.info("Backfilled credits for {} users so far...", backfilled);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to create credits for user {}: {}",
|
||||
user.getUsername(),
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Successfully backfilled user_credits for {} existing users", backfilled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package stirling.software.saas.service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.temporal.TemporalAdjusters;
|
||||
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.saas.config.CreditsProperties;
|
||||
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class CreditResetScheduler {
|
||||
|
||||
private final CreditService creditService;
|
||||
private final CreditsProperties creditsProperties;
|
||||
|
||||
/**
|
||||
* Reset cycle credits for all users and teams on the 1st of each month at 2 AM UTC This runs
|
||||
* monthly, resetting credits based on user roles and team seats
|
||||
*/
|
||||
@Scheduled(cron = "${credits.reset.cron:0 0 2 1 * *}", zone = "${credits.reset.zone:UTC}")
|
||||
public void resetCycleCredits() {
|
||||
log.info(
|
||||
"Starting monthly credit reset for all users and teams (schedule: {}, zone: {})",
|
||||
creditsProperties.getReset().getCron(),
|
||||
creditsProperties.getReset().getZone());
|
||||
|
||||
try {
|
||||
ZoneId configuredZone = ZoneId.of(creditsProperties.getReset().getZone());
|
||||
LocalDateTime resetTime = LocalDateTime.now(configuredZone);
|
||||
creditService.resetCycleCreditsForAllUsers(resetTime);
|
||||
creditService.resetCycleCreditsForAllTeams(resetTime);
|
||||
log.info("Monthly credit reset completed successfully at {}", resetTime);
|
||||
} catch (Exception e) {
|
||||
log.error("Error during monthly credit reset", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Check for missed resets on application startup */
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void onApplicationReady() {
|
||||
try {
|
||||
ZoneId configuredZone = ZoneId.of(creditsProperties.getReset().getZone());
|
||||
LocalDateTime now = LocalDateTime.now(configuredZone);
|
||||
LocalDateTime lastScheduledReset = getMostRecentScheduledReset(now, configuredZone);
|
||||
|
||||
log.info(
|
||||
"Checking for missed cycle credit resets. Last scheduled: {}, Current: {}",
|
||||
lastScheduledReset,
|
||||
now);
|
||||
|
||||
creditService.resetCycleCreditsForAllUsers(lastScheduledReset);
|
||||
creditService.resetCycleCreditsForAllTeams(lastScheduledReset);
|
||||
log.info("Catch-up cycle credit reset completed");
|
||||
} catch (Exception e) {
|
||||
log.error("Error during catch-up credit reset", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the most recent scheduled reset time based on configured schedule and zone */
|
||||
private LocalDateTime getMostRecentScheduledReset(LocalDateTime now, ZoneId configuredZone) {
|
||||
ZonedDateTime zonedNow = now.atZone(configuredZone);
|
||||
|
||||
// Find the 1st of the current month at the configured time (default 02:00)
|
||||
ZonedDateTime firstOfMonth =
|
||||
zonedNow.with(TemporalAdjusters.firstDayOfMonth())
|
||||
.withHour(2)
|
||||
.withMinute(0)
|
||||
.withSecond(0)
|
||||
.withNano(0);
|
||||
|
||||
// If it's the 1st and before the reset hour, or if current time is before the 1st at 2 AM,
|
||||
// go to previous month's 1st
|
||||
if (zonedNow.isBefore(firstOfMonth)) {
|
||||
firstOfMonth = firstOfMonth.minusMonths(1);
|
||||
}
|
||||
|
||||
return firstOfMonth.toLocalDateTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup and maintenance task; runs daily at 3 AM UTC. Performs maintenance tasks like
|
||||
* cleaning up old data.
|
||||
*/
|
||||
@Scheduled(cron = "0 0 3 * * *", zone = "UTC")
|
||||
public void performDailyMaintenance() {
|
||||
log.debug("Starting daily credit system maintenance");
|
||||
|
||||
try {
|
||||
// API call history cleanup is no longer needed; audit system handles this
|
||||
log.debug("Daily credit system maintenance completed");
|
||||
} catch (Exception e) {
|
||||
log.error("Error during daily credit system maintenance", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,315 @@
|
||||
package stirling.software.saas.service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.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;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.config.CreditsProperties;
|
||||
import stirling.software.saas.model.ProcessingErrorType;
|
||||
import stirling.software.saas.model.UserErrorTracker;
|
||||
import stirling.software.saas.repository.UserErrorTrackerRepository;
|
||||
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@Slf4j
|
||||
@Transactional
|
||||
public class ErrorTrackingService {
|
||||
|
||||
private final UserErrorTrackerRepository errorTrackerRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final CreditsProperties creditsProperties;
|
||||
|
||||
/**
|
||||
* Local cache for error counts to reduce database chatter.
|
||||
*
|
||||
* <p>This cache is used to temporarily store error counts for each API key and endpoint,
|
||||
* reducing the frequency of database writes and lookups.
|
||||
*
|
||||
* <p><b>Nullability:</b> This field may be {@code null} if local caching is disabled via {@link
|
||||
* CreditsProperties#getCache()#isLocalEnabled()}. All usages must check for null before
|
||||
* accessing or invoking methods on this cache.
|
||||
*
|
||||
* <p><b>Lifecycle:</b> The cache is initialized in the constructor based on configuration and
|
||||
* remains unchanged for the lifetime of this service instance.
|
||||
*
|
||||
* <p><b>Thread-safety:</b> The underlying Caffeine cache is thread-safe.
|
||||
*/
|
||||
private final Cache<String, ErrorCountCache> errorCountCache;
|
||||
|
||||
public ErrorTrackingService(
|
||||
UserErrorTrackerRepository errorTrackerRepository,
|
||||
UserRepository userRepository,
|
||||
CreditsProperties creditsProperties) {
|
||||
this.errorTrackerRepository = errorTrackerRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.creditsProperties = creditsProperties;
|
||||
|
||||
// Initialize cache based on configuration
|
||||
this.errorCountCache =
|
||||
creditsProperties.getCache().isLocalEnabled()
|
||||
? Caffeine.newBuilder()
|
||||
.maximumSize(10000)
|
||||
.expireAfterWrite(
|
||||
creditsProperties.getErrors().getTtlMinutes(),
|
||||
TimeUnit.MINUTES)
|
||||
.build()
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an error and determine if credits should be consumed
|
||||
*
|
||||
* @param apiKey User's API key
|
||||
* @param endpoint The endpoint that failed
|
||||
* @param throwable The exception that occurred
|
||||
* @param httpStatus HTTP response status
|
||||
* @return true if credits should be consumed for this error
|
||||
*/
|
||||
public boolean recordErrorAndShouldConsumeCredit(
|
||||
String apiKey, String endpoint, Throwable throwable, int httpStatus) {
|
||||
ProcessingErrorType errorType =
|
||||
ProcessingErrorType.classifyError(throwable, httpStatus, endpoint);
|
||||
|
||||
// Never charge for validation errors or system errors
|
||||
if (errorType != ProcessingErrorType.PROCESSING_ERROR) {
|
||||
log.debug(
|
||||
"Error classified as {}, no credit consumption for API key: {}, endpoint: {}",
|
||||
errorType,
|
||||
maskApiKey(apiKey),
|
||||
endpoint);
|
||||
return false;
|
||||
}
|
||||
|
||||
String cacheKey = apiKey + "|" + endpoint;
|
||||
|
||||
if (errorCountCache != null) {
|
||||
// Use cache for fast tracking
|
||||
ErrorCountCache cachedCount = errorCountCache.get(cacheKey, k -> new ErrorCountCache());
|
||||
cachedCount.incrementErrorCount();
|
||||
|
||||
boolean shouldCharge =
|
||||
cachedCount.getErrorCount()
|
||||
> creditsProperties.getErrors().getFreeProcessingErrors();
|
||||
|
||||
// Persist to DB when crossing the charging threshold or on first error
|
||||
if (shouldCharge
|
||||
&& cachedCount.getErrorCount()
|
||||
== creditsProperties.getErrors().getFreeProcessingErrors() + 1) {
|
||||
persistErrorToDatabase(apiKey, endpoint);
|
||||
}
|
||||
|
||||
log.info(
|
||||
"Processing error recorded (cached) for API key: {}, endpoint: {}, error count: {}, will charge: {}",
|
||||
maskApiKey(apiKey),
|
||||
endpoint,
|
||||
cachedCount.getErrorCount(),
|
||||
shouldCharge);
|
||||
|
||||
return shouldCharge;
|
||||
} else {
|
||||
// Fallback to direct DB tracking
|
||||
return recordErrorDirectToDatabase(apiKey, endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean recordErrorDirectToDatabase(String apiKey, String endpoint) {
|
||||
Optional<User> userOpt = userRepository.findByApiKey(apiKey);
|
||||
if (userOpt.isEmpty()) {
|
||||
log.warn("User not found for API key: {}", maskApiKey(apiKey));
|
||||
return false;
|
||||
}
|
||||
|
||||
User user = userOpt.get();
|
||||
UserErrorTracker tracker = getOrCreateErrorTracker(user, endpoint);
|
||||
|
||||
tracker.recordProcessingError(creditsProperties.getErrors().getTtlMinutes());
|
||||
errorTrackerRepository.save(tracker);
|
||||
|
||||
boolean shouldCharge =
|
||||
tracker.shouldChargeForProcessingError(
|
||||
creditsProperties.getErrors().getFreeProcessingErrors());
|
||||
|
||||
log.info(
|
||||
"Processing error recorded (DB) for user: {}, endpoint: {}, error count: {}, will charge: {}",
|
||||
user.getUsername(),
|
||||
endpoint,
|
||||
tracker.getProcessingErrorCount(),
|
||||
shouldCharge);
|
||||
|
||||
return shouldCharge;
|
||||
}
|
||||
|
||||
private void persistErrorToDatabase(String apiKey, String endpoint) {
|
||||
try {
|
||||
Optional<User> userOpt = userRepository.findByApiKey(apiKey);
|
||||
if (userOpt.isPresent()) {
|
||||
User user = userOpt.get();
|
||||
UserErrorTracker tracker = getOrCreateErrorTracker(user, endpoint);
|
||||
// Set to threshold + 1 to indicate charging has started
|
||||
tracker.setProcessingErrorCount(
|
||||
creditsProperties.getErrors().getFreeProcessingErrors() + 1);
|
||||
tracker.setLastProcessingError(LocalDateTime.now());
|
||||
tracker.setResetAfter(
|
||||
LocalDateTime.now()
|
||||
.plusMinutes(creditsProperties.getErrors().getTtlMinutes()));
|
||||
errorTrackerRepository.save(tracker);
|
||||
log.debug(
|
||||
"Persisted error threshold crossing to DB for API key: {}, endpoint: {}",
|
||||
maskApiKey(apiKey),
|
||||
endpoint);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"Failed to persist error to database for API key: {}, endpoint: {}",
|
||||
maskApiKey(apiKey),
|
||||
endpoint,
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if a user has high error counts that might indicate abuse */
|
||||
public boolean hasHighErrorCount(String apiKey, String endpoint) {
|
||||
Optional<UserErrorTracker> trackerOpt =
|
||||
errorTrackerRepository.findByUserApiKeyAndEndpoint(apiKey, endpoint);
|
||||
return trackerOpt
|
||||
.map(
|
||||
t ->
|
||||
t.shouldChargeForProcessingError(
|
||||
creditsProperties.getErrors().getFreeProcessingErrors()))
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
/** Get error information for a user and endpoint */
|
||||
public ErrorInfo getErrorInfo(String apiKey, String endpoint) {
|
||||
String cacheKey = apiKey + "|" + endpoint;
|
||||
|
||||
if (errorCountCache != null) {
|
||||
// Check cache first
|
||||
ErrorCountCache cachedCount = errorCountCache.getIfPresent(cacheKey);
|
||||
if (cachedCount != null) {
|
||||
int currentCount = cachedCount.getErrorCount();
|
||||
int freeErrors = creditsProperties.getErrors().getFreeProcessingErrors();
|
||||
return new ErrorInfo(
|
||||
currentCount,
|
||||
Math.max(0, freeErrors - currentCount),
|
||||
currentCount > freeErrors,
|
||||
cachedCount.getLastErrorTime());
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to DB
|
||||
Optional<UserErrorTracker> trackerOpt =
|
||||
errorTrackerRepository.findByUserApiKeyAndEndpoint(apiKey, endpoint);
|
||||
if (trackerOpt.isEmpty()) {
|
||||
return new ErrorInfo(
|
||||
0, creditsProperties.getErrors().getFreeProcessingErrors(), false, null);
|
||||
}
|
||||
|
||||
UserErrorTracker tracker = trackerOpt.get();
|
||||
|
||||
// Reset if expired
|
||||
if (tracker.isExpired()) {
|
||||
tracker.resetErrorCount(creditsProperties.getErrors().getTtlMinutes());
|
||||
errorTrackerRepository.save(tracker);
|
||||
return new ErrorInfo(
|
||||
0, creditsProperties.getErrors().getFreeProcessingErrors(), false, null);
|
||||
}
|
||||
|
||||
return new ErrorInfo(
|
||||
tracker.getProcessingErrorCount(),
|
||||
tracker.getErrorsUntilCharged(
|
||||
creditsProperties.getErrors().getFreeProcessingErrors()),
|
||||
tracker.shouldChargeForProcessingError(
|
||||
creditsProperties.getErrors().getFreeProcessingErrors()),
|
||||
tracker.getLastProcessingError());
|
||||
}
|
||||
|
||||
private UserErrorTracker getOrCreateErrorTracker(User user, String endpoint) {
|
||||
Optional<UserErrorTracker> existing =
|
||||
errorTrackerRepository.findByUserAndEndpoint(user, endpoint);
|
||||
|
||||
if (existing.isPresent()) {
|
||||
UserErrorTracker tracker = existing.get();
|
||||
|
||||
// Reset if expired
|
||||
if (tracker.isExpired()) {
|
||||
tracker.resetErrorCount(creditsProperties.getErrors().getTtlMinutes());
|
||||
}
|
||||
|
||||
return tracker;
|
||||
}
|
||||
|
||||
// Create new tracker
|
||||
return new UserErrorTracker(user, endpoint, creditsProperties.getErrors().getTtlMinutes());
|
||||
}
|
||||
|
||||
/** Clean up expired error trackers every hour */
|
||||
@Scheduled(cron = "0 0 * * * *")
|
||||
public void cleanupExpiredErrorTrackers() {
|
||||
try {
|
||||
int deleted = errorTrackerRepository.deleteExpiredErrorTrackers(LocalDateTime.now());
|
||||
if (deleted > 0) {
|
||||
log.debug("Cleaned up {} expired error trackers", deleted);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error cleaning up expired error trackers", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String maskApiKey(String apiKey) {
|
||||
if (apiKey == null || apiKey.length() < 8) {
|
||||
return "***";
|
||||
}
|
||||
return apiKey.substring(0, 4) + "***" + apiKey.substring(apiKey.length() - 4);
|
||||
}
|
||||
|
||||
/** Information about user's error status for an endpoint */
|
||||
public static class ErrorInfo {
|
||||
public final int currentErrorCount;
|
||||
public final int errorsUntilCharged;
|
||||
public final boolean isChargingForErrors;
|
||||
public final LocalDateTime lastError;
|
||||
|
||||
public ErrorInfo(
|
||||
int currentErrorCount,
|
||||
int errorsUntilCharged,
|
||||
boolean isChargingForErrors,
|
||||
LocalDateTime lastError) {
|
||||
this.currentErrorCount = currentErrorCount;
|
||||
this.errorsUntilCharged = errorsUntilCharged;
|
||||
this.isChargingForErrors = isChargingForErrors;
|
||||
this.lastError = lastError;
|
||||
}
|
||||
}
|
||||
|
||||
/** Cache entry for tracking error counts in memory */
|
||||
private static class ErrorCountCache {
|
||||
private int errorCount = 0;
|
||||
private LocalDateTime lastErrorTime = LocalDateTime.now();
|
||||
|
||||
public void incrementErrorCount() {
|
||||
errorCount++;
|
||||
lastErrorTime = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public int getErrorCount() {
|
||||
return errorCount;
|
||||
}
|
||||
|
||||
public LocalDateTime getLastErrorTime() {
|
||||
return lastErrorTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package stirling.software.saas.service;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.commons.lang3.tuple.Pair;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.FileInfo;
|
||||
import stirling.software.proprietary.security.service.DatabaseServiceInterface;
|
||||
|
||||
/** Saas-profile {@link DatabaseServiceInterface}: every method is a safe no-op. */
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@Slf4j
|
||||
public class NoOpDatabaseService implements DatabaseServiceInterface {
|
||||
|
||||
@Override
|
||||
public void exportDatabase() {
|
||||
log.debug("[saas] exportDatabase() skipped");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void importDatabase() {
|
||||
log.debug("[saas] importDatabase() skipped");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasBackup() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<FileInfo> getBackupList() {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Pair<FileInfo, Boolean>> deleteAllBackups() {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Pair<FileInfo, Boolean>> deleteLastBackup() {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getH2Version() {
|
||||
return "N/A (managed Postgres)";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package stirling.software.saas.service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Simple in-memory rate limiting service. Tracks attempts per key (e.g., user ID or team ID) and
|
||||
* enforces limits.
|
||||
*/
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@Slf4j
|
||||
public class RateLimitService {
|
||||
|
||||
// Rate limit configurations
|
||||
private static final int INVITATION_LIMIT_PER_HOUR = 50;
|
||||
private static final int INVITATION_LIMIT_PER_DAY = 150;
|
||||
|
||||
// In-memory storage: key -> (count, resetTime)
|
||||
private final ConcurrentHashMap<String, RateLimitBucket> hourlyLimits =
|
||||
new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<String, RateLimitBucket> dailyLimits =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Check if an invitation attempt is allowed for a team.
|
||||
*
|
||||
* @param teamId the team ID
|
||||
* @return true if allowed, false if rate limit exceeded
|
||||
*/
|
||||
public boolean allowInvitation(Long teamId) {
|
||||
String key = "team:" + teamId;
|
||||
|
||||
// Check hourly limit
|
||||
if (!checkAndIncrement(hourlyLimits, key, INVITATION_LIMIT_PER_HOUR, Duration.ofHours(1))) {
|
||||
log.warn("Team {} exceeded hourly invitation limit", teamId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check daily limit
|
||||
if (!checkAndIncrement(dailyLimits, key, INVITATION_LIMIT_PER_DAY, Duration.ofDays(1))) {
|
||||
log.warn("Team {} exceeded daily invitation limit", teamId);
|
||||
// Decrement hourly counter since we're rejecting
|
||||
decrementCounter(hourlyLimits, key);
|
||||
return false;
|
||||
}
|
||||
|
||||
log.debug("Team {} invitation allowed", teamId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining invitation quota for a team.
|
||||
*
|
||||
* @param teamId the team ID
|
||||
* @return remaining invitations allowed this hour
|
||||
*/
|
||||
public int getRemainingInvitations(Long teamId) {
|
||||
String key = "team:" + teamId;
|
||||
RateLimitBucket bucket = hourlyLimits.get(key);
|
||||
|
||||
if (bucket == null || bucket.isExpired()) {
|
||||
return INVITATION_LIMIT_PER_HOUR;
|
||||
}
|
||||
|
||||
return Math.max(0, INVITATION_LIMIT_PER_HOUR - bucket.getCount());
|
||||
}
|
||||
|
||||
/** Check rate limit and increment counter if allowed. */
|
||||
private boolean checkAndIncrement(
|
||||
ConcurrentHashMap<String, RateLimitBucket> storage,
|
||||
String key,
|
||||
int limit,
|
||||
Duration window) {
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
long resetTime = now + window.toMillis();
|
||||
|
||||
RateLimitBucket bucket =
|
||||
storage.compute(
|
||||
key,
|
||||
(k, existing) -> {
|
||||
if (existing == null || existing.isExpired()) {
|
||||
return new RateLimitBucket(1, resetTime);
|
||||
} else {
|
||||
existing.increment();
|
||||
return existing;
|
||||
}
|
||||
});
|
||||
|
||||
return bucket.getCount() <= limit;
|
||||
}
|
||||
|
||||
/** Decrement counter (for rollback scenarios). */
|
||||
private void decrementCounter(ConcurrentHashMap<String, RateLimitBucket> storage, String key) {
|
||||
storage.computeIfPresent(
|
||||
key,
|
||||
(k, bucket) -> {
|
||||
bucket.decrement();
|
||||
return bucket;
|
||||
});
|
||||
}
|
||||
|
||||
/** Cleanup expired buckets every hour. */
|
||||
@Scheduled(fixedRate = 3600000) // 1 hour
|
||||
public void cleanupExpiredBuckets() {
|
||||
long now = System.currentTimeMillis();
|
||||
|
||||
int hourlyRemoved =
|
||||
(int)
|
||||
hourlyLimits.entrySet().stream()
|
||||
.filter(e -> e.getValue().getResetTime() < now)
|
||||
.peek(e -> hourlyLimits.remove(e.getKey()))
|
||||
.count();
|
||||
|
||||
int dailyRemoved =
|
||||
(int)
|
||||
dailyLimits.entrySet().stream()
|
||||
.filter(e -> e.getValue().getResetTime() < now)
|
||||
.peek(e -> dailyLimits.remove(e.getKey()))
|
||||
.count();
|
||||
|
||||
if (hourlyRemoved + dailyRemoved > 0) {
|
||||
log.debug(
|
||||
"Cleaned up {} expired rate limit buckets (hourly: {}, daily: {})",
|
||||
hourlyRemoved + dailyRemoved,
|
||||
hourlyRemoved,
|
||||
dailyRemoved);
|
||||
}
|
||||
}
|
||||
|
||||
/** Internal class to track rate limit counts and reset times. */
|
||||
private static class RateLimitBucket {
|
||||
private final AtomicInteger count;
|
||||
private final long resetTime;
|
||||
|
||||
public RateLimitBucket(int initialCount, long resetTime) {
|
||||
this.count = new AtomicInteger(initialCount);
|
||||
this.resetTime = resetTime;
|
||||
}
|
||||
|
||||
public int getCount() {
|
||||
return count.get();
|
||||
}
|
||||
|
||||
public void increment() {
|
||||
count.incrementAndGet();
|
||||
}
|
||||
|
||||
public void decrement() {
|
||||
count.decrementAndGet();
|
||||
}
|
||||
|
||||
public long getResetTime() {
|
||||
return resetTime;
|
||||
}
|
||||
|
||||
public boolean isExpired() {
|
||||
return System.currentTimeMillis() > resetTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package stirling.software.saas.service;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.saas.model.SaasTeamExtensions;
|
||||
import stirling.software.saas.repository.SaasTeamExtensionsRepository;
|
||||
|
||||
/**
|
||||
* Read/write access to {@link SaasTeamExtensions}. Reads return safe defaults (non-personal team,
|
||||
* seat fields zeroed) when no row exists; writes create the row lazily.
|
||||
*/
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class SaasTeamExtensionService {
|
||||
|
||||
private final SaasTeamExtensionsRepository repository;
|
||||
|
||||
public SaasTeamExtensions getOrCreate(Team team) {
|
||||
return repository
|
||||
.findByTeamId(team.getId())
|
||||
.orElseGet(() -> repository.save(new SaasTeamExtensions(team)));
|
||||
}
|
||||
|
||||
/** Whether the team is personal (1-seat owned by a single user). Defaults to false. */
|
||||
public boolean isPersonal(Team team) {
|
||||
if (team == null || team.getId() == null) {
|
||||
return false;
|
||||
}
|
||||
return repository
|
||||
.findByTeamId(team.getId())
|
||||
.map(SaasTeamExtensions::isPersonal)
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
/** Team type string ("PERSONAL" or "STANDARD"). Defaults to STANDARD. */
|
||||
public String getTeamType(Team team) {
|
||||
if (team == null || team.getId() == null) {
|
||||
return SaasTeamExtensions.TEAM_TYPE_STANDARD;
|
||||
}
|
||||
return repository
|
||||
.findByTeamId(team.getId())
|
||||
.map(SaasTeamExtensions::getTeamType)
|
||||
.orElse(SaasTeamExtensions.TEAM_TYPE_STANDARD);
|
||||
}
|
||||
|
||||
public int getSeatsUsed(Team team) {
|
||||
return repository
|
||||
.findByTeamId(team.getId())
|
||||
.map(SaasTeamExtensions::getSeatsUsed)
|
||||
.orElse(0);
|
||||
}
|
||||
|
||||
public int getMaxSeats(Team team) {
|
||||
return repository.findByTeamId(team.getId()).map(SaasTeamExtensions::getMaxSeats).orElse(1);
|
||||
}
|
||||
|
||||
public Long getCreatedByUserId(Team team) {
|
||||
return repository
|
||||
.findByTeamId(team.getId())
|
||||
.map(SaasTeamExtensions::getCreatedByUserId)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/** Whether the team has unused seats. See {@link SaasTeamExtensions#hasAvailableSeats()}. */
|
||||
public boolean hasAvailableSeats(Team team) {
|
||||
return repository
|
||||
.findByTeamId(team.getId())
|
||||
.map(SaasTeamExtensions::hasAvailableSeats)
|
||||
.orElse(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the team accepts new invitations. See {@link SaasTeamExtensions#canInviteMembers()}.
|
||||
*/
|
||||
public boolean canInviteMembers(Team team) {
|
||||
return repository
|
||||
.findByTeamId(team.getId())
|
||||
.map(SaasTeamExtensions::canInviteMembers)
|
||||
.orElse(true);
|
||||
}
|
||||
|
||||
/** Atomic seat increment with personal-team cap enforcement. */
|
||||
@Transactional
|
||||
public int incrementSeatsUsed(Team team) {
|
||||
getOrCreate(team);
|
||||
return repository.incrementSeatsUsed(team.getId());
|
||||
}
|
||||
|
||||
/** Atomic seat decrement, floored at 0. */
|
||||
@Transactional
|
||||
public int decrementSeatsUsed(Team team) {
|
||||
return repository.decrementSeatsUsed(team.getId());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void setPersonal(Team team, boolean personal) {
|
||||
SaasTeamExtensions ext = getOrCreate(team);
|
||||
ext.setIsPersonal(personal);
|
||||
ext.setTeamType(
|
||||
personal
|
||||
? SaasTeamExtensions.TEAM_TYPE_PERSONAL
|
||||
: SaasTeamExtensions.TEAM_TYPE_STANDARD);
|
||||
repository.save(ext);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void setSeats(Team team, int seatCount, int maxSeats) {
|
||||
SaasTeamExtensions ext = getOrCreate(team);
|
||||
ext.setSeatCount(seatCount);
|
||||
ext.setMaxSeats(maxSeats);
|
||||
repository.save(ext);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void setCreatedByUserId(Team team, Long createdByUserId) {
|
||||
SaasTeamExtensions ext = getOrCreate(team);
|
||||
ext.setCreatedByUserId(createdByUserId);
|
||||
repository.save(ext);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,865 @@
|
||||
package stirling.software.saas.service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.enumeration.InvitationStatus;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.common.model.enumeration.TeamRole;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.saas.billing.repository.BillingSubscriptionRepository;
|
||||
import stirling.software.saas.config.CreditsProperties;
|
||||
import stirling.software.saas.config.SupabaseConfigurationProperties;
|
||||
import stirling.software.saas.model.TeamCredit;
|
||||
import stirling.software.saas.model.TeamInvitation;
|
||||
import stirling.software.saas.model.TeamMembership;
|
||||
import stirling.software.saas.repository.SaasTeamExtensionsRepository;
|
||||
import stirling.software.saas.repository.TeamCreditRepository;
|
||||
import stirling.software.saas.repository.TeamInvitationRepository;
|
||||
import stirling.software.saas.repository.TeamMembershipRepository;
|
||||
import stirling.software.saas.repository.UserCreditRepository;
|
||||
|
||||
/** SaaS-only team management: invitations, personal teams, seat caps, paid-subscription gating. */
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class SaasTeamService {
|
||||
|
||||
private final TeamRepository teamRepository;
|
||||
private final TeamMembershipRepository membershipRepository;
|
||||
private final TeamInvitationRepository invitationRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final UserCreditRepository userCreditRepository;
|
||||
private final BillingSubscriptionRepository billingSubscriptionRepository;
|
||||
private final TeamCreditService teamCreditService;
|
||||
private final TeamCreditRepository teamCreditRepository;
|
||||
private final CreditsProperties creditsProperties;
|
||||
private final RestTemplate restTemplate;
|
||||
private final RateLimitService rateLimitService;
|
||||
private final SupabaseConfigurationProperties supabaseConfig;
|
||||
private final UserRoleService userRoleService;
|
||||
private final SaasTeamExtensionService saasTeamExtensionService;
|
||||
private final SaasTeamExtensionsRepository saasTeamExtensionsRepository;
|
||||
private final stirling.software.proprietary.security.service.UserService userService;
|
||||
|
||||
public static final String DEFAULT_TEAM_NAME = "Default";
|
||||
public static final String INTERNAL_TEAM_NAME = "Internal";
|
||||
|
||||
/**
|
||||
* Create personal team for new user during signup or migrate existing user from Default team
|
||||
*
|
||||
* @param user the user
|
||||
* @return created Team
|
||||
*/
|
||||
@Transactional
|
||||
public Team createPersonalTeam(User user) {
|
||||
final long userId = user.getId();
|
||||
// Refetch so the entity is managed by the current session.
|
||||
user =
|
||||
userRepository
|
||||
.findById(userId)
|
||||
.orElseThrow(
|
||||
() -> new IllegalArgumentException("User not found: " + userId));
|
||||
|
||||
String personalTeamName = "My Team";
|
||||
|
||||
Team team = new Team();
|
||||
team.setName(personalTeamName);
|
||||
Team savedTeam = teamRepository.save(team);
|
||||
|
||||
saasTeamExtensionService.setPersonal(savedTeam, true);
|
||||
saasTeamExtensionService.setSeats(savedTeam, 1, 1);
|
||||
saasTeamExtensionService.setCreatedByUserId(savedTeam, user.getId());
|
||||
saasTeamExtensionsRepository.incrementSeatsUsed(savedTeam.getId());
|
||||
|
||||
// Create membership
|
||||
TeamMembership membership = new TeamMembership();
|
||||
membership.setTeam(savedTeam);
|
||||
membership.setUser(user);
|
||||
membership.setRole(TeamRole.LEADER);
|
||||
membership.setInvitedAt(LocalDateTime.now());
|
||||
membership.setAcceptedAt(LocalDateTime.now());
|
||||
membershipRepository.save(membership);
|
||||
|
||||
// Update user's team_id to point to personal team
|
||||
Team oldTeam = user.getTeam();
|
||||
user.setTeam(savedTeam);
|
||||
userRepository.save(user);
|
||||
|
||||
// Initialize team credits
|
||||
teamCreditService.initializeTeamCredits(savedTeam, user);
|
||||
|
||||
// Clean up old Default/Internal team membership
|
||||
if (oldTeam != null
|
||||
&& (DEFAULT_TEAM_NAME.equals(oldTeam.getName())
|
||||
|| INTERNAL_TEAM_NAME.equals(oldTeam.getName()))) {
|
||||
membershipRepository.deleteByTeamIdAndUserId(oldTeam.getId(), user.getId());
|
||||
|
||||
// Note: We intentionally leave the Default/Internal team in the database even if empty
|
||||
// Deleting it within the same transaction causes Hibernate session management issues
|
||||
// Empty system teams are harmless and can be cleaned up manually if needed
|
||||
}
|
||||
|
||||
log.debug("Created personal team {} for user {}", savedTeam.getId(), user.getId());
|
||||
return savedTeam;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite user to team (sends email via Supabase Edge Function)
|
||||
*
|
||||
* @param teamId the team ID
|
||||
* @param inviteeEmail the invitee's email
|
||||
* @param inviter the user sending the invitation
|
||||
* @return created TeamInvitation
|
||||
*/
|
||||
@Transactional
|
||||
public TeamInvitation inviteUserToTeam(Long teamId, String inviteeEmail, User inviter) {
|
||||
Team team =
|
||||
teamRepository
|
||||
.findById(teamId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Team not found"));
|
||||
|
||||
// Validate: inviter is team leader
|
||||
TeamMembership inviterMembership =
|
||||
membershipRepository
|
||||
.findByTeamIdAndUserId(teamId, inviter.getId())
|
||||
.orElseThrow(
|
||||
() -> new SecurityException("You are not a member of this team"));
|
||||
|
||||
if (!inviterMembership.isLeader()) {
|
||||
throw new SecurityException("Only team leaders can invite members");
|
||||
}
|
||||
|
||||
// Auto-convert personal team to non-personal team for Pro users
|
||||
if (saasTeamExtensionService.isPersonal(team)) {
|
||||
log.info(
|
||||
"Converting personal team {} to non-personal team for first invitation by {}",
|
||||
team.getName(),
|
||||
inviter.getUsername());
|
||||
saasTeamExtensionService.setPersonal(team, false);
|
||||
// Unlimited seats once converted to standard
|
||||
saasTeamExtensionService.setSeats(team, Integer.MAX_VALUE, Integer.MAX_VALUE);
|
||||
}
|
||||
|
||||
// Validate: team can invite (not personal, has available seats)
|
||||
if (!saasTeamExtensionService.canInviteMembers(team)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Cannot invite members: personal team or no available seats");
|
||||
}
|
||||
|
||||
// Check rate limit (10 invitations per hour, 50 per day)
|
||||
if (!rateLimitService.allowInvitation(teamId)) {
|
||||
int remaining = rateLimitService.getRemainingInvitations(teamId);
|
||||
throw new IllegalStateException(
|
||||
String.format(
|
||||
"Rate limit exceeded. Please try again later. (Remaining: %d)",
|
||||
remaining));
|
||||
}
|
||||
|
||||
// Check if there's already a pending invitation
|
||||
if (invitationRepository.existsPendingInvitationByTeamIdAndEmail(teamId, inviteeEmail)) {
|
||||
throw new IllegalArgumentException("Pending invitation already exists for this email");
|
||||
}
|
||||
|
||||
// Check if invitee already exists and has active paid subscription
|
||||
userRepository
|
||||
.findByEmail(inviteeEmail)
|
||||
.ifPresent(
|
||||
existingUser -> {
|
||||
if (hasPaidSubscription(existingUser)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Cannot invite paid users to teams. Only team leaders manage billing.");
|
||||
}
|
||||
|
||||
// Check if already a member
|
||||
if (membershipRepository.existsByTeamIdAndUserId(
|
||||
teamId, existingUser.getId())) {
|
||||
throw new IllegalArgumentException("User is already a team member");
|
||||
}
|
||||
});
|
||||
|
||||
// Create invitation
|
||||
TeamInvitation invitation = new TeamInvitation();
|
||||
invitation.setTeam(team);
|
||||
invitation.setInviter(inviter);
|
||||
invitation.setInviteeEmail(inviteeEmail);
|
||||
invitation.setStatus(InvitationStatus.PENDING);
|
||||
invitation.setInvitationToken(UUID.randomUUID().toString());
|
||||
invitation.setExpiresAt(LocalDateTime.now().plusDays(7));
|
||||
|
||||
userRepository.findByEmail(inviteeEmail).ifPresent(invitation::setInviteeUser);
|
||||
|
||||
TeamInvitation savedInvitation = invitationRepository.save(invitation);
|
||||
|
||||
// Send invitation email
|
||||
sendInvitationEmail(savedInvitation);
|
||||
|
||||
log.info(
|
||||
"User {} invited {} to team {}",
|
||||
inviter.getUsername(),
|
||||
inviteeEmail,
|
||||
team.getName());
|
||||
return savedInvitation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an invitation and grant PRO role in the same transaction. If the role grant fails the
|
||||
* membership write is rolled back so we never end up with a member sitting on a paid team
|
||||
* without the matching role (regression #18).
|
||||
*/
|
||||
@Transactional
|
||||
public void acceptInvitationAndGrantRole(String invitationToken, User acceptingUser)
|
||||
throws java.sql.SQLException,
|
||||
stirling.software.common.model.exception.UnsupportedProviderException {
|
||||
acceptInvitation(invitationToken, acceptingUser);
|
||||
|
||||
// Re-read the user post-accept; acceptInvitation refetches+saves them.
|
||||
User user = userRepository.findById(acceptingUser.getId()).orElse(acceptingUser);
|
||||
Team userTeam = user.getTeam();
|
||||
if (userTeam == null || !hasActivePaidSubscription(userTeam)) {
|
||||
log.warn(
|
||||
"User {} joined team {} but team has no active subscription - not granting PRO role",
|
||||
user.getUsername(),
|
||||
userTeam != null ? userTeam.getName() : "null");
|
||||
return;
|
||||
}
|
||||
String currentRole = user.getRolesAsString();
|
||||
if (stirling.software.common.model.enumeration.Role.PRO_USER
|
||||
.getRoleId()
|
||||
.equals(currentRole)) {
|
||||
return;
|
||||
}
|
||||
log.info(
|
||||
"Granting ROLE_PRO_USER to user {} joining team {} with active subscription",
|
||||
user.getUsername(),
|
||||
userTeam.getName());
|
||||
userService.changeRole(
|
||||
user, stirling.software.common.model.enumeration.Role.PRO_USER.getRoleId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an invitation. The user's individual subscription stays active but is suspended in
|
||||
* favour of the team's shared pool; it resumes if they leave the team.
|
||||
*/
|
||||
@Transactional
|
||||
public void acceptInvitation(String invitationToken, User acceptingUser) {
|
||||
// Refetch via id rather than reattach: supabase_auth_id is insertable/updatable=false and
|
||||
// gets cleared on detach.
|
||||
final long userId = acceptingUser.getId();
|
||||
acceptingUser =
|
||||
userRepository
|
||||
.findById(userId)
|
||||
.orElseThrow(
|
||||
() -> new IllegalArgumentException("User not found: " + userId));
|
||||
|
||||
TeamInvitation invitation =
|
||||
invitationRepository
|
||||
.findByInvitationToken(invitationToken)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Invitation not found"));
|
||||
|
||||
// Force lazy-load inviter early to ensure it's in managed state
|
||||
User inviter = invitation.getInviter();
|
||||
|
||||
// Validate invitation status
|
||||
if (invitation.getStatus() != InvitationStatus.PENDING) {
|
||||
throw new IllegalStateException(
|
||||
"Invitation already processed: " + invitation.getStatus());
|
||||
}
|
||||
|
||||
if (invitation.isExpired()) {
|
||||
invitation.setStatus(InvitationStatus.EXPIRED);
|
||||
invitationRepository.save(invitation);
|
||||
throw new IllegalStateException("Invitation expired");
|
||||
}
|
||||
|
||||
// Validate: email matches
|
||||
if (!invitation.getInviteeEmail().equalsIgnoreCase(acceptingUser.getEmail())) {
|
||||
throw new SecurityException("Invitation email mismatch");
|
||||
}
|
||||
|
||||
// Validate: user doesn't have paid subscription
|
||||
if (hasPaidSubscription(acceptingUser)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Cannot join team with active paid subscription. Cancel your subscription first.");
|
||||
}
|
||||
|
||||
// Validate: team has available seats
|
||||
Team team = invitation.getTeam();
|
||||
if (!saasTeamExtensionService.hasAvailableSeats(team)) {
|
||||
throw new IllegalStateException("Team has no available seats");
|
||||
}
|
||||
|
||||
// User can only be in one team . leave existing teams before joining new one
|
||||
List<TeamMembership> existingMemberships =
|
||||
membershipRepository.findByUserId(acceptingUser.getId());
|
||||
List<Team> teamsToDelete = new java.util.ArrayList<>();
|
||||
|
||||
for (TeamMembership existingMembership : existingMemberships) {
|
||||
Team oldTeam = existingMembership.getTeam();
|
||||
|
||||
membershipRepository.delete(existingMembership);
|
||||
|
||||
saasTeamExtensionsRepository.decrementSeatsUsed(oldTeam.getId());
|
||||
|
||||
// Mark personal team for deletion if it's now empty (user was the only member)
|
||||
if (saasTeamExtensionService.isPersonal(oldTeam)
|
||||
&& membershipRepository.countByTeamId(oldTeam.getId()) == 0) {
|
||||
teamsToDelete.add(oldTeam);
|
||||
}
|
||||
|
||||
log.info(
|
||||
"User {} left team {} to join team {}",
|
||||
acceptingUser.getUsername(),
|
||||
oldTeam.getName(),
|
||||
team.getName());
|
||||
}
|
||||
|
||||
// Native query: avoids Hibernate touching the read-only supabase_auth_id column.
|
||||
userRepository.updateUserTeamId(acceptingUser.getId(), team.getId());
|
||||
acceptingUser.setTeam(team);
|
||||
log.info(
|
||||
"User {} team reference updated to team {}",
|
||||
acceptingUser.getUsername(),
|
||||
team.getName());
|
||||
|
||||
// Now safe to delete empty personal teams
|
||||
for (Team teamToDelete : teamsToDelete) {
|
||||
log.info(
|
||||
"Deleting empty personal team {} after user {} joined another team",
|
||||
teamToDelete.getId(),
|
||||
acceptingUser.getUsername());
|
||||
teamRepository.delete(teamToDelete);
|
||||
}
|
||||
|
||||
// Create team membership
|
||||
TeamMembership membership = new TeamMembership();
|
||||
membership.setTeam(team);
|
||||
membership.setUser(acceptingUser);
|
||||
membership.setRole(TeamRole.MEMBER);
|
||||
membership.setInvitedBy(inviter);
|
||||
membership.setInvitedAt(invitation.getCreatedAt());
|
||||
membership.setAcceptedAt(LocalDateTime.now());
|
||||
membershipRepository.save(membership);
|
||||
|
||||
log.info(
|
||||
"User {} added to team {} with role MEMBER",
|
||||
acceptingUser.getUsername(),
|
||||
team.getName());
|
||||
|
||||
// incrementSeatsUsed enforces the seat cap atomically; rowsUpdated==0 means at capacity.
|
||||
int rowsUpdated = saasTeamExtensionsRepository.incrementSeatsUsed(team.getId());
|
||||
if (rowsUpdated == 0) {
|
||||
throw new IllegalStateException("Team has no available seats");
|
||||
}
|
||||
log.info("Team {} seats_used incremented", team.getName());
|
||||
|
||||
// Don't set inviteeUser; acceptance is recorded via status + TeamMembership row.
|
||||
invitation.setStatus(InvitationStatus.ACCEPTED);
|
||||
invitationRepository.save(invitation);
|
||||
|
||||
log.info(
|
||||
"User {} accepted invitation to team {}",
|
||||
acceptingUser.getUsername(),
|
||||
team.getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove team member (only by team leader)
|
||||
*
|
||||
* @param teamId the team ID
|
||||
* @param memberUserId the user ID to remove
|
||||
* @param remover the user performing the removal
|
||||
*/
|
||||
@Transactional
|
||||
public void removeTeamMember(Long teamId, Long memberUserId, User remover) {
|
||||
// Validate: remover is team leader
|
||||
TeamMembership removerMembership =
|
||||
membershipRepository
|
||||
.findByTeamIdAndUserId(teamId, remover.getId())
|
||||
.orElseThrow(
|
||||
() -> new SecurityException("You are not a member of this team"));
|
||||
|
||||
if (!removerMembership.isLeader()) {
|
||||
throw new SecurityException("Only team leaders can remove members");
|
||||
}
|
||||
|
||||
// Cannot remove yourself if you're the only leader
|
||||
List<TeamMembership> leaders =
|
||||
membershipRepository.findByTeamIdAndRole(teamId, TeamRole.LEADER);
|
||||
if (removerMembership.getUser().getId().equals(memberUserId) && leaders.size() == 1) {
|
||||
throw new IllegalStateException(
|
||||
"Cannot remove the last team leader. Transfer leadership first.");
|
||||
}
|
||||
|
||||
TeamMembership memberToRemove =
|
||||
membershipRepository
|
||||
.findByTeamIdAndUserId(teamId, memberUserId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found in team"));
|
||||
|
||||
User userToRemove = memberToRemove.getUser();
|
||||
|
||||
membershipRepository.delete(memberToRemove);
|
||||
|
||||
// Atomically decrement team seats_used (prevents race condition)
|
||||
saasTeamExtensionsRepository.decrementSeatsUsed(teamId);
|
||||
|
||||
// Fetch team for downstream checks
|
||||
Team team = teamRepository.findById(teamId).orElseThrow();
|
||||
|
||||
// Create new personal team for removed user
|
||||
createPersonalTeam(userToRemove);
|
||||
|
||||
// Downgrade user to FREE tier after leaving team
|
||||
// They either had a trial (which was cancelled) or had an existing subscription
|
||||
// Either way, they should be FREE after leaving
|
||||
downgradeUserToFree(userToRemove);
|
||||
|
||||
// Delete non-personal team if it's now empty
|
||||
if (!saasTeamExtensionService.isPersonal(team)
|
||||
&& membershipRepository.countByTeamId(teamId) == 0) {
|
||||
log.info("Deleting empty non-personal team {} after last member removed", teamId);
|
||||
teamRepository.delete(team);
|
||||
}
|
||||
|
||||
log.info(
|
||||
"User {} removed user {} from team {} and created new personal team",
|
||||
remover.getId(),
|
||||
memberUserId,
|
||||
teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave team (self-removal)
|
||||
*
|
||||
* @param teamId the team ID
|
||||
* @param user the user leaving
|
||||
*/
|
||||
@Transactional
|
||||
public void leaveTeam(Long teamId, User user) {
|
||||
TeamMembership membership =
|
||||
membershipRepository
|
||||
.findByTeamIdAndUserId(teamId, user.getId())
|
||||
.orElseThrow(
|
||||
() -> new IllegalArgumentException("Not a member of this team"));
|
||||
|
||||
// Cannot leave if you're the only leader
|
||||
if (membership.isLeader()) {
|
||||
List<TeamMembership> leaders =
|
||||
membershipRepository.findByTeamIdAndRole(teamId, TeamRole.LEADER);
|
||||
if (leaders.size() == 1) {
|
||||
throw new IllegalStateException(
|
||||
"Cannot leave as the last team leader. Transfer leadership first.");
|
||||
}
|
||||
}
|
||||
|
||||
membershipRepository.delete(membership);
|
||||
|
||||
// Atomically decrement team seats_used (prevents race condition)
|
||||
saasTeamExtensionsRepository.decrementSeatsUsed(teamId);
|
||||
|
||||
// Fetch team for downstream checks
|
||||
Team team = teamRepository.findById(teamId).orElseThrow();
|
||||
|
||||
// Create new personal team for user who left
|
||||
createPersonalTeam(user);
|
||||
|
||||
// Check if user should be downgraded after leaving team
|
||||
// If user has an active subscription (including trial), they keep PRO access
|
||||
// Otherwise, downgrade to FREE tier
|
||||
downgradeUserToFree(user);
|
||||
|
||||
// Delete non-personal team if it's now empty
|
||||
if (!saasTeamExtensionService.isPersonal(team)
|
||||
&& membershipRepository.countByTeamId(teamId) == 0) {
|
||||
log.info("Deleting empty non-personal team {} after last member left", teamId);
|
||||
teamRepository.delete(team);
|
||||
}
|
||||
|
||||
log.info("User {} left team {} and created new personal team", user.getId(), teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has active paid subscription.
|
||||
*
|
||||
* <p>Queries the billing_subscriptions table to determine if the user has an active, trialing,
|
||||
* or past_due subscription. This prevents inviting users who are already paying for their own
|
||||
* subscription, which would create billing conflicts.
|
||||
*
|
||||
* @param user the user to check
|
||||
* @return true if user has active paid subscription
|
||||
*/
|
||||
private boolean hasPaidSubscription(User user) {
|
||||
if (user.getSupabaseId() == null) {
|
||||
log.debug(
|
||||
"User {} has no Supabase ID, cannot check billing subscription", user.getId());
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check for active PAID subscriptions only (excludes trialing users)
|
||||
// Trialing users and users with scheduled cancellations can be invited to teams
|
||||
boolean hasActivePaidSubscription =
|
||||
billingSubscriptionRepository.existsActivePaidSubscriptionForUser(
|
||||
user.getSupabaseId());
|
||||
|
||||
if (hasActivePaidSubscription) {
|
||||
log.info(
|
||||
"User {} (supabase: {}) has active paid subscription",
|
||||
user.getId(),
|
||||
user.getSupabaseId());
|
||||
}
|
||||
|
||||
return hasActivePaidSubscription;
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"Error checking billing subscription for user {}: {}",
|
||||
user.getId(),
|
||||
e.getMessage(),
|
||||
e);
|
||||
// Fail safe: treat as if they DO have a subscription to avoid double billing
|
||||
// Better to block invitation and require manual check than risk inviting paying
|
||||
// customer
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if team has an active paid subscription. This determines if new team members should be
|
||||
* granted ROLE_PRO_USER.
|
||||
*
|
||||
* @param team the team to check
|
||||
* @return true if team has active paid subscription
|
||||
*/
|
||||
public boolean hasActivePaidSubscription(Team team) {
|
||||
if (team == null || team.getId() == null) {
|
||||
log.debug("Team is null or has no ID, cannot check subscription");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
boolean hasActiveSubscription =
|
||||
billingSubscriptionRepository.existsActiveSubscriptionForTeam(team.getId());
|
||||
|
||||
if (hasActiveSubscription) {
|
||||
log.info("Team {} has active paid subscription", team.getId());
|
||||
}
|
||||
|
||||
return hasActiveSubscription;
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"Error checking billing subscription for team {}: {}",
|
||||
team.getId(),
|
||||
e.getMessage(),
|
||||
e);
|
||||
// Fail safe: assume no subscription on error
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send invitation email via Supabase Edge Function
|
||||
*
|
||||
* @param invitation the invitation
|
||||
*/
|
||||
private void sendInvitationEmail(TeamInvitation invitation) {
|
||||
try {
|
||||
// Check if Supabase is configured
|
||||
if (!supabaseConfig.isEdgeFunctionConfigured()) {
|
||||
log.warn(
|
||||
"Supabase integration not configured, skipping email send. "
|
||||
+ "Please configure supabase.edgeFunctionUrl and supabase.edgeFunctionSecret "
|
||||
+ "in application properties.");
|
||||
return;
|
||||
}
|
||||
|
||||
String url = supabaseConfig.getEdgeFunctionUrl() + "/team-invitation-email";
|
||||
String edgeFunctionSecret = supabaseConfig.getEdgeFunctionSecret();
|
||||
|
||||
var requestBody =
|
||||
new EmailInvitationRequest(
|
||||
invitation.getInviteeEmail(),
|
||||
invitation.getTeam().getName(),
|
||||
invitation.getInviter().getEmail(),
|
||||
invitation.getInvitationToken());
|
||||
|
||||
// Create headers with authorization
|
||||
org.springframework.http.HttpHeaders headers =
|
||||
new org.springframework.http.HttpHeaders();
|
||||
headers.set("Authorization", "Bearer " + edgeFunctionSecret);
|
||||
headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON);
|
||||
|
||||
org.springframework.http.HttpEntity<EmailInvitationRequest> entity =
|
||||
new org.springframework.http.HttpEntity<>(requestBody, headers);
|
||||
|
||||
restTemplate.postForEntity(url, entity, String.class);
|
||||
|
||||
log.info(
|
||||
"Sent invitation email to {} for team {}",
|
||||
invitation.getInviteeEmail(),
|
||||
invitation.getTeam().getName());
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to send invitation email", e);
|
||||
// Don't throw . invitation is already saved
|
||||
}
|
||||
}
|
||||
|
||||
/** Request body for edge function email */
|
||||
private record EmailInvitationRequest(
|
||||
String invitee_email, String team_name, String inviter_name, String invitation_token) {}
|
||||
|
||||
/**
|
||||
* Update team seat allocation (called by billing webhooks)
|
||||
*
|
||||
* @param teamId the team ID
|
||||
* @param maxSeats new maximum seat count
|
||||
*/
|
||||
@Transactional
|
||||
public void updateTeamSeats(Long teamId, Integer maxSeats) {
|
||||
if (maxSeats == null || maxSeats < 1) {
|
||||
throw new IllegalArgumentException("maxSeats must be at least 1");
|
||||
}
|
||||
|
||||
Team team =
|
||||
teamRepository
|
||||
.findById(teamId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Team not found"));
|
||||
|
||||
// Handle seat reduction: automatically remove excess members if reducing below current
|
||||
// usage
|
||||
int currentSeatsUsed = saasTeamExtensionService.getSeatsUsed(team);
|
||||
int currentMaxSeats = saasTeamExtensionService.getMaxSeats(team);
|
||||
if (maxSeats < currentSeatsUsed) {
|
||||
int excessMembers = currentSeatsUsed - maxSeats;
|
||||
log.warn(
|
||||
"Team {} reducing seats from {} to {} with {} current members. Removing {} excess members.",
|
||||
teamId,
|
||||
currentMaxSeats,
|
||||
maxSeats,
|
||||
currentSeatsUsed,
|
||||
excessMembers);
|
||||
|
||||
// Get all team members, sorted by priority (non-leaders first, most recently joined
|
||||
// first)
|
||||
List<TeamMembership> allMembers = membershipRepository.findByTeamId(teamId);
|
||||
|
||||
// Sort: MEMBER role first (non-leaders), then by accepted date descending (most recent
|
||||
// first)
|
||||
List<TeamMembership> membersToRemove =
|
||||
allMembers.stream()
|
||||
.sorted(
|
||||
(m1, m2) -> {
|
||||
// Leaders (LEADER role) come last (lower priority for
|
||||
// removal)
|
||||
if (m1.getRole() != m2.getRole()) {
|
||||
return m1.getRole() == TeamRole.LEADER ? 1 : -1;
|
||||
}
|
||||
// Among same role, remove most recently joined first
|
||||
// (descending accepted date)
|
||||
LocalDateTime date1 =
|
||||
m1.getAcceptedAt() != null
|
||||
? m1.getAcceptedAt()
|
||||
: m1.getInvitedAt();
|
||||
LocalDateTime date2 =
|
||||
m2.getAcceptedAt() != null
|
||||
? m2.getAcceptedAt()
|
||||
: m2.getInvitedAt();
|
||||
if (date1 == null && date2 == null) return 0;
|
||||
if (date1 == null) return 1;
|
||||
if (date2 == null) return -1;
|
||||
return date2.compareTo(
|
||||
date1); // Descending (most recent first)
|
||||
})
|
||||
.limit(excessMembers)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
|
||||
// Remove excess members
|
||||
for (TeamMembership membership : membersToRemove) {
|
||||
User userToRemove = membership.getUser();
|
||||
log.info(
|
||||
"Removing user {} ({}) from team {} due to seat reduction",
|
||||
userToRemove.getId(),
|
||||
userToRemove.getUsername(),
|
||||
teamId);
|
||||
|
||||
// Delete membership
|
||||
membershipRepository.delete(membership);
|
||||
|
||||
// Atomically decrement seats_used count (prevents race condition)
|
||||
saasTeamExtensionsRepository.decrementSeatsUsed(teamId);
|
||||
|
||||
// Create new personal team for removed user
|
||||
createPersonalTeam(userToRemove);
|
||||
|
||||
// Downgrade user to FREE tier
|
||||
downgradeUserToFree(userToRemove);
|
||||
|
||||
log.info(
|
||||
"User {} removed from team {} and migrated to personal team due to seat reduction",
|
||||
userToRemove.getId(),
|
||||
teamId);
|
||||
}
|
||||
|
||||
log.info(
|
||||
"Completed seat reduction for team {}: removed {} members",
|
||||
teamId,
|
||||
excessMembers);
|
||||
}
|
||||
|
||||
saasTeamExtensionService.setSeats(team, maxSeats, maxSeats);
|
||||
|
||||
// Personal team with maxSeats > 1 is really a standard team.
|
||||
boolean wasPersonal = saasTeamExtensionService.isPersonal(team);
|
||||
if (wasPersonal && maxSeats > 1) {
|
||||
saasTeamExtensionService.setPersonal(team, false);
|
||||
log.info(
|
||||
"Team {} converted from PERSONAL to STANDARD (maxSeats increased to {})",
|
||||
teamId,
|
||||
maxSeats);
|
||||
}
|
||||
// Revert to personal when downgrading to 1 seat
|
||||
else if (!wasPersonal && maxSeats == 1) {
|
||||
saasTeamExtensionService.setPersonal(team, true);
|
||||
log.info("Team {} converted from STANDARD to PERSONAL (maxSeats reduced to 1)", teamId);
|
||||
}
|
||||
|
||||
teamRepository.save(team);
|
||||
|
||||
Optional<TeamCredit> creditOpt = teamCreditRepository.findByTeamId(teamId);
|
||||
|
||||
int fixedAllocation =
|
||||
creditsProperties.getCycle().getAllocations().getOrDefault("ROLE_PRO_USER", 500);
|
||||
|
||||
if (creditOpt.isPresent()) {
|
||||
TeamCredit credit = creditOpt.get();
|
||||
|
||||
int oldAllocation =
|
||||
credit.getCycleCreditsAllocated() != null
|
||||
? credit.getCycleCreditsAllocated()
|
||||
: 0;
|
||||
|
||||
if (oldAllocation != fixedAllocation) {
|
||||
int currentRemaining =
|
||||
credit.getCycleCreditsRemaining() != null
|
||||
? credit.getCycleCreditsRemaining()
|
||||
: 0;
|
||||
int allocationDifference = fixedAllocation - oldAllocation;
|
||||
|
||||
credit.setCycleCreditsAllocated(fixedAllocation);
|
||||
|
||||
int newRemaining = Math.max(0, currentRemaining + allocationDifference);
|
||||
credit.setCycleCreditsRemaining(newRemaining);
|
||||
|
||||
teamCreditRepository.save(credit);
|
||||
|
||||
log.info(
|
||||
"Updated team {} credit allocation: {} -> {} (fixed PRO amount). Remaining: {} -> {}",
|
||||
teamId,
|
||||
oldAllocation,
|
||||
fixedAllocation,
|
||||
currentRemaining,
|
||||
newRemaining);
|
||||
} else {
|
||||
log.debug(
|
||||
"Team {} already has fixed allocation of {} credits, no update needed",
|
||||
teamId,
|
||||
fixedAllocation);
|
||||
}
|
||||
} else {
|
||||
log.warn("Team {} missing credit record; creating with fixed allocation", teamId);
|
||||
TeamCredit credit = new TeamCredit(team);
|
||||
|
||||
credit.setCycleCreditsAllocated(fixedAllocation);
|
||||
credit.setCycleCreditsRemaining(fixedAllocation);
|
||||
credit.setBoughtCreditsRemaining(0);
|
||||
credit.setTotalBoughtCredits(0);
|
||||
credit.setTotalApiCallsMade(0L);
|
||||
credit.setLastCycleResetAt(LocalDateTime.now());
|
||||
teamCreditRepository.save(credit);
|
||||
|
||||
log.info(
|
||||
"Created team_credits record for team {} with {} fixed credits (unlimited seats model)",
|
||||
teamId,
|
||||
fixedAllocation);
|
||||
}
|
||||
|
||||
log.info(
|
||||
"Team {} seat allocation updated: maxSeats={}, seatsUsed={}, isPersonal={}",
|
||||
teamId,
|
||||
maxSeats,
|
||||
saasTeamExtensionService.getSeatsUsed(team),
|
||||
saasTeamExtensionService.isPersonal(team));
|
||||
}
|
||||
|
||||
/**
|
||||
* Downgrade user to FREE tier (called when leaving team)
|
||||
*
|
||||
* <p>Note: Uses UserRoleService to avoid code duplication. Refetches user to avoid stale entity
|
||||
* issues (createPersonalTeam refetches and saves the user).
|
||||
*
|
||||
* @param user the user to downgrade
|
||||
*/
|
||||
private void downgradeUserToFree(User user) {
|
||||
// Refetch user to ensure we have the latest managed entity
|
||||
// (createPersonalTeam refetches and saves the user, making our reference stale)
|
||||
final long userId = user.getId();
|
||||
final User refetchedUser =
|
||||
userRepository
|
||||
.findById(userId)
|
||||
.orElseThrow(
|
||||
() -> new IllegalArgumentException("User not found: " + userId));
|
||||
|
||||
String currentRole = refetchedUser.getRolesAsString();
|
||||
|
||||
if (!Role.PRO_USER.getRoleId().equals(currentRole)) {
|
||||
log.debug(
|
||||
"User {} already on FREE tier, no downgrade needed",
|
||||
refetchedUser.getUsername());
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user has an active subscription (including trial)
|
||||
// If they do, keep their PRO access . don't downgrade
|
||||
if (refetchedUser.getSupabaseId() != null) {
|
||||
try {
|
||||
boolean hasActiveSubscription =
|
||||
billingSubscriptionRepository.existsActiveSubscriptionForUser(
|
||||
refetchedUser.getSupabaseId());
|
||||
|
||||
if (hasActiveSubscription) {
|
||||
log.info(
|
||||
"User {} has active subscription (trial or paid), maintaining PRO access after leaving team",
|
||||
refetchedUser.getUsername());
|
||||
return; // Keep PRO access
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"Error checking subscription for user {} after leaving team: {}",
|
||||
refetchedUser.getUsername(),
|
||||
e.getMessage(),
|
||||
e);
|
||||
// On error, proceed with downgrade to be safe
|
||||
}
|
||||
}
|
||||
|
||||
log.info(
|
||||
"Downgrading user {} from PRO to FREE after leaving team (no active subscription)",
|
||||
refetchedUser.getUsername());
|
||||
|
||||
// Delegate to UserRoleService for consistent downgrade logic
|
||||
userRoleService.downgradeToFree(refetchedUser);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package stirling.software.saas.service;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.saas.model.SupabaseUser;
|
||||
import stirling.software.saas.util.LogRedactionUtils;
|
||||
|
||||
/** Saas user lifecycle operations driven by Stripe/Supabase webhooks. */
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class SaasUserAccountService {
|
||||
|
||||
private final UserService userService;
|
||||
private final UserRepository userRepository;
|
||||
private final UserRoleService userRoleService;
|
||||
private final SupabaseUserService supabaseUserService;
|
||||
private final SaasUserExtensionService saasUserExtensionService;
|
||||
private final SaasTeamExtensionService saasTeamExtensionService;
|
||||
|
||||
/**
|
||||
* Resolve a local {@link User} from a Supabase UUID string. Throws if the ID format is invalid
|
||||
* or no matching local user row exists.
|
||||
*/
|
||||
public User getUserBySupabaseId(String supabaseId) {
|
||||
UUID supabaseUserId;
|
||||
try {
|
||||
supabaseUserId = UUID.fromString(supabaseId);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new IllegalArgumentException("Invalid Supabase ID format: " + supabaseId, e);
|
||||
}
|
||||
return userService
|
||||
.findBySupabaseId(supabaseUserId)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IllegalArgumentException(
|
||||
"User not found for Supabase ID: " + supabaseId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote a user from {@code ROLE_USER} (free) to {@code ROLE_PRO_USER} (paid). Called by the
|
||||
* Stripe-webhook {@code UserRoleWebhookController} after a successful subscription.
|
||||
*
|
||||
* @return true if a promotion happened, false if the user was already on PRO or higher
|
||||
*/
|
||||
@Transactional
|
||||
public boolean handleUpgrade(String supabaseId) {
|
||||
User user = getUserBySupabaseId(supabaseId);
|
||||
String currentRole = user.getRolesAsString();
|
||||
|
||||
if (Role.USER.getRoleId().equals(currentRole)) {
|
||||
userRoleService.upgradeToPro(user);
|
||||
return true;
|
||||
}
|
||||
log.info(
|
||||
"User {} already has role {}, no upgrade needed",
|
||||
LogRedactionUtils.redactEmail(user.getUsername()),
|
||||
currentRole);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Demote a user from PRO back to FREE. Multi-member teams keep their PRO access via team
|
||||
* membership even if the personal subscription cancels, so users on non-personal teams are left
|
||||
* at PRO.
|
||||
*
|
||||
* @return true if a downgrade happened, false if user was already FREE or kept PRO via team
|
||||
*/
|
||||
@Transactional
|
||||
public boolean handleDowngrade(String supabaseId) {
|
||||
User user = getUserBySupabaseId(supabaseId);
|
||||
String currentRole = user.getRolesAsString();
|
||||
|
||||
if (Role.PRO_USER.getRoleId().equals(currentRole)) {
|
||||
Team userTeam = user.getTeam();
|
||||
if (userTeam != null && !saasTeamExtensionService.isPersonal(userTeam)) {
|
||||
log.info(
|
||||
"User {} is on team {} - keeping PRO access through team membership",
|
||||
LogRedactionUtils.redactEmail(user.getUsername()),
|
||||
userTeam.getName());
|
||||
return false;
|
||||
}
|
||||
userRoleService.downgradeToFree(user);
|
||||
return true;
|
||||
}
|
||||
log.info(
|
||||
"User {} has role {}, no downgrade needed",
|
||||
LogRedactionUtils.redactEmail(user.getUsername()),
|
||||
currentRole);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn on metered billing so the user can pay for overage. Compatible with PRO (a PRO user
|
||||
* still uses credits via the metered fallback for API usage above the cycle pool).
|
||||
*/
|
||||
@Transactional
|
||||
public boolean enableMeteredBilling(String supabaseId) {
|
||||
User user = getUserBySupabaseId(supabaseId);
|
||||
if (saasUserExtensionService.isMeteredBillingEnabled(user)) {
|
||||
log.info(
|
||||
"User {} already has metered billing enabled",
|
||||
LogRedactionUtils.redactEmail(user.getUsername()));
|
||||
return false;
|
||||
}
|
||||
saasUserExtensionService.setMeteredBillingEnabled(user, true);
|
||||
log.info(
|
||||
"Enabled metered billing for user {}",
|
||||
LogRedactionUtils.redactEmail(user.getUsername()));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Turn off metered billing. Called when the metered subscription is canceled in Stripe. */
|
||||
@Transactional
|
||||
public boolean disableMeteredBilling(String supabaseId) {
|
||||
User user = getUserBySupabaseId(supabaseId);
|
||||
if (!saasUserExtensionService.isMeteredBillingEnabled(user)) {
|
||||
log.info(
|
||||
"User {} does not have metered billing enabled",
|
||||
LogRedactionUtils.redactEmail(user.getUsername()));
|
||||
return false;
|
||||
}
|
||||
saasUserExtensionService.setMeteredBillingEnabled(user, false);
|
||||
log.info(
|
||||
"Disabled metered billing for user {}",
|
||||
LogRedactionUtils.redactEmail(user.getUsername()));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Anonymous -> authenticated upgrade triggered from the frontend after Supabase accepts it. */
|
||||
@Transactional
|
||||
public User synchronizeUserUpgrade(SupabaseUser supabaseUser, String email, String authMethod) {
|
||||
log.debug(
|
||||
"Synchronizing user upgrade for SupabaseId: {}, Email: {}",
|
||||
LogRedactionUtils.redactSupabaseId(supabaseUser.getId()),
|
||||
LogRedactionUtils.redactEmail(email));
|
||||
|
||||
User user =
|
||||
userService
|
||||
.findBySupabaseId(supabaseUser.getId())
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IllegalStateException(
|
||||
"No local user linked to Supabase ID "
|
||||
+ supabaseUser.getId()));
|
||||
|
||||
// Flip is_anonymous on the auth.users mirror.
|
||||
if (supabaseUser.isAnonymous()) {
|
||||
supabaseUser.setAnonymous(false);
|
||||
supabaseUserService.save(supabaseUser);
|
||||
}
|
||||
|
||||
// Promote the local row's auth type and email if currently anonymous.
|
||||
if (AuthenticationType.ANONYMOUS.name().equalsIgnoreCase(user.getAuthenticationType())) {
|
||||
AuthenticationType newType = mapAuthMethodToType(authMethod);
|
||||
user.setAuthenticationType(newType);
|
||||
if (email != null && !email.isBlank()) {
|
||||
user.setEmail(email);
|
||||
user.setUsername(email);
|
||||
}
|
||||
user = userService.saveUser(user);
|
||||
log.info(
|
||||
"Upgraded anonymous user {} to {} ({})",
|
||||
user.getId(),
|
||||
newType,
|
||||
LogRedactionUtils.redactEmail(email));
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
private static AuthenticationType mapAuthMethodToType(String authMethod) {
|
||||
if (authMethod == null) {
|
||||
return AuthenticationType.WEB;
|
||||
}
|
||||
return switch (authMethod) {
|
||||
case "google", "github", "apple", "azure", "linkedin_oidc", "oauth" ->
|
||||
AuthenticationType.OAUTH2;
|
||||
default -> AuthenticationType.WEB;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package stirling.software.saas.service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.model.SaasUserExtensions;
|
||||
import stirling.software.saas.repository.SaasUserExtensionsRepository;
|
||||
|
||||
/**
|
||||
* Read/write access to {@link SaasUserExtensions}. Reads return safe defaults when no row exists
|
||||
* for the user; writes create the row lazily.
|
||||
*/
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class SaasUserExtensionService {
|
||||
|
||||
private final SaasUserExtensionsRepository repository;
|
||||
|
||||
public SaasUserExtensions getOrCreate(User user) {
|
||||
return repository
|
||||
.findByUserId(user.getId())
|
||||
.orElseGet(() -> repository.save(new SaasUserExtensions(user)));
|
||||
}
|
||||
|
||||
public boolean isMeteredBillingEnabled(User user) {
|
||||
return repository
|
||||
.findByUserId(user.getId())
|
||||
.map(SaasUserExtensions::isMeteredBillingEnabled)
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void setMeteredBillingEnabled(User user, boolean enabled) {
|
||||
SaasUserExtensions ext = getOrCreate(user);
|
||||
ext.setHasMeteredBillingEnabled(enabled);
|
||||
repository.save(ext);
|
||||
}
|
||||
|
||||
public LocalDateTime getApiKeyFirstUsedAt(User user) {
|
||||
return repository
|
||||
.findByUserId(user.getId())
|
||||
.map(SaasUserExtensions::getApiKeyFirstUsedAt)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/** Idempotent first-use marker. Records the first time this user's API key fired a request. */
|
||||
@Transactional
|
||||
public void trackApiKeyFirstUse(User user) {
|
||||
SaasUserExtensions ext = getOrCreate(user);
|
||||
if (ext.getApiKeyFirstUsedAt() == null) {
|
||||
ext.setApiKeyFirstUsedAt(LocalDateTime.now());
|
||||
repository.save(ext);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package stirling.software.saas.service;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.saas.model.SupabaseUser;
|
||||
import stirling.software.saas.model.exception.UserNotFoundException;
|
||||
import stirling.software.saas.repository.SupabaseUserRepository;
|
||||
|
||||
/**
|
||||
* CRUD over Supabase's {@code auth.users} mirror. Exists only under the saas profile because the
|
||||
* {@code auth} schema is Supabase-specific.
|
||||
*/
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@RequiredArgsConstructor
|
||||
public class SupabaseUserService {
|
||||
|
||||
private final SupabaseUserRepository supabaseUserRepository;
|
||||
|
||||
public SupabaseUser getUser(UUID supabaseId) {
|
||||
return supabaseUserRepository
|
||||
.findById(supabaseId)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new UserNotFoundException(
|
||||
"Supabase user " + supabaseId + " not found"));
|
||||
}
|
||||
|
||||
public SupabaseUser createSupabaseUser(UUID supabaseId, String email, boolean isAnonymous) {
|
||||
SupabaseUser supabaseUser = new SupabaseUser();
|
||||
supabaseUser.setId(supabaseId);
|
||||
supabaseUser.setEmail(email);
|
||||
supabaseUser.setAnonymous(isAnonymous);
|
||||
return supabaseUserRepository.save(supabaseUser);
|
||||
}
|
||||
|
||||
public SupabaseUser save(SupabaseUser supabaseUser) {
|
||||
return supabaseUserRepository.save(supabaseUser);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package stirling.software.saas.service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.enumeration.TeamRole;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.billing.service.StripeUsageReportingService;
|
||||
import stirling.software.saas.config.CreditsProperties;
|
||||
import stirling.software.saas.model.CreditConsumptionResult;
|
||||
import stirling.software.saas.model.TeamCredit;
|
||||
import stirling.software.saas.model.TeamMembership;
|
||||
import stirling.software.saas.repository.TeamCreditRepository;
|
||||
import stirling.software.saas.repository.TeamMembershipRepository;
|
||||
|
||||
/**
|
||||
* Service for managing team credit pools. Handles credit initialization, consumption, and cycle
|
||||
* resets for teams.
|
||||
*/
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class TeamCreditService {
|
||||
|
||||
private final TeamCreditRepository teamCreditRepository;
|
||||
private final TeamMembershipRepository membershipRepository;
|
||||
private final CreditsProperties creditsProperties;
|
||||
private final StripeUsageReportingService stripeUsageReportingService;
|
||||
private final SaasUserExtensionService saasUserExtensionService;
|
||||
|
||||
/** Initialise a fixed PRO credit allocation for a new team. */
|
||||
@Transactional
|
||||
public TeamCredit initializeTeamCredits(Team team, User primaryUser) {
|
||||
Optional<TeamCredit> existing = teamCreditRepository.findByTeamId(team.getId());
|
||||
if (existing.isPresent()) {
|
||||
log.debug("Team credits already exist for team {}", team.getId());
|
||||
return existing.get();
|
||||
}
|
||||
|
||||
TeamCredit credits = new TeamCredit(team);
|
||||
|
||||
// Fixed PRO allocation; seat-independent.
|
||||
int proAllocation =
|
||||
creditsProperties.getCycle().getAllocations().getOrDefault("ROLE_PRO_USER", 500);
|
||||
int totalCycleAllocation = proAllocation;
|
||||
|
||||
credits.setCycleCreditsAllocated(totalCycleAllocation);
|
||||
credits.setCycleCreditsRemaining(totalCycleAllocation);
|
||||
credits.setLastCycleResetAt(LocalDateTime.now());
|
||||
|
||||
TeamCredit saved = teamCreditRepository.save(credits);
|
||||
log.info(
|
||||
"Initialized team credits for team {} with {} cycle credits (fixed PRO amount)",
|
||||
team.getId(),
|
||||
totalCycleAllocation);
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if team has credits available
|
||||
*
|
||||
* @param teamId the team ID
|
||||
* @return true if team has credits available
|
||||
*/
|
||||
public boolean hasCreditsAvailable(Long teamId) {
|
||||
return teamCreditRepository
|
||||
.findByTeamId(teamId)
|
||||
.map(TeamCredit::hasCreditsAvailable)
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically consume credits from team pool
|
||||
*
|
||||
* @param teamId the team ID
|
||||
* @param amount number of credits to consume
|
||||
* @return true if credits were consumed, false if insufficient credits or version conflict
|
||||
*/
|
||||
@Transactional
|
||||
public boolean consumeCredit(Long teamId, int amount) {
|
||||
int rowsUpdated = teamCreditRepository.consumeCredit(teamId, amount);
|
||||
if (rowsUpdated == 0) {
|
||||
log.warn(
|
||||
"Failed to consume {} credits for team {} (insufficient credits or version conflict)",
|
||||
amount,
|
||||
teamId);
|
||||
return false;
|
||||
}
|
||||
log.debug("Consumed {} credits for team {}", amount, teamId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get team credit summary for a user's team.
|
||||
*
|
||||
* @param user the user
|
||||
* @return Optional of TeamCredit for the user's team
|
||||
*/
|
||||
public Optional<TeamCredit> getCreditSummaryForUser(User user) {
|
||||
if (user.getTeam() == null) {
|
||||
log.warn("User {} has no team assigned", user.getId());
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Long teamId = user.getTeam().getId();
|
||||
log.debug("Using user's team {} for credit summary", teamId);
|
||||
return teamCreditRepository.findByTeamId(teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get team credits by team ID
|
||||
*
|
||||
* @param teamId the team ID
|
||||
* @return Optional of TeamCredit
|
||||
*/
|
||||
public Optional<TeamCredit> getTeamCredits(Long teamId) {
|
||||
return teamCreditRepository.findByTeamId(teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add bought credits to team pool
|
||||
*
|
||||
* @param teamId the team ID
|
||||
* @param credits number of credits to add
|
||||
*/
|
||||
@Transactional
|
||||
public void addBoughtCredits(Long teamId, int credits) {
|
||||
TeamCredit teamCredit =
|
||||
teamCreditRepository
|
||||
.findByTeamId(teamId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Team credits not found"));
|
||||
|
||||
teamCredit.addBoughtCredits(credits);
|
||||
teamCreditRepository.save(teamCredit);
|
||||
log.info("Added {} bought credits to team {}", credits, teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset cycle credits for team
|
||||
*
|
||||
* @param teamId the team ID
|
||||
* @param cycleAllocation new cycle allocation
|
||||
* @param resetTime reset timestamp
|
||||
*/
|
||||
@Transactional
|
||||
public void resetCycleCredits(Long teamId, int cycleAllocation, LocalDateTime resetTime) {
|
||||
TeamCredit teamCredit =
|
||||
teamCreditRepository
|
||||
.findByTeamId(teamId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Team credits not found"));
|
||||
|
||||
teamCredit.resetCycleCredits(cycleAllocation, resetTime);
|
||||
teamCreditRepository.save(teamCredit);
|
||||
log.info("Reset cycle credits for team {} to {}", teamId, cycleAllocation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume from the team credit pool; falls through to the team leader's metered Stripe billing
|
||||
* when the pool is exhausted.
|
||||
*/
|
||||
@Transactional
|
||||
public CreditConsumptionResult consumeCreditWithWaterfall(Long teamId, int amount) {
|
||||
log.debug("[TEAM-CREDIT] Starting consumption for team {} - amount: {}", teamId, amount);
|
||||
|
||||
// Step 1: Try consuming from team credit pool
|
||||
int rowsUpdated = teamCreditRepository.consumeCredit(teamId, amount);
|
||||
if (rowsUpdated == 1) {
|
||||
log.info("[TEAM-CREDIT] Consumed {} credits from team {} pool", amount, teamId);
|
||||
return CreditConsumptionResult.success("TEAM_CREDITS");
|
||||
}
|
||||
|
||||
log.warn("[TEAM-CREDIT] Team {} credit pool exhausted; checking leader overage", teamId);
|
||||
|
||||
// Step 2: Get team leader
|
||||
Optional<User> leaderOpt = getTeamLeader(teamId);
|
||||
if (leaderOpt.isEmpty()) {
|
||||
log.error("[TEAM-CREDIT] Team {} has no leader; cannot use overage billing", teamId);
|
||||
return CreditConsumptionResult.failure("NO_TEAM_LEADER");
|
||||
}
|
||||
|
||||
User teamLeader = leaderOpt.get();
|
||||
|
||||
// Step 3: Check if team leader has metered billing enabled
|
||||
if (!saasUserExtensionService.isMeteredBillingEnabled(teamLeader)) {
|
||||
log.warn(
|
||||
"[TEAM-CREDIT] Team {} leader {} does not have metered billing enabled",
|
||||
teamId,
|
||||
teamLeader.getUsername());
|
||||
return CreditConsumptionResult.failure(
|
||||
"TEAM_CREDITS_EXHAUSTED_NO_OVERAGE",
|
||||
"Team credits exhausted. Team leader must enable overage billing for"
|
||||
+ " uninterrupted service.");
|
||||
}
|
||||
|
||||
// Step 4: Report overage to Stripe via team leader's metered billing
|
||||
String leaderSupabaseId =
|
||||
teamLeader.getSupabaseId() != null ? teamLeader.getSupabaseId().toString() : null;
|
||||
|
||||
if (leaderSupabaseId == null) {
|
||||
log.error("[TEAM-CREDIT] Team leader {} has no Supabase ID", teamLeader.getUsername());
|
||||
return CreditConsumptionResult.failure("LEADER_NO_SUPABASE_ID");
|
||||
}
|
||||
|
||||
try {
|
||||
String operationId = org.slf4j.MDC.get("requestId");
|
||||
if (operationId == null || operationId.isBlank()) {
|
||||
operationId = java.util.UUID.randomUUID().toString();
|
||||
}
|
||||
String idempotencyKey =
|
||||
stripeUsageReportingService.generateIdempotencyKey(
|
||||
leaderSupabaseId, amount, operationId);
|
||||
|
||||
log.info(
|
||||
"[TEAM-CREDIT] Reporting {} overage credits to Stripe for team {} leader {}",
|
||||
amount,
|
||||
teamId,
|
||||
teamLeader.getUsername());
|
||||
|
||||
boolean reported =
|
||||
stripeUsageReportingService.reportUsageToStripe(
|
||||
leaderSupabaseId, amount, idempotencyKey);
|
||||
|
||||
if (reported) {
|
||||
log.info(
|
||||
"[TEAM-CREDIT] Successfully reported {} overage credits for team {} via"
|
||||
+ " leader {}",
|
||||
amount,
|
||||
teamId,
|
||||
teamLeader.getUsername());
|
||||
return CreditConsumptionResult.success("TEAM_LEADER_METERED");
|
||||
} else {
|
||||
log.error("[TEAM-CREDIT] Failed to report overage to Stripe for team {}", teamId);
|
||||
return CreditConsumptionResult.failure(
|
||||
"STRIPE_REPORTING_FAILED",
|
||||
"Unable to report usage to Stripe. Please try again.");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"[TEAM-CREDIT] Exception reporting overage for team {}: {}",
|
||||
teamId,
|
||||
e.getMessage(),
|
||||
e);
|
||||
return CreditConsumptionResult.failure(
|
||||
"STRIPE_REPORTING_ERROR", "Error reporting usage: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the team's LEADER (first one if multiple exist) for overage-billing routing. */
|
||||
private Optional<User> getTeamLeader(Long teamId) {
|
||||
List<TeamMembership> leaders =
|
||||
membershipRepository.findByTeamIdAndRole(teamId, TeamRole.LEADER);
|
||||
|
||||
if (leaders.isEmpty()) {
|
||||
log.warn("Team {} has no leaders", teamId);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// Return first leader (typically only one leader per team)
|
||||
TeamMembership leader = leaders.get(0);
|
||||
User leaderUser = leader.getUser();
|
||||
log.debug(
|
||||
"Found team {} leader: {} (user ID: {})",
|
||||
teamId,
|
||||
leaderUser.getUsername(),
|
||||
leaderUser.getId());
|
||||
|
||||
return Optional.of(leaderUser);
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package stirling.software.saas.service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.enumeration.InvitationStatus;
|
||||
import stirling.software.saas.model.TeamInvitation;
|
||||
import stirling.software.saas.repository.TeamInvitationRepository;
|
||||
|
||||
/**
|
||||
* Scheduled service for cleaning up expired team invitations. Runs daily to mark invitations that
|
||||
* have passed their expiration date as EXPIRED.
|
||||
*/
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class TeamInvitationCleanupService {
|
||||
|
||||
private final TeamInvitationRepository invitationRepository;
|
||||
|
||||
/** Mark expired invitations as EXPIRED. Runs every day at 2:00 AM. */
|
||||
@Scheduled(cron = "0 0 2 * * *")
|
||||
@Transactional
|
||||
public void markExpiredInvitations() {
|
||||
try {
|
||||
log.info("Starting invitation expiration cleanup job");
|
||||
|
||||
int expiredCount = invitationRepository.markExpiredInvitations(LocalDateTime.now());
|
||||
|
||||
if (expiredCount > 0) {
|
||||
log.info("Marked {} invitations as expired", expiredCount);
|
||||
} else {
|
||||
log.debug("No invitations to expire");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error during invitation cleanup", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete old expired invitations (older than 30 days). Runs monthly on the 1st at 3:00 AM. */
|
||||
@Scheduled(cron = "0 0 3 1 * *")
|
||||
@Transactional
|
||||
public void deleteOldExpiredInvitations() {
|
||||
try {
|
||||
log.info("Starting cleanup of old expired invitations");
|
||||
|
||||
LocalDateTime cutoffDate = LocalDateTime.now().minusDays(30);
|
||||
|
||||
List<TeamInvitation> oldInvitations =
|
||||
invitationRepository.findByStatusAndExpiresAtBefore(
|
||||
InvitationStatus.EXPIRED, cutoffDate);
|
||||
|
||||
if (!oldInvitations.isEmpty()) {
|
||||
invitationRepository.deleteAll(oldInvitations);
|
||||
log.info("Deleted {} old expired invitations", oldInvitations.size());
|
||||
} else {
|
||||
log.debug("No old expired invitations to delete");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error during old invitation cleanup", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package stirling.software.saas.service;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.security.database.repository.AuthorityRepository;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.Authority;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.config.CreditsProperties;
|
||||
import stirling.software.saas.util.LogRedactionUtils;
|
||||
|
||||
/** Changes user roles and refreshes their credit allocation. */
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class UserRoleService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final AuthorityRepository authorityRepository;
|
||||
private final CreditService creditService;
|
||||
private final CreditsProperties creditsProperties;
|
||||
|
||||
/**
|
||||
* Change a user's role
|
||||
*
|
||||
* @param user the user to change
|
||||
* @param newRole the new role ID (e.g., "ROLE_USER", "ROLE_PRO_USER")
|
||||
*/
|
||||
@Transactional
|
||||
public void changeRole(User user, String newRole) {
|
||||
log.debug(
|
||||
"Changing role for user {} from {} to {}",
|
||||
user.getUsername(),
|
||||
user.getRolesAsString(),
|
||||
newRole);
|
||||
|
||||
Authority userAuthority = authorityRepository.findByUserId(user.getId());
|
||||
userAuthority.setAuthority(newRole);
|
||||
authorityRepository.save(userAuthority);
|
||||
|
||||
// Update denormalized roleName column in User table
|
||||
user.setRoleName(newRole);
|
||||
userRepository.save(user);
|
||||
|
||||
log.info(
|
||||
"Changed role for user {} to {}",
|
||||
LogRedactionUtils.redactEmail(user.getUsername()),
|
||||
newRole);
|
||||
}
|
||||
|
||||
/**
|
||||
* Downgrade a user to FREE tier (ROLE_USER)
|
||||
*
|
||||
* <p>Changes role from PRO_USER to USER and resets cycle credit allocation to FREE tier.
|
||||
*
|
||||
* @param user the user to downgrade
|
||||
*/
|
||||
@Transactional
|
||||
public void downgradeToFree(User user) {
|
||||
log.info(
|
||||
"Downgrading user {} to FREE tier",
|
||||
LogRedactionUtils.redactEmail(user.getUsername()));
|
||||
|
||||
changeRole(user, Role.USER.getRoleId());
|
||||
|
||||
// Reset credits to FREE tier allocation
|
||||
int freeAllocation =
|
||||
creditsProperties
|
||||
.getCycle()
|
||||
.getAllocations()
|
||||
.getOrDefault(Role.USER.getRoleId(), 25);
|
||||
creditService.resetCycleAllocationForRoleChange(user.getId(), freeAllocation);
|
||||
|
||||
log.info(
|
||||
"Successfully downgraded user {} to FREE with {} cycle credits",
|
||||
LogRedactionUtils.redactEmail(user.getUsername()),
|
||||
freeAllocation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade a user to PRO tier (ROLE_PRO_USER)
|
||||
*
|
||||
* <p>Changes role from USER to PRO_USER and resets cycle credit allocation to PRO tier.
|
||||
*
|
||||
* @param user the user to upgrade
|
||||
*/
|
||||
@Transactional
|
||||
public void upgradeToPro(User user) {
|
||||
log.info(
|
||||
"Upgrading user {} to PRO tier", LogRedactionUtils.redactEmail(user.getUsername()));
|
||||
|
||||
changeRole(user, Role.PRO_USER.getRoleId());
|
||||
|
||||
// Reset credits to PRO tier allocation
|
||||
int proAllocation =
|
||||
creditsProperties
|
||||
.getCycle()
|
||||
.getAllocations()
|
||||
.getOrDefault(Role.PRO_USER.getRoleId(), 100);
|
||||
creditService.resetCycleAllocationForRoleChange(user.getId(), proAllocation);
|
||||
|
||||
log.info(
|
||||
"Successfully upgraded user {} to PRO with {} cycle credits",
|
||||
LogRedactionUtils.redactEmail(user.getUsername()),
|
||||
proAllocation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get credit allocation for a specific role
|
||||
*
|
||||
* @param roleId the role ID (e.g., "ROLE_USER", "ROLE_PRO_USER")
|
||||
* @return the cycle credit allocation for that role
|
||||
*/
|
||||
public int getCreditAllocationForRole(String roleId) {
|
||||
return creditsProperties
|
||||
.getCycle()
|
||||
.getAllocations()
|
||||
.getOrDefault(roleId, Role.USER.getRoleId().equals(roleId) ? 25 : 100);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package stirling.software.saas.util;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.security.EnhancedJwtAuthenticationToken;
|
||||
|
||||
/**
|
||||
* Utility class for extracting information from authentication objects. Provides consistent access
|
||||
* to user identifiers across different authentication types.
|
||||
*/
|
||||
public class AuthenticationUtils {
|
||||
|
||||
private AuthenticationUtils() {
|
||||
// Utility class - no instances
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the Supabase ID from an authentication object for credit operations and user lookups.
|
||||
*
|
||||
* @param authentication The authentication object
|
||||
* @return The Supabase ID for JWT users, or API key for API key users
|
||||
*/
|
||||
public static String extractSupabaseId(Authentication authentication) {
|
||||
if (authentication instanceof EnhancedJwtAuthenticationToken enhancedJwt) {
|
||||
return enhancedJwt.getSupabaseId();
|
||||
} else if (authentication instanceof ApiKeyAuthenticationToken) {
|
||||
// For API key authentication, the name is the API key
|
||||
return authentication.getName();
|
||||
}
|
||||
// Fallback for other authentication types
|
||||
return authentication.getName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the email from an authentication object for audit and display purposes.
|
||||
*
|
||||
* @param authentication The authentication object
|
||||
* @return The user's email or fallback identifier
|
||||
*/
|
||||
public static String extractEmail(Authentication authentication) {
|
||||
if (authentication instanceof EnhancedJwtAuthenticationToken enhancedJwt) {
|
||||
return enhancedJwt.getEmail();
|
||||
}
|
||||
// For other types, getName() should return the appropriate identifier
|
||||
return authentication.getName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current User from an authentication object, looking it up in the database if needed.
|
||||
*
|
||||
* @param authentication The authentication object
|
||||
* @param userRepository Repository to look up users
|
||||
* @return The authenticated User
|
||||
* @throws SecurityException if user cannot be resolved
|
||||
*/
|
||||
public static User getCurrentUser(
|
||||
Authentication authentication, UserRepository userRepository) {
|
||||
if (authentication == null) {
|
||||
throw new SecurityException("Not authenticated");
|
||||
}
|
||||
|
||||
Object principal = authentication.getPrincipal();
|
||||
|
||||
// Direct User object (from custom filter)
|
||||
if (principal instanceof User) {
|
||||
return (User) principal;
|
||||
}
|
||||
|
||||
// Handle EnhancedJwtAuthenticationToken (includes anonymous users)
|
||||
// Use Supabase ID lookup which works for all JWT users
|
||||
if (authentication instanceof EnhancedJwtAuthenticationToken) {
|
||||
String supabaseId = extractSupabaseId(authentication);
|
||||
try {
|
||||
java.util.UUID supabaseUuid = java.util.UUID.fromString(supabaseId);
|
||||
return userRepository
|
||||
.findBySupabaseId(supabaseUuid)
|
||||
.orElseThrow(() -> new SecurityException("User not found"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new SecurityException("Invalid Supabase ID format: " + supabaseId);
|
||||
}
|
||||
}
|
||||
|
||||
// String username/email
|
||||
if (principal instanceof String) {
|
||||
return userRepository
|
||||
.findByUsername((String) principal)
|
||||
.orElseThrow(() -> new SecurityException("User not found"));
|
||||
}
|
||||
|
||||
// Jwt principal from oauth2ResourceServer
|
||||
if (principal instanceof Jwt jwt) {
|
||||
String email = jwt.getClaimAsString("email");
|
||||
if (email != null) {
|
||||
return userRepository
|
||||
.findByUsername(email)
|
||||
.orElseThrow(() -> new SecurityException("User not found"));
|
||||
}
|
||||
}
|
||||
|
||||
throw new SecurityException(
|
||||
"Invalid authentication principal: " + principal.getClass().getName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package stirling.software.saas.util;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.model.TeamCredit;
|
||||
import stirling.software.saas.model.UserCredit;
|
||||
import stirling.software.saas.service.CreditService;
|
||||
import stirling.software.saas.service.SaasTeamExtensionService;
|
||||
import stirling.software.saas.service.TeamCreditService;
|
||||
|
||||
/**
|
||||
* Resolves the user's remaining credit balance. Uses the team pool for non-personal team members,
|
||||
* otherwise the user's individual credits (looked up by Supabase ID or API key).
|
||||
*/
|
||||
@Component
|
||||
@Profile("saas")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CreditHeaderUtils {
|
||||
|
||||
private final SaasTeamExtensionService saasTeamExtensionService;
|
||||
|
||||
/**
|
||||
* Get the remaining credits for a user, checking team credits first (non-personal teams only).
|
||||
*
|
||||
* @param user The user whose credits to check
|
||||
* @param creditService The credit service to fetch user credits
|
||||
* @param teamCreditService The team credit service to fetch team credits
|
||||
* @return The remaining credit balance, or -1 if credits cannot be determined
|
||||
*/
|
||||
public int getRemainingCredits(
|
||||
User user, CreditService creditService, TeamCreditService teamCreditService) {
|
||||
try {
|
||||
// Limited-API users always read personal credits.
|
||||
boolean isLimitedApiUser =
|
||||
user.getAuthorities().stream()
|
||||
.anyMatch(
|
||||
authority ->
|
||||
"ROLE_LIMITED_API_USER".equals(authority.getAuthority())
|
||||
|| "ROLE_EXTRA_LIMITED_API_USER"
|
||||
.equals(authority.getAuthority()));
|
||||
|
||||
Long targetTeamId = null;
|
||||
if (!isLimitedApiUser
|
||||
&& user.getTeam() != null
|
||||
&& !saasTeamExtensionService.isPersonal(user.getTeam())) {
|
||||
targetTeamId = user.getTeam().getId();
|
||||
}
|
||||
|
||||
if (targetTeamId != null) {
|
||||
return teamCreditService
|
||||
.getTeamCredits(targetTeamId)
|
||||
.map(TeamCredit::getTotalAvailableCredits)
|
||||
.orElse(-1);
|
||||
} else {
|
||||
log.debug(
|
||||
"[CREDIT-HEADER] Getting personal credits - SupabaseId: {}, ApiKey: {}, Username: {}",
|
||||
user.getSupabaseId(),
|
||||
user.getApiKey() != null ? "present" : "null",
|
||||
user.getUsername());
|
||||
|
||||
Optional<UserCredit> credits;
|
||||
if (user.getSupabaseId() != null) {
|
||||
credits =
|
||||
creditService.getUserCreditsBySupabaseId(
|
||||
user.getSupabaseId().toString());
|
||||
log.debug(
|
||||
"[CREDIT-HEADER] Looked up by SupabaseId - Found: {}",
|
||||
credits.isPresent());
|
||||
} else if (user.getApiKey() != null) {
|
||||
credits = creditService.getUserCreditsByApiKey(user.getApiKey());
|
||||
log.debug(
|
||||
"[CREDIT-HEADER] Looked up by ApiKey - Found: {}", credits.isPresent());
|
||||
} else {
|
||||
log.warn(
|
||||
"[CREDIT-HEADER] No SupabaseId or ApiKey for user: {}",
|
||||
user.getUsername());
|
||||
return -1;
|
||||
}
|
||||
|
||||
int remaining = credits.map(UserCredit::getTotalAvailableCredits).orElse(-1);
|
||||
log.debug("[CREDIT-HEADER] Returning credits: {}", remaining);
|
||||
return remaining;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[CREDIT-HEADER] Could not get remaining credits: {}", e.getMessage(), e);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package stirling.software.saas.util;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/** PII redaction helpers for log lines. */
|
||||
public final class LogRedactionUtils {
|
||||
|
||||
private LogRedactionUtils() {}
|
||||
|
||||
/** Mask an email as {@code j***@stirling.com}; non-email input is returned unchanged. */
|
||||
public static String redactEmail(String email) {
|
||||
if (email == null || email.isBlank()) {
|
||||
return email;
|
||||
}
|
||||
int at = email.indexOf('@');
|
||||
if (at <= 0 || at == email.length() - 1) {
|
||||
return email;
|
||||
}
|
||||
return email.charAt(0) + "***" + email.substring(at);
|
||||
}
|
||||
|
||||
/** Mask a Supabase UUID as {@code 12345678-***-abcd}; short input is returned unchanged. */
|
||||
public static String redactSupabaseId(String supabaseId) {
|
||||
if (supabaseId == null || supabaseId.length() < 12) {
|
||||
return supabaseId;
|
||||
}
|
||||
return supabaseId.substring(0, 8) + "-***-" + supabaseId.substring(supabaseId.length() - 4);
|
||||
}
|
||||
|
||||
/** UUID overload. */
|
||||
public static String redactSupabaseId(UUID supabaseId) {
|
||||
return supabaseId == null ? null : redactSupabaseId(supabaseId.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# SaaS dev profile. Points at the dev Supabase project.
|
||||
# Boot: java -jar stirling-pdf.jar --spring.profiles.include=dev
|
||||
spring.config.import=optional:classpath:application-dev-local.properties
|
||||
|
||||
app.supabase.project-ref=qacaivhsjtftfwtgjvva
|
||||
|
||||
stirling.supabase.url=https://qacaivhsjtftfwtgjvva.supabase.co
|
||||
stirling.supabase.publishable-key=sb_publishable_nIM8y-9ARPE7EzQwAQHKMg_40fCN6kY # gitleaks:allow
|
||||
|
||||
spring.datasource.url=${SAAS_DEV_DB_URL:jdbc:postgresql://db.qacaivhsjtftfwtgjvva.supabase.co:5432/postgres?ApplicationName=stirling-consolidation-${user.name}}
|
||||
spring.datasource.username=${SAAS_DEV_DB_USERNAME:postgres}
|
||||
# Password not committed; export SAAS_DEV_DB_PASSWORD or pass --spring.datasource.password=...
|
||||
spring.datasource.password=${SAAS_DEV_DB_PASSWORD:}
|
||||
|
||||
# Conservative dev pool sizing.
|
||||
spring.datasource.hikari.maximum-pool-size=2
|
||||
spring.datasource.hikari.minimum-idle=1
|
||||
spring.datasource.hikari.idle-timeout=60000
|
||||
spring.datasource.hikari.max-lifetime=1800000
|
||||
spring.datasource.hikari.keepalive-time=300000
|
||||
spring.datasource.hikari.data-source-properties.ApplicationName=stirling-consolidation-${user.name}
|
||||
|
||||
logging.level.stirling.software.saas=DEBUG
|
||||
logging.level.org.springframework.security.oauth2.jwt=WARN
|
||||
logging.level.org.springframework.security.oauth2.server.resource=WARN
|
||||
@@ -0,0 +1,41 @@
|
||||
# Stirling-PDF SaaS profile. Pure multi-tenant cloud.
|
||||
# Activated when the :saas module is on the classpath.
|
||||
|
||||
# ---------- Datasource ----------
|
||||
system.datasource.enableCustomDatabase=true
|
||||
system.datasource.customDatabaseUrl=${SAAS_DB_URL:}
|
||||
system.datasource.username=${SAAS_DB_USERNAME:postgres}
|
||||
system.datasource.password=${SAAS_DB_PASSWORD:}
|
||||
system.datasource.type=postgresql
|
||||
|
||||
# Override application.properties' default H2 URL so missing creds fail clearly.
|
||||
spring.datasource.url=${SAAS_DB_URL:}
|
||||
spring.datasource.username=${SAAS_DB_USERNAME:postgres}
|
||||
spring.datasource.password=${SAAS_DB_PASSWORD:}
|
||||
|
||||
# ---------- DB schema / migrations ----------
|
||||
spring.jpa.properties.hibernate.default_schema=stirling_pdf
|
||||
spring.jpa.properties.hibernate.hbm2ddl.create_namespaces=true
|
||||
|
||||
spring.jpa.hibernate.ddl-auto=update
|
||||
spring.flyway.enabled=true
|
||||
spring.flyway.baseline-on-migrate=true
|
||||
spring.flyway.locations=classpath:db/migration,classpath:db/migration/saas
|
||||
spring.flyway.schemas=stirling_pdf
|
||||
spring.flyway.default-schema=stirling_pdf
|
||||
|
||||
# ---------- Supabase JWT auth ----------
|
||||
# Required: set SAAS_DB_PROJECT_REF via env.
|
||||
app.supabase.project-ref=${SAAS_DB_PROJECT_REF:}
|
||||
app.supabase.issuer=https://${app.supabase.project-ref}.supabase.co/auth/v1
|
||||
app.supabase.expected-aud=${app.jwt.expected-aud:authenticated}
|
||||
app.supabase.clock-skew-seconds=${app.jwt.clock-skew-seconds:120}
|
||||
|
||||
# Edge Function admin endpoint (used by billing/license rollup).
|
||||
app.supabase.edge-function-url=https://${app.supabase.project-ref}.supabase.co/functions/v1
|
||||
app.supabase.edge-function-secret=${SUPABASE_EDGE_FUNCTION_SECRET:}
|
||||
|
||||
supabase.url=https://${app.supabase.project-ref}.supabase.co
|
||||
|
||||
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://${app.supabase.project-ref}.supabase.co/auth/v1/.well-known/jwks.json
|
||||
spring.security.oauth2.resourceserver.jwt.audiences=${app.supabase.expected-aud}
|
||||
@@ -0,0 +1,7 @@
|
||||
-- SaaS-only column additions on top of the OSS users schema. Idempotent.
|
||||
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS supabase_id UUID;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_users_email ON users (email);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_users_supabase_id ON users (supabase_id);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Stripe billing subscription mirror, populated by Supabase webhooks.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS billing_subscriptions (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
user_id UUID NOT NULL,
|
||||
team_id BIGINT,
|
||||
status VARCHAR(64) NOT NULL,
|
||||
price_id VARCHAR(255),
|
||||
current_period_end TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_billing_subscriptions_user_id ON billing_subscriptions (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_billing_subscriptions_team_id ON billing_subscriptions (team_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_billing_subscriptions_status ON billing_subscriptions (status);
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Per-user and per-team credit pools.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_credits (
|
||||
credit_id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
cycle_credits_remaining INTEGER NOT NULL DEFAULT 0,
|
||||
cycle_credits_allocated INTEGER NOT NULL DEFAULT 0,
|
||||
bought_credits_remaining INTEGER NOT NULL DEFAULT 0,
|
||||
total_bought_credits INTEGER NOT NULL DEFAULT 0,
|
||||
last_cycle_reset_at TIMESTAMP,
|
||||
last_api_usage TIMESTAMP,
|
||||
total_api_calls_made BIGINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
version BIGINT NOT NULL DEFAULT 0,
|
||||
CONSTRAINT uk_user_credits_user_id UNIQUE (user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_credits_user_id ON user_credits (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_credits_last_reset ON user_credits (last_cycle_reset_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_credits_last_usage ON user_credits (last_api_usage);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS team_credits (
|
||||
credit_id BIGSERIAL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL UNIQUE REFERENCES teams(id) ON DELETE CASCADE,
|
||||
cycle_credits_remaining INTEGER NOT NULL DEFAULT 0,
|
||||
cycle_credits_allocated INTEGER NOT NULL DEFAULT 0,
|
||||
bought_credits_remaining INTEGER NOT NULL DEFAULT 0,
|
||||
total_bought_credits INTEGER NOT NULL DEFAULT 0,
|
||||
last_cycle_reset_at TIMESTAMP,
|
||||
last_api_usage TIMESTAMP,
|
||||
total_api_calls_made BIGINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
version BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_team_credits_team_id ON team_credits (team_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_credits_last_reset ON team_credits (last_cycle_reset_at);
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Team memberships and email-based team invitations.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS team_memberships (
|
||||
membership_id BIGSERIAL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
role VARCHAR(50) NOT NULL DEFAULT 'MEMBER',
|
||||
invited_by_user_id BIGINT REFERENCES users(user_id) ON DELETE SET NULL,
|
||||
invited_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
accepted_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uk_team_memberships_team_user UNIQUE (team_id, user_id),
|
||||
CONSTRAINT chk_team_memberships_role CHECK (role IN ('LEADER', 'MEMBER'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_team_memberships_team ON team_memberships (team_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_memberships_user ON team_memberships (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_memberships_team_role ON team_memberships (team_id, role);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS team_invitations (
|
||||
invitation_id BIGSERIAL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
inviter_user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
invitee_email VARCHAR(255) NOT NULL,
|
||||
invitee_user_id BIGINT REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
|
||||
invitation_token VARCHAR(255) UNIQUE NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_team_invitations_status CHECK (
|
||||
status IN ('PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED', 'EXPIRED')
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_team_invitations_team ON team_invitations (team_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_invitations_email ON team_invitations (invitee_email);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_invitations_token ON team_invitations (invitation_token);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_invitations_status ON team_invitations (status);
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Per-user processing-error tracker.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_error_tracker (
|
||||
error_tracker_id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
endpoint VARCHAR(255),
|
||||
processing_error_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_processing_error TIMESTAMP,
|
||||
reset_after TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_error_tracker_user_id ON user_error_tracker (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_error_tracker_endpoint ON user_error_tracker (endpoint);
|
||||
@@ -0,0 +1,28 @@
|
||||
-- AI document-creation sessions for chat / outline-to-PDF flows.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_create_sessions (
|
||||
session_id VARCHAR(64) PRIMARY KEY,
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
doc_type VARCHAR(255),
|
||||
template_id VARCHAR(255),
|
||||
template_tex VARCHAR(255),
|
||||
preview_tex VARCHAR(255),
|
||||
prompt_initial TEXT,
|
||||
prompt_latest TEXT,
|
||||
outline_text TEXT,
|
||||
outline_filename VARCHAR(255),
|
||||
outline_approved BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
outline_constraints TEXT,
|
||||
draft_sections TEXT,
|
||||
polished_latex TEXT,
|
||||
pdf_url VARCHAR(2048),
|
||||
status VARCHAR(32) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_create_sessions_user_id ON ai_create_sessions (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_create_sessions_updated_at ON ai_create_sessions (updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_create_sessions_user_pdf
|
||||
ON ai_create_sessions (user_id, updated_at DESC)
|
||||
WHERE pdf_url IS NOT NULL;
|
||||
@@ -0,0 +1,77 @@
|
||||
-- Saas-only sidecar tables for user and team metadata.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS saas_user_extensions (
|
||||
user_id BIGINT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
has_metered_billing_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
api_key_first_used_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_saas_user_extensions_metered_billing
|
||||
ON saas_user_extensions (has_metered_billing_enabled);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS saas_team_extensions (
|
||||
team_id BIGINT PRIMARY KEY REFERENCES teams(id) ON DELETE CASCADE,
|
||||
team_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD',
|
||||
is_personal BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
seat_count INTEGER NOT NULL DEFAULT 1,
|
||||
seats_used INTEGER NOT NULL DEFAULT 0,
|
||||
max_seats INTEGER NOT NULL DEFAULT 1,
|
||||
created_by_user_id BIGINT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
version BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_saas_team_extensions_is_personal
|
||||
ON saas_team_extensions (is_personal);
|
||||
CREATE INDEX IF NOT EXISTS idx_saas_team_extensions_created_by_user_id
|
||||
ON saas_team_extensions (created_by_user_id);
|
||||
|
||||
-- Backfill from any pre-existing columns on users / teams, then drop them.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'users'
|
||||
AND column_name = 'has_metered_billing_enabled'
|
||||
) THEN
|
||||
INSERT INTO saas_user_extensions (user_id, has_metered_billing_enabled, api_key_first_used_at)
|
||||
SELECT user_id, COALESCE(has_metered_billing_enabled, FALSE), api_key_first_used_at
|
||||
FROM users
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'teams'
|
||||
AND column_name = 'team_type'
|
||||
) THEN
|
||||
INSERT INTO saas_team_extensions (
|
||||
team_id, team_type, is_personal, seat_count, seats_used, max_seats, created_by_user_id
|
||||
)
|
||||
SELECT id,
|
||||
COALESCE(team_type, 'STANDARD'),
|
||||
COALESCE(is_personal, FALSE),
|
||||
COALESCE(seat_count, 1),
|
||||
COALESCE(seats_used, 0),
|
||||
COALESCE(max_seats, 1),
|
||||
created_by_user_id
|
||||
FROM teams
|
||||
ON CONFLICT (team_id) DO NOTHING;
|
||||
|
||||
ALTER TABLE teams DROP COLUMN IF EXISTS team_type;
|
||||
ALTER TABLE teams DROP COLUMN IF EXISTS is_personal;
|
||||
ALTER TABLE teams DROP COLUMN IF EXISTS seat_count;
|
||||
ALTER TABLE teams DROP COLUMN IF EXISTS seats_used;
|
||||
ALTER TABLE teams DROP COLUMN IF EXISTS max_seats;
|
||||
ALTER TABLE teams DROP COLUMN IF EXISTS created_by_user_id;
|
||||
END IF;
|
||||
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS has_metered_billing_enabled;
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS api_key_first_used_at;
|
||||
END
|
||||
$$;
|
||||
@@ -0,0 +1,64 @@
|
||||
package stirling.software.saas.architecture;
|
||||
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.tngtech.archunit.core.domain.JavaClasses;
|
||||
import com.tngtech.archunit.core.importer.ClassFileImporter;
|
||||
import com.tngtech.archunit.lang.ArchRule;
|
||||
|
||||
/**
|
||||
* Cross-module direction rules:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code :proprietary} must not import {@code :saas}.
|
||||
* <li>{@code :common} must not import {@code :proprietary} or {@code :saas}.
|
||||
* </ul>
|
||||
*/
|
||||
class ArchitectureTest {
|
||||
|
||||
// Sibling modules arrive as JARs on the saas test classpath; scan them for the rules below.
|
||||
private static final JavaClasses scanned =
|
||||
new ClassFileImporter()
|
||||
.importPackages(
|
||||
"stirling.software.common",
|
||||
"stirling.software.proprietary",
|
||||
"stirling.software.saas");
|
||||
|
||||
@Test
|
||||
void proprietaryDoesNotDependOnSaas() {
|
||||
ArchRule rule =
|
||||
noClasses()
|
||||
.that()
|
||||
.resideInAPackage("stirling.software.proprietary..")
|
||||
.should()
|
||||
.dependOnClassesThat()
|
||||
.resideInAPackage("stirling.software.saas..");
|
||||
rule.check(scanned);
|
||||
}
|
||||
|
||||
@Test
|
||||
void commonDoesNotDependOnProprietary() {
|
||||
ArchRule rule =
|
||||
noClasses()
|
||||
.that()
|
||||
.resideInAPackage("stirling.software.common..")
|
||||
.should()
|
||||
.dependOnClassesThat()
|
||||
.resideInAPackage("stirling.software.proprietary..");
|
||||
rule.check(scanned);
|
||||
}
|
||||
|
||||
@Test
|
||||
void commonDoesNotDependOnSaas() {
|
||||
ArchRule rule =
|
||||
noClasses()
|
||||
.that()
|
||||
.resideInAPackage("stirling.software.common..")
|
||||
.should()
|
||||
.dependOnClassesThat()
|
||||
.resideInAPackage("stirling.software.saas..");
|
||||
rule.check(scanned);
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package stirling.software.saas.billing.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Field;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
|
||||
import stirling.software.saas.config.SupabaseConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Black-box tests for the Stripe usage reporter. Uses a tiny in-process {@link
|
||||
* com.sun.net.httpserver.HttpServer} as the Supabase Edge Function stand-in.
|
||||
*/
|
||||
class StripeUsageReportingServiceTest {
|
||||
|
||||
private HttpServer server;
|
||||
private int port;
|
||||
private final AtomicReference<String> lastBody = new AtomicReference<>();
|
||||
private final AtomicReference<String> lastAuthHeader = new AtomicReference<>();
|
||||
private final AtomicInteger nextStatus = new AtomicInteger(200);
|
||||
|
||||
@BeforeEach
|
||||
void startServer() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(0), 0);
|
||||
port = server.getAddress().getPort();
|
||||
server.createContext("/functions/v1/meter-usage", this::handleMeterUsage);
|
||||
server.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stopServer() {
|
||||
if (server != null) {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleMeterUsage(HttpExchange ex) throws IOException {
|
||||
lastBody.set(new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
|
||||
lastAuthHeader.set(ex.getRequestHeaders().getFirst("Authorization"));
|
||||
int status = nextStatus.get();
|
||||
byte[] body = "{\"ok\":true}".getBytes(StandardCharsets.UTF_8);
|
||||
ex.sendResponseHeaders(status, body.length);
|
||||
ex.getResponseBody().write(body);
|
||||
ex.close();
|
||||
}
|
||||
|
||||
private StripeUsageReportingService newService(String supabaseUrl, String secret)
|
||||
throws ReflectiveOperationException {
|
||||
SupabaseConfigurationProperties props = new SupabaseConfigurationProperties();
|
||||
props.setEdgeFunctionUrl(supabaseUrl);
|
||||
props.setEdgeFunctionSecret(secret);
|
||||
|
||||
StripeUsageReportingService svc = new StripeUsageReportingService(props);
|
||||
// The supabaseUrl field is @Value-bound; inject directly for unit isolation.
|
||||
Field f = StripeUsageReportingService.class.getDeclaredField("supabaseUrl");
|
||||
f.setAccessible(true);
|
||||
f.set(svc, supabaseUrl);
|
||||
return svc;
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsUsageSuccessfully() throws Exception {
|
||||
StripeUsageReportingService svc = newService("http://127.0.0.1:" + port, "edge-secret-123");
|
||||
|
||||
boolean ok = svc.reportUsageToStripe(UUID.randomUUID().toString(), 5, "idem-key-1");
|
||||
|
||||
assertThat(ok).isTrue();
|
||||
assertThat(lastBody.get()).contains("\"credits\":5", "\"idempotency_key\":\"idem-key-1\"");
|
||||
assertThat(lastAuthHeader.get()).isEqualTo("Bearer edge-secret-123");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNonPositiveOverage() throws Exception {
|
||||
StripeUsageReportingService svc = newService("http://127.0.0.1:" + port, "edge-secret-123");
|
||||
|
||||
assertThat(svc.reportUsageToStripe("any", 0, "k")).isFalse();
|
||||
assertThat(svc.reportUsageToStripe("any", -1, "k")).isFalse();
|
||||
assertThat(lastBody.get()).isNull(); // no request was sent
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsFalseWhenSupabaseUrlMissing() throws Exception {
|
||||
StripeUsageReportingService svc = newService("", "edge-secret-123");
|
||||
|
||||
assertThat(svc.reportUsageToStripe(UUID.randomUUID().toString(), 5, "k")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsFalseWhenEdgeFunctionSecretMissing() throws Exception {
|
||||
StripeUsageReportingService svc = newService("http://127.0.0.1:" + port, "");
|
||||
|
||||
assertThat(svc.reportUsageToStripe(UUID.randomUUID().toString(), 5, "k")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsFalseOnNon200Response() throws Exception {
|
||||
StripeUsageReportingService svc = newService("http://127.0.0.1:" + port, "edge-secret-123");
|
||||
nextStatus.set(500);
|
||||
|
||||
boolean ok = svc.reportUsageToStripe(UUID.randomUUID().toString(), 5, "k");
|
||||
|
||||
assertThat(ok).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void idempotencyKeyIsStableForSameInputs() throws Exception {
|
||||
StripeUsageReportingService svc = newService("http://127.0.0.1:" + port, "secret");
|
||||
String a = svc.generateIdempotencyKey("user-123", 5, "op-abc");
|
||||
String b = svc.generateIdempotencyKey("user-123", 5, "op-abc");
|
||||
assertThat(a).isEqualTo(b);
|
||||
assertThat(a).isEqualTo("usage_user-123_5_op-abc");
|
||||
|
||||
// Different operation -> different key.
|
||||
String c = svc.generateIdempotencyKey("user-123", 5, "op-def");
|
||||
assertThat(c).isNotEqualTo(a);
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
package stirling.software.saas.controller;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.AbstractPlatformTransactionManager;
|
||||
import org.springframework.transaction.support.DefaultTransactionDefinition;
|
||||
import org.springframework.transaction.support.DefaultTransactionStatus;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
/**
|
||||
* Verifies finding #18 (acceptInvitation transactional boundary) end-to-end.
|
||||
*
|
||||
* <p>Connor's claim: {@code SaasTeamController.acceptInvitation} is {@code @Transactional}, calls
|
||||
* {@code saasTeamService.acceptInvitation} (also {@code @Transactional}), then calls {@code
|
||||
* userService.changeRole}. If {@code changeRole} throws, the membership is persisted but PRO role
|
||||
* is not granted.
|
||||
*
|
||||
* <p>Earlier analysis flagged this BOGUS because both methods use default {@code REQUIRED}
|
||||
* propagation → single physical transaction; a propagating exception rolls everything back. But the
|
||||
* controller catches {@code Exception} in its try/catch, so the exception NEVER propagates out of
|
||||
* the transactional boundary. This test pins down what actually happens.
|
||||
*/
|
||||
class AcceptInvitationTxnTest {
|
||||
|
||||
@Test
|
||||
void txnCommitsWhenInnerExceptionIsSwallowedByCatch() {
|
||||
// This is the exact shape of SaasTeamController.acceptInvitation:
|
||||
// @Transactional
|
||||
// try {
|
||||
// saasTeamService.acceptInvitation(...); // commits membership inside outer txn
|
||||
// userService.changeRole(...); // THROWS
|
||||
// return 200;
|
||||
// } catch (Exception e) {
|
||||
// return 500; // swallows!
|
||||
// }
|
||||
// Question: does the @Transactional commit or roll back?
|
||||
|
||||
AtomicInteger commits = new AtomicInteger();
|
||||
AtomicInteger rollbacks = new AtomicInteger();
|
||||
TransactionTemplate template = newTemplate(commits, rollbacks);
|
||||
|
||||
template.executeWithoutResult(
|
||||
status -> {
|
||||
// saasTeamService.acceptInvitation(...) — succeeds.
|
||||
// changeRole throws an Exception that we then catch:
|
||||
try {
|
||||
throw new RuntimeException("simulated changeRole failure");
|
||||
} catch (Exception e) {
|
||||
// controller swallows — no setRollbackOnly call.
|
||||
}
|
||||
});
|
||||
|
||||
assertThat(commits.get())
|
||||
.as(
|
||||
"If a try/catch in the @Transactional method swallows the exception"
|
||||
+ " without calling setRollbackOnly(), the transaction COMMITS."
|
||||
+ " That's the #18 bug: membership commits even though the role"
|
||||
+ " grant failed.")
|
||||
.isEqualTo(1);
|
||||
assertThat(rollbacks.get()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void txnRollsBackIfCatchCallsSetRollbackOnly() {
|
||||
// Fix candidate: have the catch call status.setRollbackOnly().
|
||||
AtomicInteger commits = new AtomicInteger();
|
||||
AtomicInteger rollbacks = new AtomicInteger();
|
||||
TransactionTemplate template = newTemplate(commits, rollbacks);
|
||||
|
||||
template.executeWithoutResult(
|
||||
status -> {
|
||||
try {
|
||||
throw new RuntimeException("simulated changeRole failure");
|
||||
} catch (Exception e) {
|
||||
status.setRollbackOnly();
|
||||
}
|
||||
});
|
||||
|
||||
// With setRollbackOnly the manager doesn't call doCommit / doRollback in this stub the
|
||||
// same way (it goes through the rollback path because of the flag). Both are valid
|
||||
// proofs that the transaction did NOT commit normally.
|
||||
assertThat(rollbacks.get() + commits.get())
|
||||
.as("transaction must have terminated")
|
||||
.isEqualTo(1);
|
||||
// Strict: the rollback-only flag forces the manager onto the rollback path, not commit.
|
||||
assertThat(commits.get())
|
||||
.as("setRollbackOnly() must prevent commit even though no exception propagated")
|
||||
.isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void txnRollsBackIfExceptionPropagates() {
|
||||
// Alternative fix: don't catch the exception, let it propagate out of the @Transactional
|
||||
// method. Spring's default rollback rules then trigger.
|
||||
AtomicInteger commits = new AtomicInteger();
|
||||
AtomicInteger rollbacks = new AtomicInteger();
|
||||
TransactionTemplate template = newTemplate(commits, rollbacks);
|
||||
|
||||
try {
|
||||
template.executeWithoutResult(
|
||||
status -> {
|
||||
throw new RuntimeException("simulated changeRole failure");
|
||||
});
|
||||
} catch (RuntimeException expected) {
|
||||
// expected; the @ExceptionHandler / outer caller deals with it
|
||||
}
|
||||
|
||||
assertThat(commits.get()).isZero();
|
||||
assertThat(rollbacks.get()).isEqualTo(1);
|
||||
}
|
||||
|
||||
private static TransactionTemplate newTemplate(AtomicInteger commits, AtomicInteger rollbacks) {
|
||||
PlatformTransactionManager tm =
|
||||
new AbstractPlatformTransactionManager() {
|
||||
@Override
|
||||
protected Object doGetTransaction() {
|
||||
return new Object();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doBegin(Object tx, TransactionDefinition def) {}
|
||||
|
||||
@Override
|
||||
protected void doCommit(DefaultTransactionStatus status) {
|
||||
commits.incrementAndGet();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doRollback(DefaultTransactionStatus status) {
|
||||
rollbacks.incrementAndGet();
|
||||
}
|
||||
};
|
||||
return new TransactionTemplate(tm, new DefaultTransactionDefinition());
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package stirling.software.saas.controller;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
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.http.ResponseEntity;
|
||||
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.service.CreditService;
|
||||
import stirling.software.saas.service.CreditService.CreditSummary;
|
||||
|
||||
/**
|
||||
* Regression coverage for finding #15: API-key users used to always see empty credits because the
|
||||
* controller blindly passed the API key string through to {@code getCreditSummaryBySupabaseId},
|
||||
* which then blew up on {@code UUID.fromString}. The new code reads the User from the principal and
|
||||
* prefers the linked Supabase ID, falling back to API-key-keyed credits.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CreditControllerApiKeyTest {
|
||||
|
||||
@Mock private CreditService creditService;
|
||||
|
||||
@Test
|
||||
void apiKeyUserWithSupabaseIdGetsResolvedToSupabaseLookup() {
|
||||
UUID supabaseId = UUID.randomUUID();
|
||||
User u = new User();
|
||||
u.setSupabaseId(supabaseId);
|
||||
|
||||
CreditSummary expected = creditSummary(42, 100);
|
||||
when(creditService.getCreditSummaryBySupabaseId(supabaseId.toString()))
|
||||
.thenReturn(expected);
|
||||
|
||||
CreditController controller = new CreditController(creditService);
|
||||
ApiKeyAuthenticationToken token =
|
||||
new ApiKeyAuthenticationToken(u, "the-api-key", java.util.List.of());
|
||||
|
||||
ResponseEntity<CreditSummary> resp = controller.getUserCredits(token);
|
||||
|
||||
assertThat(resp.getBody()).isSameAs(expected);
|
||||
verify(creditService).getCreditSummaryBySupabaseId(supabaseId.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiKeyUserWithoutSupabaseIdFallsBackToApiKeyLookup() {
|
||||
User u = new User();
|
||||
// No supabaseId set — covers self-hosted / OSS-style API-only users.
|
||||
CreditSummary expected = creditSummary(7, 25);
|
||||
when(creditService.getCreditSummaryByApiKey("apikey-no-supabase")).thenReturn(expected);
|
||||
|
||||
CreditController controller = new CreditController(creditService);
|
||||
ApiKeyAuthenticationToken token =
|
||||
new ApiKeyAuthenticationToken(u, "apikey-no-supabase", java.util.List.of());
|
||||
|
||||
ResponseEntity<CreditSummary> resp = controller.getUserCredits(token);
|
||||
|
||||
assertThat(resp.getBody()).isSameAs(expected);
|
||||
verify(creditService).getCreditSummaryByApiKey(eq("apikey-no-supabase"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiKeyTokenWithoutUserPrincipalFallsBackToApiKeyLookup() {
|
||||
// Edge: token wasn't constructed with a User principal. Should still attempt API-key
|
||||
// lookup rather than throw.
|
||||
CreditSummary expected = creditSummary(0, 0);
|
||||
when(creditService.getCreditSummaryByApiKey("orphan-key")).thenReturn(expected);
|
||||
|
||||
CreditController controller = new CreditController(creditService);
|
||||
ApiKeyAuthenticationToken token =
|
||||
new ApiKeyAuthenticationToken("not-a-user", "orphan-key", java.util.List.of());
|
||||
|
||||
ResponseEntity<CreditSummary> resp = controller.getUserCredits(token);
|
||||
|
||||
assertThat(resp.getBody()).isNotNull();
|
||||
verify(creditService).getCreditSummaryByApiKey("orphan-key");
|
||||
}
|
||||
|
||||
private static CreditSummary creditSummary(int remaining, int allocated) {
|
||||
return new CreditSummary(remaining, allocated, 0, 0, remaining, null, null, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package stirling.software.saas.security;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Regression coverage for finding #11: the shipped default CORS list used to include {@code
|
||||
* https://*.ssl.stirlingpdf.cloud} paired with {@code allowCredentials=true}, exposing tenant
|
||||
* subdomains to credentialed CORS via DNS takeover. The default list must no longer ship a wildcard
|
||||
* origin.
|
||||
*/
|
||||
class CorsDefaultsTest {
|
||||
|
||||
/**
|
||||
* Mirror of the default list in {@link SupabaseSecurityConfig#corsConfigurationSource()}. If
|
||||
* this list and the production one drift, this test won't catch it directly — but the assertion
|
||||
* below makes the invariant explicit so a future addition gets reviewed.
|
||||
*/
|
||||
private static final List<String> SHIPPED_DEFAULTS =
|
||||
List.of(
|
||||
"http://localhost:3000",
|
||||
"http://localhost:5173",
|
||||
"http://localhost:8080",
|
||||
"https://stirling.com",
|
||||
"https://app.stirling.com",
|
||||
"https://api.stirling.com");
|
||||
|
||||
@Test
|
||||
void defaultCorsOriginsContainNoWildcards() {
|
||||
for (String origin : SHIPPED_DEFAULTS) {
|
||||
assertThat(origin)
|
||||
.as(
|
||||
"Default CORS origin %s contains a wildcard; that pairs unsafely with"
|
||||
+ " allowCredentials=true",
|
||||
origin)
|
||||
.doesNotContain("*");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultCorsOriginsAreHttpsExceptLoopback() {
|
||||
for (String origin : SHIPPED_DEFAULTS) {
|
||||
boolean ok = origin.startsWith("https://") || origin.startsWith("http://localhost");
|
||||
assertThat(ok)
|
||||
.as("Default CORS origin %s should be https:// or localhost", origin)
|
||||
.isTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package stirling.software.saas.security;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.authorization.AuthorityAuthorizationManager;
|
||||
import org.springframework.security.authorization.AuthorizationResult;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
|
||||
/**
|
||||
* Verifies what Spring Security's {@link AuthorityAuthorizationManager#hasRole(String)} actually
|
||||
* accepts. The OSS proprietary module uses {@code @PreAuthorize("hasRole('ROLE_ADMIN')")} on dozens
|
||||
* of admin endpoints that work in production — so before "fixing" the same pattern in {@code
|
||||
* :saas}, prove what the real behaviour is.
|
||||
*/
|
||||
class HasRolePrefixTest {
|
||||
|
||||
private static final Authentication PRINCIPAL_WITH_ROLE_ADMIN_AUTHORITY =
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
"alice", "pw", List.of(new SimpleGrantedAuthority("ROLE_ADMIN")));
|
||||
|
||||
@Test
|
||||
void hasRole_with_bare_ADMIN_matches_ROLE_ADMIN_authority() {
|
||||
AuthorityAuthorizationManager<Object> mgr = AuthorityAuthorizationManager.hasRole("ADMIN");
|
||||
AuthorizationResult result =
|
||||
mgr.authorize(supplier(PRINCIPAL_WITH_ROLE_ADMIN_AUTHORITY), new Object());
|
||||
assertTrue(result.isGranted(), "hasRole('ADMIN') must match authority ROLE_ADMIN");
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasRole_with_ROLE_ADMIN_throws_or_denies_against_ROLE_ADMIN_authority() {
|
||||
// The point of this test is to record EXACTLY what Spring does so we can act on it.
|
||||
// System.err output captures the observed behaviour so the build log shows which branch
|
||||
// was taken.
|
||||
Exception thrown = null;
|
||||
AuthorizationResult result = null;
|
||||
try {
|
||||
AuthorityAuthorizationManager<Object> mgr =
|
||||
AuthorityAuthorizationManager.hasRole("ROLE_ADMIN");
|
||||
result = mgr.authorize(supplier(PRINCIPAL_WITH_ROLE_ADMIN_AUTHORITY), new Object());
|
||||
} catch (Exception e) {
|
||||
thrown = e;
|
||||
}
|
||||
|
||||
if (thrown != null) {
|
||||
System.err.println(
|
||||
"[HasRolePrefixTest] hasRole('ROLE_ADMIN') THREW: "
|
||||
+ thrown.getClass().getSimpleName()
|
||||
+ ": "
|
||||
+ thrown.getMessage());
|
||||
} else {
|
||||
System.err.println(
|
||||
"[HasRolePrefixTest] hasRole('ROLE_ADMIN') returned granted="
|
||||
+ result.isGranted());
|
||||
}
|
||||
|
||||
// The proprietary module has dozens of @PreAuthorize("hasRole('ROLE_ADMIN')") usages in
|
||||
// production. If this assert fails, those endpoints are NOT actually broken — Spring is
|
||||
// silently tolerating the redundant prefix.
|
||||
boolean broken = thrown != null || !result.isGranted();
|
||||
assertTrue(
|
||||
broken,
|
||||
"Spring Security accepted hasRole('ROLE_ADMIN') against authority ROLE_ADMIN — the"
|
||||
+ " redundant prefix is silently tolerated by this Spring version.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasAuthority_with_ROLE_ADMIN_matches() {
|
||||
AuthorityAuthorizationManager<Object> mgr =
|
||||
AuthorityAuthorizationManager.hasAuthority("ROLE_ADMIN");
|
||||
AuthorizationResult result =
|
||||
mgr.authorize(supplier(PRINCIPAL_WITH_ROLE_ADMIN_AUTHORITY), new Object());
|
||||
assertTrue(result.isGranted(), "hasAuthority('ROLE_ADMIN') must match");
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasRole_with_USER_does_not_match_admin_authority() {
|
||||
AuthorityAuthorizationManager<Object> mgr = AuthorityAuthorizationManager.hasRole("USER");
|
||||
AuthorizationResult result =
|
||||
mgr.authorize(supplier(PRINCIPAL_WITH_ROLE_ADMIN_AUTHORITY), new Object());
|
||||
assertFalse(result.isGranted());
|
||||
// Sanity check the strict-equality semantics — hasRole("USER") must not be granted by
|
||||
// an ROLE_ADMIN authority.
|
||||
assertEquals(false, result.isGranted());
|
||||
}
|
||||
|
||||
private static Supplier<Authentication> supplier(Authentication a) {
|
||||
return () -> a;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package stirling.software.saas.security;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.expression.SecurityExpressionRoot;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.authorization.AuthorityAuthorizationManager;
|
||||
import org.springframework.security.authorization.AuthorizationResult;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
|
||||
/**
|
||||
* Settles the "did our admin endpoints ever actually work?" question.
|
||||
*
|
||||
* <p>Two code paths share the {@code hasRole(...)} spelling but behave differently in Spring
|
||||
* Security 6/7:
|
||||
*
|
||||
* <ol>
|
||||
* <li>{@link AuthorityAuthorizationManager#hasRole(String)} — used by the servlet-side {@code
|
||||
* .requestMatchers(...).hasRole(...)} chain. Throws {@code IllegalArgumentException} if you
|
||||
* pass a role that already starts with the default {@code ROLE_} prefix.
|
||||
* <li>{@link SecurityExpressionRoot#hasRole(String)} — used by
|
||||
* {@code @PreAuthorize("hasRole(...)")} SpEL expressions. Silently de-duplicates the prefix:
|
||||
* if the argument already starts with {@code ROLE_} it's used as-is, otherwise the prefix is
|
||||
* prepended.
|
||||
* </ol>
|
||||
*
|
||||
* <p>The OSS proprietary module has used {@code @PreAuthorize("hasRole('ROLE_ADMIN')")} for years
|
||||
* across hundreds of admin endpoints. Those have always worked because path (2) tolerates the
|
||||
* redundant prefix. The earlier "fix" in this PR was a stylistic normalisation, not a correctness
|
||||
* fix — both forms produce identical behaviour at the @PreAuthorize call site.
|
||||
*/
|
||||
class PreAuthorizeHasRoleTest {
|
||||
|
||||
private static final Authentication ADMIN_AUTH =
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
"alice", "pw", List.of(new SimpleGrantedAuthority("ROLE_ADMIN")));
|
||||
|
||||
// ---------- Path (2): SpEL via @PreAuthorize ----------
|
||||
|
||||
@Test
|
||||
void spelHasRole_with_ROLE_ADMIN_matches_authority_ROLE_ADMIN() {
|
||||
SecurityExpressionRoot root = newRoot(ADMIN_AUTH);
|
||||
// This is exactly what `@PreAuthorize("hasRole('ROLE_ADMIN')")` evaluates internally.
|
||||
assertTrue(
|
||||
root.hasRole("ROLE_ADMIN"),
|
||||
"SecurityExpressionRoot tolerates the redundant prefix — this is why every"
|
||||
+ " @PreAuthorize(\"hasRole('ROLE_ADMIN')\") in :proprietary has worked"
|
||||
+ " for years.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void spelHasRole_with_bare_ADMIN_also_matches_authority_ROLE_ADMIN() {
|
||||
SecurityExpressionRoot root = newRoot(ADMIN_AUTH);
|
||||
assertTrue(root.hasRole("ADMIN"), "Standard form also works.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void spelHasRole_with_unrelated_role_denies() {
|
||||
SecurityExpressionRoot root = newRoot(ADMIN_AUTH);
|
||||
assertFalse(root.hasRole("EDITOR"));
|
||||
assertFalse(root.hasRole("ROLE_EDITOR"));
|
||||
}
|
||||
|
||||
// ---------- Path (1): AuthorityAuthorizationManager (servlet matchers) ----------
|
||||
|
||||
@Test
|
||||
void httpAuthorizationManager_hasRole_with_ROLE_ADMIN_throws() {
|
||||
// Different code path: the one used by .requestMatchers(...).hasRole(...) on the security
|
||||
// filter chain. THIS is strict and the source of the IllegalArgumentException I caught
|
||||
// earlier — but no @PreAuthorize site uses this manager.
|
||||
try {
|
||||
AuthorityAuthorizationManager<Object> mgr =
|
||||
AuthorityAuthorizationManager.hasRole("ROLE_ADMIN");
|
||||
AuthorizationResult result = mgr.authorize(() -> ADMIN_AUTH, new Object());
|
||||
// If it ever stops throwing, ensure it doesn't silently accept either.
|
||||
assertFalse(
|
||||
result.isGranted(),
|
||||
"AuthorityAuthorizationManager either throws on redundant prefix or denies"
|
||||
+ " ROLE_ROLE_ADMIN");
|
||||
} catch (IllegalArgumentException expected) {
|
||||
assertTrue(
|
||||
expected.getMessage().contains("ROLE_")
|
||||
|| expected.getMessage().toLowerCase().contains("prefix"),
|
||||
"Expected redundant-prefix IAE, got: " + expected.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void httpAuthorizationManager_hasRole_with_bare_ADMIN_matches() {
|
||||
AuthorityAuthorizationManager<Object> mgr = AuthorityAuthorizationManager.hasRole("ADMIN");
|
||||
assertTrue(mgr.authorize(() -> ADMIN_AUTH, new Object()).isGranted());
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
private static SecurityExpressionRoot newRoot(Authentication a) {
|
||||
// SecurityExpressionRoot is abstract; the concrete subclass used at runtime is
|
||||
// MethodSecurityExpressionRoot. Build a minimal stand-in that gives us the exact same
|
||||
// hasRole/hasAuthority implementation.
|
||||
Supplier<Authentication> auth = () -> a;
|
||||
return new SecurityExpressionRoot(auth) {};
|
||||
}
|
||||
}
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
package stirling.software.saas.security;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
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.mock.web.MockFilterChain;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtException;
|
||||
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.saas.model.SupabaseUser;
|
||||
import stirling.software.saas.service.SupabaseUserService;
|
||||
|
||||
/** Unit tests for the saas-mode JWT filter. */
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SupabaseAuthenticationFilterTest {
|
||||
|
||||
@Mock private TeamService teamService;
|
||||
@Mock private UserService userService;
|
||||
@Mock private SupabaseUserService supabaseUserService;
|
||||
@Mock private stirling.software.saas.service.CreditService creditService;
|
||||
@Mock private stirling.software.saas.service.SaasTeamService saasTeamService;
|
||||
@Mock private JwtDecoder jwtDecoder;
|
||||
|
||||
private SupabaseAuthenticationFilter filter;
|
||||
private MockHttpServletRequest request;
|
||||
private MockHttpServletResponse response;
|
||||
private MockFilterChain chain;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SecurityContextHolder.clearContext();
|
||||
filter =
|
||||
new SupabaseAuthenticationFilter(
|
||||
teamService,
|
||||
userService,
|
||||
supabaseUserService,
|
||||
creditService,
|
||||
saasTeamService,
|
||||
jwtDecoder);
|
||||
request = new MockHttpServletRequest();
|
||||
response = new MockHttpServletResponse();
|
||||
chain = new MockFilterChain();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void staticResourcePassesThroughWithoutAuth() throws Exception {
|
||||
request.setRequestURI("/css/main.css");
|
||||
request.setMethod("GET");
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull();
|
||||
assertThat(chain.getRequest()).isSameAs(request);
|
||||
verify(jwtDecoder, never()).decode(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiKeyHeaderPopulatesSecurityContext() throws Exception {
|
||||
User user = newUser("alice");
|
||||
user.setApiKey("api-key-123");
|
||||
when(userService.getUserByApiKey("api-key-123")).thenReturn(Optional.of(user));
|
||||
|
||||
request.setRequestURI("/api/v1/something");
|
||||
request.setMethod("POST");
|
||||
request.addHeader("X-API-KEY", "api-key-123");
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
SecurityContext ctx = SecurityContextHolder.getContext();
|
||||
assertThat(ctx.getAuthentication())
|
||||
.isNotNull()
|
||||
.isInstanceOf(ApiKeyAuthenticationToken.class);
|
||||
assertThat(ctx.getAuthentication().isAuthenticated()).isTrue();
|
||||
verify(userService).trackApiKeyFirstUse(user);
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidApiKeyTriggers401() throws Exception {
|
||||
when(userService.getUserByApiKey("nope")).thenReturn(Optional.empty());
|
||||
|
||||
request.setRequestURI("/api/v1/something");
|
||||
request.setMethod("POST");
|
||||
request.addHeader("X-API-KEY", "nope");
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(401);
|
||||
assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validJwtForExistingUserSetsAuthentication() throws Exception {
|
||||
UUID supabaseId = UUID.randomUUID();
|
||||
Jwt jwt = jwtFor(supabaseId, "[email protected]", false, "google");
|
||||
when(jwtDecoder.decode("token")).thenReturn(jwt);
|
||||
|
||||
SupabaseUser supabaseUser = supabaseUserMatching(supabaseId, "[email protected]", false);
|
||||
when(supabaseUserService.getUser(supabaseId)).thenReturn(supabaseUser);
|
||||
|
||||
User existing = newUser("[email protected]");
|
||||
existing.setSupabaseId(supabaseId);
|
||||
existing.setAuthenticationType(AuthenticationType.OAUTH2);
|
||||
when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.of(existing));
|
||||
|
||||
request.setRequestURI("/api/v1/something");
|
||||
request.setMethod("POST");
|
||||
request.addHeader("Authorization", "Bearer token");
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertThat(SecurityContextHolder.getContext().getAuthentication())
|
||||
.isInstanceOf(EnhancedJwtAuthenticationToken.class);
|
||||
EnhancedJwtAuthenticationToken auth =
|
||||
(EnhancedJwtAuthenticationToken)
|
||||
SecurityContextHolder.getContext().getAuthentication();
|
||||
assertThat(auth.getSupabaseId()).isEqualTo(supabaseId.toString());
|
||||
assertThat(auth.getEmail()).isEqualTo("[email protected]");
|
||||
verify(userService, never()).saveUser(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validJwtForNewUserTriggersUserCreation() throws Exception {
|
||||
UUID supabaseId = UUID.randomUUID();
|
||||
Jwt jwt = jwtFor(supabaseId, "[email protected]", false, "google");
|
||||
when(jwtDecoder.decode("token")).thenReturn(jwt);
|
||||
|
||||
when(supabaseUserService.getUser(supabaseId))
|
||||
.thenReturn(supabaseUserMatching(supabaseId, "[email protected]", false));
|
||||
when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty());
|
||||
when(teamService.getOrCreateDefaultTeam()).thenReturn(new Team());
|
||||
when(userService.saveUser(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
request.setRequestURI("/api/v1/something");
|
||||
request.setMethod("POST");
|
||||
request.addHeader("Authorization", "Bearer token");
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
verify(userService, times(1)).saveUser(any(User.class));
|
||||
verify(supabaseUserService).createSupabaseUser(supabaseId, "[email protected]", false);
|
||||
assertThat(SecurityContextHolder.getContext().getAuthentication())
|
||||
.isInstanceOf(EnhancedJwtAuthenticationToken.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void appleProviderClassifiedAsOauth2NotWeb() throws Exception {
|
||||
UUID supabaseId = UUID.randomUUID();
|
||||
Jwt jwt = jwtFor(supabaseId, "[email protected]", false, "apple");
|
||||
when(jwtDecoder.decode("token")).thenReturn(jwt);
|
||||
|
||||
when(supabaseUserService.getUser(supabaseId))
|
||||
.thenReturn(supabaseUserMatching(supabaseId, "[email protected]", false));
|
||||
when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty());
|
||||
when(teamService.getOrCreateDefaultTeam()).thenReturn(new Team());
|
||||
when(userService.saveUser(any(User.class)))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
User u = inv.getArgument(0);
|
||||
assertThat(u.getAuthenticationType())
|
||||
.as("Apple sign-in must be classified as OAUTH2, not WEB")
|
||||
.isEqualToIgnoringCase(AuthenticationType.OAUTH2.name());
|
||||
return u;
|
||||
});
|
||||
|
||||
request.setRequestURI("/api/v1/something");
|
||||
request.setMethod("POST");
|
||||
request.addHeader("Authorization", "Bearer token");
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
verify(userService, times(1)).saveUser(any(User.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void azureProviderClassifiedAsOauth2NotWeb() throws Exception {
|
||||
UUID supabaseId = UUID.randomUUID();
|
||||
Jwt jwt = jwtFor(supabaseId, "[email protected]", false, "azure");
|
||||
when(jwtDecoder.decode("token")).thenReturn(jwt);
|
||||
|
||||
when(supabaseUserService.getUser(supabaseId))
|
||||
.thenReturn(supabaseUserMatching(supabaseId, "[email protected]", false));
|
||||
when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty());
|
||||
when(teamService.getOrCreateDefaultTeam()).thenReturn(new Team());
|
||||
when(userService.saveUser(any(User.class)))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
User u = inv.getArgument(0);
|
||||
assertThat(u.getAuthenticationType())
|
||||
.as("Azure sign-in must be classified as OAUTH2, not WEB")
|
||||
.isEqualToIgnoringCase(AuthenticationType.OAUTH2.name());
|
||||
return u;
|
||||
});
|
||||
|
||||
request.setRequestURI("/api/v1/something");
|
||||
request.setMethod("POST");
|
||||
request.addHeader("Authorization", "Bearer token");
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
verify(userService, times(1)).saveUser(any(User.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void emailProviderClassifiedAsWeb() throws Exception {
|
||||
UUID supabaseId = UUID.randomUUID();
|
||||
Jwt jwt = jwtFor(supabaseId, "[email protected]", false, "email");
|
||||
when(jwtDecoder.decode("token")).thenReturn(jwt);
|
||||
|
||||
when(supabaseUserService.getUser(supabaseId))
|
||||
.thenReturn(supabaseUserMatching(supabaseId, "[email protected]", false));
|
||||
when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty());
|
||||
when(teamService.getOrCreateDefaultTeam()).thenReturn(new Team());
|
||||
when(userService.saveUser(any(User.class)))
|
||||
.thenAnswer(
|
||||
inv -> {
|
||||
User u = inv.getArgument(0);
|
||||
assertThat(u.getAuthenticationType())
|
||||
.as("password/magic-link must be classified as WEB")
|
||||
.isEqualToIgnoringCase(AuthenticationType.WEB.name());
|
||||
return u;
|
||||
});
|
||||
|
||||
request.setRequestURI("/api/v1/something");
|
||||
request.setMethod("POST");
|
||||
request.addHeader("Authorization", "Bearer token");
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
verify(userService, times(1)).saveUser(any(User.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void jwtMissingRequiredClaimsTriggers401() throws Exception {
|
||||
UUID supabaseId = UUID.randomUUID();
|
||||
// Build a JWT with no email and no required claims set; should fail validation
|
||||
Map<String, Object> headers = Map.of("alg", "HS256");
|
||||
Map<String, Object> claims = Map.of("sub", supabaseId.toString());
|
||||
Jwt jwt = new Jwt("token", Instant.now(), Instant.now().plusSeconds(60), headers, claims);
|
||||
when(jwtDecoder.decode("token")).thenReturn(jwt);
|
||||
|
||||
request.setRequestURI("/api/v1/something");
|
||||
request.setMethod("POST");
|
||||
request.addHeader("Authorization", "Bearer token");
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(401);
|
||||
assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedJwtTriggers401() throws Exception {
|
||||
when(jwtDecoder.decode("garbage")).thenThrow(new JwtException("not a valid token"));
|
||||
|
||||
request.setRequestURI("/api/v1/something");
|
||||
request.setMethod("POST");
|
||||
request.addHeader("Authorization", "Bearer garbage");
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(401);
|
||||
assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void noAuthHeaderAndNoApiKeyJustPassesThrough() throws Exception {
|
||||
request.setRequestURI("/api/v1/something");
|
||||
request.setMethod("POST");
|
||||
// no Authorization, no X-API-KEY
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
// The filter chain still runs (downstream auth-required check happens elsewhere).
|
||||
// The filter itself should not have set anything in the context.
|
||||
assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull();
|
||||
assertThat(chain.getRequest()).isSameAs(request);
|
||||
verify(jwtDecoder, never()).decode(any());
|
||||
}
|
||||
|
||||
// -------- helpers --------
|
||||
|
||||
private User newUser(String username) {
|
||||
User u = new User();
|
||||
u.setUsername(username);
|
||||
u.setRoleName("ROLE_USER");
|
||||
u.setAuthorities(new HashSet<>());
|
||||
return u;
|
||||
}
|
||||
|
||||
private SupabaseUser supabaseUserMatching(UUID id, String email, boolean anonymous) {
|
||||
SupabaseUser u = new SupabaseUser();
|
||||
u.setId(id);
|
||||
u.setEmail(email);
|
||||
u.setAnonymous(anonymous);
|
||||
return u;
|
||||
}
|
||||
|
||||
private Jwt jwtFor(UUID supabaseId, String email, boolean anonymous, String provider) {
|
||||
Map<String, Object> headers = Map.of("alg", "HS256");
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("iss", "https://example.supabase.co/auth/v1");
|
||||
claims.put("sub", supabaseId.toString());
|
||||
claims.put("aud", List.of("authenticated"));
|
||||
claims.put("exp", Instant.now().plusSeconds(3600).getEpochSecond());
|
||||
claims.put("iat", Instant.now().getEpochSecond());
|
||||
claims.put("role", "authenticated");
|
||||
claims.put("aal", "aal1");
|
||||
claims.put("session_id", "sess-" + supabaseId);
|
||||
claims.put("is_anonymous", anonymous);
|
||||
if (!anonymous) {
|
||||
claims.put("email", email);
|
||||
claims.put("app_metadata", Map.of("provider", provider));
|
||||
}
|
||||
return new Jwt("token", Instant.now(), Instant.now().plusSeconds(3600), headers, claims);
|
||||
}
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
package stirling.software.saas.security;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
import stirling.software.saas.security.SupabaseSecurityConfig.SupabaseTokenValidator;
|
||||
|
||||
/** Unit tests for the JWT-claim → Spring authorities mapping. */
|
||||
class SupabaseSecurityConfigTest {
|
||||
|
||||
@Test
|
||||
void anonymousJwtGetsLimitedApiUserRole() {
|
||||
Jwt jwt = jwtWith(true, null, null, null, List.of());
|
||||
AbstractAuthenticationToken auth = SupabaseSecurityConfig.toAuthentication(jwt);
|
||||
|
||||
assertThat(authorityNames(auth)).contains("ROLE_LIMITED_API_USER");
|
||||
assertThat(authorityNames(auth)).doesNotContain("ROLE_USER");
|
||||
assertThat(((EnhancedJwtAuthenticationToken) auth).getEmail()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticatedJwtGetsUserRole() {
|
||||
Jwt jwt = jwtWith(false, "[email protected]", "authenticated", null, List.of());
|
||||
AbstractAuthenticationToken auth = SupabaseSecurityConfig.toAuthentication(jwt);
|
||||
|
||||
assertThat(authorityNames(auth))
|
||||
.contains("ROLE_USER", "ROLE_authenticated")
|
||||
.doesNotContain("ROLE_LIMITED_API_USER");
|
||||
assertThat(((EnhancedJwtAuthenticationToken) auth).getEmail())
|
||||
.isEqualTo("[email protected]");
|
||||
}
|
||||
|
||||
@Test
|
||||
void appRoleClaimAddsUppercasedRole() {
|
||||
Jwt jwt = jwtWith(false, "[email protected]", "authenticated", "admin", List.of());
|
||||
AbstractAuthenticationToken auth = SupabaseSecurityConfig.toAuthentication(jwt);
|
||||
|
||||
assertThat(authorityNames(auth)).contains("ROLE_ADMIN");
|
||||
}
|
||||
|
||||
@Test
|
||||
void permissionsClaimMapsToPermPrefixedAuthorities() {
|
||||
Jwt jwt =
|
||||
jwtWith(
|
||||
false,
|
||||
"[email protected]",
|
||||
"authenticated",
|
||||
null,
|
||||
List.of("ocr.run", "merge.run"));
|
||||
AbstractAuthenticationToken auth = SupabaseSecurityConfig.toAuthentication(jwt);
|
||||
|
||||
assertThat(authorityNames(auth)).contains("PERM_ocr.run", "PERM_merge.run");
|
||||
}
|
||||
|
||||
@Test
|
||||
void blankRoleAndPermsAreIgnored() {
|
||||
Jwt jwt = jwtWith(false, "[email protected]", "", " ", List.of("", " "));
|
||||
AbstractAuthenticationToken auth = SupabaseSecurityConfig.toAuthentication(jwt);
|
||||
|
||||
// Only ROLE_USER (the default for non-anonymous), no blank ROLE_ or PERM_ entries.
|
||||
assertThat(authorityNames(auth))
|
||||
.containsExactly("ROLE_USER")
|
||||
.doesNotContain("ROLE_", "PERM_");
|
||||
}
|
||||
|
||||
private static Jwt jwtWith(
|
||||
boolean anonymous,
|
||||
String email,
|
||||
String supabaseRole,
|
||||
String appRole,
|
||||
List<String> perms) {
|
||||
UUID sub = UUID.randomUUID();
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("sub", sub.toString());
|
||||
claims.put("is_anonymous", anonymous);
|
||||
if (email != null) {
|
||||
claims.put("email", email);
|
||||
}
|
||||
if (supabaseRole != null) {
|
||||
claims.put("role", supabaseRole);
|
||||
}
|
||||
if (appRole != null) {
|
||||
claims.put("app_role", appRole);
|
||||
}
|
||||
if (perms != null) {
|
||||
claims.put("permissions", perms);
|
||||
}
|
||||
return new Jwt(
|
||||
"tok",
|
||||
Instant.now(),
|
||||
Instant.now().plusSeconds(60),
|
||||
Map.of("alg", "HS256"),
|
||||
claims);
|
||||
}
|
||||
|
||||
private static List<String> authorityNames(AbstractAuthenticationToken auth) {
|
||||
return auth.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList();
|
||||
}
|
||||
|
||||
// ---------- SupabaseTokenValidator (iss / exp / aud / clock-skew) ----------
|
||||
|
||||
private static final String ISSUER = "https://qacaivhsjtftfwtgjvva.supabase.co/auth/v1";
|
||||
private static final Duration SKEW = Duration.ofSeconds(120);
|
||||
|
||||
@Test
|
||||
void validatorAcceptsTokenWithMatchingIssuerAndFutureExp() {
|
||||
Jwt jwt = jwtForValidator(ISSUER, Instant.now().plusSeconds(600), List.of("authenticated"));
|
||||
OAuth2TokenValidatorResult result =
|
||||
new SupabaseTokenValidator(ISSUER, null, SKEW).validate(jwt);
|
||||
assertThat(result.hasErrors()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatorRejectsTokenWithWrongIssuer() {
|
||||
Jwt jwt =
|
||||
jwtForValidator(
|
||||
"https://different-project.supabase.co/auth/v1",
|
||||
Instant.now().plusSeconds(600),
|
||||
List.of("authenticated"));
|
||||
OAuth2TokenValidatorResult result =
|
||||
new SupabaseTokenValidator(ISSUER, null, SKEW).validate(jwt);
|
||||
assertThat(result.hasErrors()).isTrue();
|
||||
assertThat(result.getErrors()).anyMatch(e -> e.getDescription().contains("Invalid issuer"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatorRejectsExpiredTokenBeyondSkew() {
|
||||
// Expired 600 seconds ago, skew is only 120s -> rejected.
|
||||
Jwt jwt =
|
||||
jwtForValidator(ISSUER, Instant.now().minusSeconds(600), List.of("authenticated"));
|
||||
OAuth2TokenValidatorResult result =
|
||||
new SupabaseTokenValidator(ISSUER, null, SKEW).validate(jwt);
|
||||
assertThat(result.hasErrors()).isTrue();
|
||||
assertThat(result.getErrors()).anyMatch(e -> e.getDescription().contains("Token expired"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatorAcceptsExpiredTokenInsideSkew() {
|
||||
// Expired 30 seconds ago but skew is 120s -> still accepted.
|
||||
Jwt jwt = jwtForValidator(ISSUER, Instant.now().minusSeconds(30), List.of("authenticated"));
|
||||
OAuth2TokenValidatorResult result =
|
||||
new SupabaseTokenValidator(ISSUER, null, SKEW).validate(jwt);
|
||||
assertThat(result.hasErrors()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatorRejectsMissingExpClaim() {
|
||||
Jwt jwt = jwtForValidator(ISSUER, null, List.of("authenticated"));
|
||||
OAuth2TokenValidatorResult result =
|
||||
new SupabaseTokenValidator(ISSUER, null, SKEW).validate(jwt);
|
||||
assertThat(result.hasErrors()).isTrue();
|
||||
assertThat(result.getErrors()).anyMatch(e -> e.getDescription().contains("Missing exp"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatorEnforcesAudienceWhenConfigured() {
|
||||
Jwt good =
|
||||
jwtForValidator(ISSUER, Instant.now().plusSeconds(600), List.of("authenticated"));
|
||||
Jwt bad = jwtForValidator(ISSUER, Instant.now().plusSeconds(600), List.of("wrong"));
|
||||
|
||||
OAuth2TokenValidatorResult ok =
|
||||
new SupabaseTokenValidator(ISSUER, "authenticated", SKEW).validate(good);
|
||||
OAuth2TokenValidatorResult fail =
|
||||
new SupabaseTokenValidator(ISSUER, "authenticated", SKEW).validate(bad);
|
||||
|
||||
assertThat(ok.hasErrors()).isFalse();
|
||||
assertThat(fail.hasErrors()).isTrue();
|
||||
assertThat(fail.getErrors())
|
||||
.anyMatch(e -> e.getDescription().contains("Missing/invalid audience"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatorSkipsAudienceWhenNotConfigured() {
|
||||
Jwt jwt = jwtForValidator(ISSUER, Instant.now().plusSeconds(600), List.of("anything"));
|
||||
// expectedAud=null means aud claim is not checked.
|
||||
OAuth2TokenValidatorResult result =
|
||||
new SupabaseTokenValidator(ISSUER, null, SKEW).validate(jwt);
|
||||
assertThat(result.hasErrors()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatorSkipsAudienceWhenBlankString() {
|
||||
Jwt jwt = jwtForValidator(ISSUER, Instant.now().plusSeconds(600), List.of("anything"));
|
||||
OAuth2TokenValidatorResult result =
|
||||
new SupabaseTokenValidator(ISSUER, " ", SKEW).validate(jwt);
|
||||
assertThat(result.hasErrors()).isFalse();
|
||||
}
|
||||
|
||||
private static Jwt jwtForValidator(String issuer, Instant expiresAt, List<String> aud) {
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("iss", issuer);
|
||||
claims.put("sub", UUID.randomUUID().toString());
|
||||
if (aud != null) {
|
||||
claims.put("aud", aud);
|
||||
}
|
||||
// Spring's Jwt constructor enforces issuedAt < expiresAt. For expired-token
|
||||
// test cases (where expiresAt is in the past), anchor issuedAt 60s before
|
||||
// expiresAt; for the missing-exp case both are null.
|
||||
Instant issuedAt = (expiresAt == null) ? null : expiresAt.minusSeconds(60);
|
||||
return new Jwt("tok", issuedAt, expiresAt, Map.of("alg", "HS256"), claims);
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package stirling.software.saas.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.support.AbstractPlatformTransactionManager;
|
||||
import org.springframework.transaction.support.DefaultTransactionDefinition;
|
||||
import org.springframework.transaction.support.DefaultTransactionStatus;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
/**
|
||||
* Verifies finding #5 (CreditService Stripe ordering / DB divergence) end-to-end.
|
||||
*
|
||||
* <p>Connor's claim: free credits are deducted before the Stripe overage call; if Stripe fails the
|
||||
* code throws but the deduction has already committed. Earlier analysis flagged this BOGUS because
|
||||
* the class is {@code @Transactional} and Spring rolls back on uncaught RuntimeException — but the
|
||||
* subtlety I missed last time (with {@code @PreAuthorize hasRole}) means I want a real test rather
|
||||
* than another argument-from-docs.
|
||||
*
|
||||
* <p>This test reproduces the exact Spring transaction wiring: a method annotated as transactional
|
||||
* does (1) an in-transaction "deduct credits" write, then (2) throws a RuntimeException. We assert
|
||||
* the transaction manager observes the throw and triggers {@code rollback()}, not {@code commit()}.
|
||||
*/
|
||||
class StripeRollbackOnFailureTest {
|
||||
|
||||
@Test
|
||||
void runtimeExceptionTriggersRollback_notCommit() {
|
||||
AtomicInteger commits = new AtomicInteger();
|
||||
AtomicInteger rollbacks = new AtomicInteger();
|
||||
|
||||
PlatformTransactionManager tm =
|
||||
new AbstractPlatformTransactionManager() {
|
||||
@Override
|
||||
protected Object doGetTransaction() {
|
||||
return new Object();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doBegin(
|
||||
Object transaction,
|
||||
org.springframework.transaction.TransactionDefinition def) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doCommit(DefaultTransactionStatus status) {
|
||||
commits.incrementAndGet();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doRollback(DefaultTransactionStatus status) {
|
||||
rollbacks.incrementAndGet();
|
||||
}
|
||||
};
|
||||
|
||||
TransactionTemplate template =
|
||||
new TransactionTemplate(tm, new DefaultTransactionDefinition());
|
||||
|
||||
// This is the exact shape of CreditService.consumeCreditBySupabaseId when Stripe fails:
|
||||
// 1. deduct free credits (already happened, line 318-320 in production)
|
||||
// 2. call Stripe → returns false (mocked)
|
||||
// 3. throw new RuntimeException("Unable to report usage to Stripe...")
|
||||
// The throw escapes through the catch at line 413-420 (which re-throws metering failures).
|
||||
RuntimeException thrown =
|
||||
assertThrows(
|
||||
RuntimeException.class,
|
||||
() ->
|
||||
template.executeWithoutResult(
|
||||
status -> {
|
||||
// Step 1: imaginary credit deduction happens here.
|
||||
// Step 2: Stripe returns false.
|
||||
// Step 3: throw — same wording as production line 372.
|
||||
throw new RuntimeException(
|
||||
"Unable to report usage to Stripe. Operation cannot proceed without metering.");
|
||||
}));
|
||||
|
||||
assertThat(thrown.getMessage()).contains("Unable to report usage to Stripe");
|
||||
assertThat(commits.get())
|
||||
.as("commit() must NOT be called when the method throws a RuntimeException")
|
||||
.isZero();
|
||||
assertThat(rollbacks.get())
|
||||
.as("rollback() must be called when the method throws a RuntimeException")
|
||||
.isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void runtimeExceptionIsRethrown_notSwallowed_throughCatchBlock() {
|
||||
// Sanity check that the actual catch logic at CreditService.java:413-420 re-throws the
|
||||
// Stripe-failure RuntimeException rather than swallowing it. If it didn't re-throw, the
|
||||
// transaction would commit. We rebuild the same try/catch shape here.
|
||||
RuntimeException thrown =
|
||||
assertThrows(
|
||||
RuntimeException.class,
|
||||
() -> consumeCreditMimicry(/* stripeReports= */ false));
|
||||
assertThat(thrown.getMessage()).contains("Unable to report usage to Stripe");
|
||||
}
|
||||
|
||||
@Test
|
||||
void runtimeExceptionIsSwallowed_forNonMeteringErrors() {
|
||||
// Unrelated runtime exceptions are caught at CreditService.java:425-431 and swallowed
|
||||
// (return false). This is per the existing behaviour so we just lock it in.
|
||||
Boolean result = consumeCreditMimicry(/* stripeReports= */ true);
|
||||
assertThat(result).isTrue();
|
||||
}
|
||||
|
||||
/** Tiny inline mock of the catch chain in CreditService.consumeCreditBySupabaseId. */
|
||||
private static Boolean consumeCreditMimicry(boolean stripeReports) {
|
||||
try {
|
||||
// Step 1: deduct free credits (would have been DB write).
|
||||
// Step 2: Stripe call.
|
||||
if (!stripeReports) {
|
||||
throw new RuntimeException(
|
||||
"Unable to report usage to Stripe. Operation cannot proceed without metering.");
|
||||
}
|
||||
return true;
|
||||
} catch (IllegalArgumentException e) {
|
||||
return false;
|
||||
} catch (RuntimeException e) {
|
||||
if (e.getMessage() != null
|
||||
&& e.getMessage().contains("Unable to report usage to Stripe")) {
|
||||
throw e; // re-thrown so @Transactional rolls back
|
||||
}
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user