mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 13:20:08 +03:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cd461cc08 | ||
|
|
9c8be25ff0 | ||
|
|
83ea07ed6a | ||
|
|
61ebe977d3 | ||
|
|
763595a5a3 | ||
|
|
398617391b | ||
|
|
a0e0e88f07 |
+124
-27
@@ -1,8 +1,9 @@
|
||||
name: AI Engine CI
|
||||
|
||||
# Validates the Python AI engine: regenerates tool models, runs fixers,
|
||||
# lint, type-check, and tests. Called from build.yml on PRs and merge_group;
|
||||
# also runs directly on push to main as a post-merge safety net.
|
||||
# Validates the Python AI engine: regenerates tool models and runs the
|
||||
# engine quality gate (lint, type-check, format-check, tests). Called from
|
||||
# build.yml on PRs and merge_group; also runs directly on push to main as
|
||||
# a post-merge safety net.
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
@@ -51,27 +52,95 @@ jobs:
|
||||
run: task engine:tool-models
|
||||
|
||||
- name: Verify tool models are up to date
|
||||
id: tool-models-check
|
||||
continue-on-error: true
|
||||
run: git diff --exit-code engine/src/stirling/models/tool_models.py
|
||||
|
||||
- name: Comment on tool models check failure
|
||||
# Only post a comment on PRs. github-script's PR helpers need an
|
||||
# issue/PR number, which doesn't exist on merge_group runs.
|
||||
if: steps.tool-models-check.outcome == 'failure' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- tool-models-check -->';
|
||||
const body = [
|
||||
marker,
|
||||
'### Tool Models Check Failed',
|
||||
'',
|
||||
'The generated `engine/src/stirling/models/tool_models.py` is out of date with the Java OpenAPI spec and will need to be regenerated before it can be merged in.',
|
||||
'',
|
||||
'Run `task engine:tool-models` to regenerate, then commit the updated file.',
|
||||
].join('\n');
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
- name: Fail if tool models check failed
|
||||
if: steps.tool-models-check.outcome == 'failure'
|
||||
run: |
|
||||
if ! git diff --exit-code engine/src/stirling/models/tool_models.py; then
|
||||
echo "tool_models.py is out of date."
|
||||
echo "Run 'task engine:tool-models' locally and commit the updated file."
|
||||
exit 1
|
||||
fi
|
||||
echo "============================================"
|
||||
echo " Tool Models Check Failed"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "The generated engine/src/stirling/models/tool_models.py"
|
||||
echo "is out of date with the Java OpenAPI spec and will"
|
||||
echo "need to be regenerated before it can be merged in."
|
||||
echo ""
|
||||
echo "Run 'task engine:tool-models' to regenerate, then"
|
||||
echo "commit the updated file."
|
||||
echo "============================================"
|
||||
exit 1
|
||||
|
||||
- name: Run fixers
|
||||
run: task engine:fix
|
||||
- name: Remove tool models check comment on success
|
||||
if: steps.tool-models-check.outcome == 'success' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- tool-models-check -->';
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
});
|
||||
}
|
||||
|
||||
- name: Verify fixes are committed
|
||||
id: fixer_changes
|
||||
run: |
|
||||
if ! git diff --quiet; then
|
||||
git --no-pager diff --stat
|
||||
echo "::error::There are issues with your Python code that will need to be fixed before they can be merged in. Run 'task engine:fix' to auto-fix what can be fixed automatically, then run 'task engine:check' to see what still needs fixing manually."
|
||||
exit 1
|
||||
fi
|
||||
- name: Quality-check engine
|
||||
id: engine-check
|
||||
run: task engine:check
|
||||
continue-on-error: true
|
||||
|
||||
- name: Comment on fixer failures
|
||||
if: steps.fixer_changes.outcome == 'failure' && github.event_name == 'pull_request'
|
||||
- name: Comment on engine check failure
|
||||
# Only post a comment on PRs. github-script's PR helpers need an
|
||||
# issue/PR number, which doesn't exist on merge_group runs.
|
||||
if: steps.engine-check.outcome == 'failure' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
@@ -107,11 +176,39 @@ jobs:
|
||||
});
|
||||
}
|
||||
|
||||
- name: Run linting
|
||||
run: task engine:lint
|
||||
- name: Fail if engine check failed
|
||||
if: steps.engine-check.outcome == 'failure'
|
||||
run: |
|
||||
echo "============================================"
|
||||
echo " Engine Check Failed"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "There are issues with your Python code that"
|
||||
echo "will need to be fixed before they can be merged in."
|
||||
echo ""
|
||||
echo "Run 'task engine:fix' to auto-fix what can be"
|
||||
echo "fixed automatically, then run 'task engine:check'"
|
||||
echo "to see what still needs fixing manually."
|
||||
echo "============================================"
|
||||
exit 1
|
||||
|
||||
- name: Run type checking
|
||||
run: task engine:typecheck
|
||||
|
||||
- name: Run tests
|
||||
run: task engine:test
|
||||
- name: Remove engine check comment on success
|
||||
if: steps.engine-check.outcome == 'success' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- engine-check -->';
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
|
||||
- name: Comment on Java formatting failure
|
||||
- name: Comment on backend format check failure
|
||||
# Only post a comment on PRs. github-script's PR helpers need an
|
||||
# issue/PR number, which doesn't exist on merge_group runs.
|
||||
if: steps.spotless-check.outcome == 'failure' && github.event_name == 'pull_request'
|
||||
@@ -78,15 +78,11 @@ jobs:
|
||||
const marker = '<!-- java-formatting-check -->';
|
||||
const body = [
|
||||
marker,
|
||||
'### Java Formatting Check Failed',
|
||||
'### Backend Format Check Failed',
|
||||
'',
|
||||
'Your code has formatting issues. Run the following command to fix them:',
|
||||
'There are formatting issues in your Java code that will need to be fixed before they can be merged in.',
|
||||
'',
|
||||
'```bash',
|
||||
'task backend:format',
|
||||
'```',
|
||||
'',
|
||||
'Then commit and push the changes.',
|
||||
'Run `task backend:format` to auto-fix, then commit and push the changes.',
|
||||
].join('\n');
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
@@ -110,22 +106,43 @@ jobs:
|
||||
});
|
||||
}
|
||||
|
||||
- name: Fail if Java formatting issues found
|
||||
- name: Fail if backend format check failed
|
||||
if: steps.spotless-check.outcome == 'failure'
|
||||
run: |
|
||||
echo "============================================"
|
||||
echo " Java Formatting Check Failed"
|
||||
echo " Backend Format Check Failed"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "Your code has formatting issues."
|
||||
echo "Run the following command to fix them:"
|
||||
echo "There are formatting issues in your Java code"
|
||||
echo "that will need to be fixed before they can be"
|
||||
echo "merged in."
|
||||
echo ""
|
||||
echo " task backend:format"
|
||||
echo ""
|
||||
echo "Then commit and push the changes."
|
||||
echo "Run 'task backend:format' to auto-fix, then"
|
||||
echo "commit and push the changes."
|
||||
echo "============================================"
|
||||
exit 1
|
||||
|
||||
- name: Remove backend format check comment on success
|
||||
if: steps.spotless-check.outcome == 'success' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- java-formatting-check -->';
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
});
|
||||
}
|
||||
|
||||
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
|
||||
run: task backend:build:ci
|
||||
env:
|
||||
|
||||
@@ -22,12 +22,13 @@ tasks:
|
||||
vars:
|
||||
PORT: '{{.PORT | default "8080"}}'
|
||||
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
|
||||
env:
|
||||
SERVER_PORT: '{{.PORT}}'
|
||||
cmds:
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||
platforms: [windows]
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
platforms: [linux, darwin]
|
||||
|
||||
dev:bundled:
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@ tasks:
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}'
|
||||
- task: frontend:dev:prototypes
|
||||
- task: frontend:dev
|
||||
vars:
|
||||
PORT: '{{.FRONTEND_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
|
||||
+5
-1
@@ -77,6 +77,10 @@ public @interface AutoJobPostMapping {
|
||||
/**
|
||||
* Relative resource weight (1-100). See {@link
|
||||
* stirling.software.common.enumeration.ResourceWeight} for the standard tiers.
|
||||
*
|
||||
* <p>The default is a sentinel ({@link Integer#MIN_VALUE}); {@code
|
||||
* AutoJobPostMappingWeightTest} fails the build if any endpoint leaves it unset. Runtime
|
||||
* readers clamp the value into {@code [1, 100]}.
|
||||
*/
|
||||
int resourceWeight() default 1;
|
||||
int resourceWeight() default Integer.MIN_VALUE;
|
||||
}
|
||||
|
||||
+27
-5
@@ -80,6 +80,7 @@ public class ConfigInitializer {
|
||||
YamlHelper settingsFile = new YamlHelper(settingTempPath);
|
||||
|
||||
migrateEnterpriseEditionToPremium(settingsFile, settingsTemplateFile);
|
||||
migrateProFeaturesKeyCasing(settingsFile, settingsTemplateFile);
|
||||
|
||||
boolean changesMade =
|
||||
settingsTemplateFile.updateValuesFromYaml(settingsFile, settingsTemplateFile);
|
||||
@@ -116,31 +117,52 @@ public class ConfigInitializer {
|
||||
}
|
||||
if (yaml.getValueByExactKeyPath("enterpriseEdition", "SSOAutoLogin") != null) {
|
||||
template.updateValue(
|
||||
List.of("premium", "proFeatures", "SSOAutoLogin"),
|
||||
List.of("premium", "proFeatures", "ssoAutoLogin"),
|
||||
yaml.getValueByExactKeyPath("enterpriseEdition", "SSOAutoLogin"));
|
||||
}
|
||||
if (yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "autoUpdateMetadata")
|
||||
!= null) {
|
||||
template.updateValue(
|
||||
List.of("premium", "proFeatures", "CustomMetadata", "autoUpdateMetadata"),
|
||||
List.of("premium", "proFeatures", "customMetadata", "autoUpdateMetadata"),
|
||||
yaml.getValueByExactKeyPath(
|
||||
"enterpriseEdition", "CustomMetadata", "autoUpdateMetadata"));
|
||||
}
|
||||
if (yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "author") != null) {
|
||||
template.updateValue(
|
||||
List.of("premium", "proFeatures", "CustomMetadata", "author"),
|
||||
List.of("premium", "proFeatures", "customMetadata", "author"),
|
||||
yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "author"));
|
||||
}
|
||||
if (yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "creator") != null) {
|
||||
template.updateValue(
|
||||
List.of("premium", "proFeatures", "CustomMetadata", "creator"),
|
||||
List.of("premium", "proFeatures", "customMetadata", "creator"),
|
||||
yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "creator"));
|
||||
}
|
||||
if (yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "producer")
|
||||
!= null) {
|
||||
template.updateValue(
|
||||
List.of("premium", "proFeatures", "CustomMetadata", "producer"),
|
||||
List.of("premium", "proFeatures", "customMetadata", "producer"),
|
||||
yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "producer"));
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Remove post migration
|
||||
// settings.yml.template renamed the two non-camelCase proFeatures keys
|
||||
// ("SSOAutoLogin" -> "ssoAutoLogin", "CustomMetadata" -> "customMetadata") so the whole
|
||||
// settings pipeline is consistent camelCase. The save path (YamlHelper.updateValue) matches
|
||||
// keys case-sensitively, so without this carry-forward an existing install's values written
|
||||
// under the old PascalCase keys would be dropped on upgrade and reset to template defaults.
|
||||
void migrateProFeaturesKeyCasing(YamlHelper yaml, YamlHelper template) {
|
||||
Object ssoAutoLogin = yaml.getValueByExactKeyPath("premium", "proFeatures", "SSOAutoLogin");
|
||||
if (ssoAutoLogin != null) {
|
||||
template.updateValue(List.of("premium", "proFeatures", "ssoAutoLogin"), ssoAutoLogin);
|
||||
}
|
||||
for (String field : List.of("autoUpdateMetadata", "author", "creator", "producer")) {
|
||||
Object value =
|
||||
yaml.getValueByExactKeyPath("premium", "proFeatures", "CustomMetadata", field);
|
||||
if (value != null) {
|
||||
template.updateValue(
|
||||
List.of("premium", "proFeatures", "customMetadata", field), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package stirling.software.common.configuration;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.snakeyaml.engine.v2.api.LoadSettings;
|
||||
|
||||
import stirling.software.common.util.YamlHelper;
|
||||
|
||||
class ConfigInitializerTest {
|
||||
|
||||
private static final LoadSettings LOAD_SETTINGS =
|
||||
LoadSettings.builder()
|
||||
.setUseMarks(true)
|
||||
.setMaxAliasesForCollections(Integer.MAX_VALUE)
|
||||
.setAllowRecursiveKeys(true)
|
||||
.setParseComments(true)
|
||||
.build();
|
||||
|
||||
// Mirrors the proFeatures block of settings.yml.template after the camelCase rename.
|
||||
private static final String CAMEL_CASE_TEMPLATE =
|
||||
"""
|
||||
premium:
|
||||
proFeatures:
|
||||
ssoAutoLogin: false
|
||||
customMetadata:
|
||||
autoUpdateMetadata: false
|
||||
author: username
|
||||
creator: Stirling-PDF
|
||||
producer: Stirling-PDF
|
||||
""";
|
||||
|
||||
@Test
|
||||
void migrateProFeaturesKeyCasing_carriesForwardLegacyPascalCaseValues() {
|
||||
// An existing install whose settings.yml still uses the old PascalCase keys.
|
||||
String legacy =
|
||||
"""
|
||||
premium:
|
||||
proFeatures:
|
||||
SSOAutoLogin: true
|
||||
CustomMetadata:
|
||||
autoUpdateMetadata: true
|
||||
author: alice
|
||||
creator: bob
|
||||
producer: carol
|
||||
""";
|
||||
YamlHelper template = new YamlHelper(LOAD_SETTINGS, CAMEL_CASE_TEMPLATE);
|
||||
YamlHelper existing = new YamlHelper(LOAD_SETTINGS, legacy);
|
||||
|
||||
new ConfigInitializer().migrateProFeaturesKeyCasing(existing, template);
|
||||
|
||||
assertEquals(
|
||||
"true", template.getValueByExactKeyPath("premium", "proFeatures", "ssoAutoLogin"));
|
||||
assertEquals(
|
||||
"true",
|
||||
template.getValueByExactKeyPath(
|
||||
"premium", "proFeatures", "customMetadata", "autoUpdateMetadata"));
|
||||
assertEquals(
|
||||
"alice",
|
||||
template.getValueByExactKeyPath(
|
||||
"premium", "proFeatures", "customMetadata", "author"));
|
||||
assertEquals(
|
||||
"bob",
|
||||
template.getValueByExactKeyPath(
|
||||
"premium", "proFeatures", "customMetadata", "creator"));
|
||||
assertEquals(
|
||||
"carol",
|
||||
template.getValueByExactKeyPath(
|
||||
"premium", "proFeatures", "customMetadata", "producer"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void migrateProFeaturesKeyCasing_withoutLegacyKeys_keepsTemplateDefaults() {
|
||||
// No PascalCase keys present -> this migration step must be a no-op.
|
||||
String alreadyCamel =
|
||||
"""
|
||||
premium:
|
||||
proFeatures:
|
||||
ssoAutoLogin: true
|
||||
customMetadata:
|
||||
author: dave
|
||||
""";
|
||||
YamlHelper template = new YamlHelper(LOAD_SETTINGS, CAMEL_CASE_TEMPLATE);
|
||||
YamlHelper existing = new YamlHelper(LOAD_SETTINGS, alreadyCamel);
|
||||
|
||||
new ConfigInitializer().migrateProFeaturesKeyCasing(existing, template);
|
||||
|
||||
assertEquals(
|
||||
"false", template.getValueByExactKeyPath("premium", "proFeatures", "ssoAutoLogin"));
|
||||
assertEquals(
|
||||
"username",
|
||||
template.getValueByExactKeyPath(
|
||||
"premium", "proFeatures", "customMetadata", "author"));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
@@ -12,9 +14,47 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
|
||||
public class GeneralUtilsTest {
|
||||
|
||||
// Regression guard for the SSO auto-login persistence bug: the admin UI writes camelCase
|
||||
// proFeatures keys, so saveKeyToSettings must match (and persist) them against the camelCase
|
||||
// settings.yml.template. A case mismatch makes YamlHelper.updateValue silently no-op.
|
||||
@Test
|
||||
void saveKeyToSettings_persistsCamelCaseProFeatureKeys(@TempDir Path tempDir) throws Exception {
|
||||
Path settings = tempDir.resolve("settings.yml");
|
||||
Files.writeString(
|
||||
settings,
|
||||
"""
|
||||
premium:
|
||||
proFeatures:
|
||||
ssoAutoLogin: false
|
||||
customMetadata:
|
||||
author: username
|
||||
""");
|
||||
|
||||
try (MockedStatic<InstallationPathConfig> mocked =
|
||||
Mockito.mockStatic(InstallationPathConfig.class)) {
|
||||
mocked.when(InstallationPathConfig::getSettingsPath).thenReturn(settings.toString());
|
||||
|
||||
GeneralUtils.saveKeyToSettings("premium.proFeatures.ssoAutoLogin", true);
|
||||
GeneralUtils.saveKeyToSettings("premium.proFeatures.customMetadata.author", "alice");
|
||||
}
|
||||
|
||||
YamlHelper reloaded = new YamlHelper(settings);
|
||||
assertEquals(
|
||||
"true", reloaded.getValueByExactKeyPath("premium", "proFeatures", "ssoAutoLogin"));
|
||||
assertEquals(
|
||||
"alice",
|
||||
reloaded.getValueByExactKeyPath(
|
||||
"premium", "proFeatures", "customMetadata", "author"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testParsePageListWithAll() {
|
||||
List<Integer> result = GeneralUtils.parsePageList(new String[] {"all"}, 5, false);
|
||||
|
||||
+5
-1
@@ -32,6 +32,7 @@ import stirling.software.SPDF.model.json.PdfJsonTextElement;
|
||||
import stirling.software.SPDF.service.PdfJsonConversionService;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.enumeration.ResourceWeight;
|
||||
import stirling.software.common.model.api.general.EditTextOperation;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
@@ -75,7 +76,10 @@ public class EditTextController {
|
||||
new StringToArrayListPropertyEditor<>(EditTextOperation.class));
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/edit-text")
|
||||
@AutoJobPostMapping(
|
||||
consumes = "multipart/form-data",
|
||||
value = "/edit-text",
|
||||
resourceWeight = ResourceWeight.LARGE_WEIGHT)
|
||||
@StandardPdfResponse
|
||||
@Operation(
|
||||
summary = "Edit text in a PDF via find and replace",
|
||||
|
||||
+16
-4
@@ -275,7 +275,10 @@ public class ConvertImgPDFController {
|
||||
GeneralUtils.generateFilename(file[0].getOriginalFilename(), "_converted.pdf"));
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/cbz/pdf")
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/cbz/pdf",
|
||||
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
|
||||
@Operation(
|
||||
summary = "Convert CBZ comic book archive to PDF",
|
||||
description =
|
||||
@@ -301,7 +304,10 @@ public class ConvertImgPDFController {
|
||||
return WebResponseUtils.pdfFileToWebResponse(pdfFile, filename);
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/cbz")
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/pdf/cbz",
|
||||
resourceWeight = ResourceWeight.LARGE_WEIGHT)
|
||||
@Operation(
|
||||
summary = "Convert PDF to CBZ comic book archive",
|
||||
description =
|
||||
@@ -324,7 +330,10 @@ public class ConvertImgPDFController {
|
||||
return WebResponseUtils.zipFileToWebResponse(cbzFile, filename);
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/cbr/pdf")
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/cbr/pdf",
|
||||
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
|
||||
@Operation(
|
||||
summary = "Convert CBR comic book archive to PDF",
|
||||
description =
|
||||
@@ -350,7 +359,10 @@ public class ConvertImgPDFController {
|
||||
return WebResponseUtils.bytesToWebResponse(pdfBytes, filename);
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/cbr")
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/pdf/cbr",
|
||||
resourceWeight = ResourceWeight.LARGE_WEIGHT)
|
||||
@Operation(
|
||||
summary = "Convert PDF to CBR comic book archive",
|
||||
description =
|
||||
|
||||
+10
-4
@@ -141,7 +141,8 @@ public class AttachmentController {
|
||||
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/extract-attachments")
|
||||
value = "/extract-attachments",
|
||||
resourceWeight = ResourceWeight.SMALL_WEIGHT)
|
||||
@Operation(
|
||||
summary = "Extract attachments from PDF",
|
||||
description =
|
||||
@@ -176,7 +177,10 @@ public class AttachmentController {
|
||||
}
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/list-attachments")
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/list-attachments",
|
||||
resourceWeight = ResourceWeight.SMALL_WEIGHT)
|
||||
@Operation(
|
||||
summary = "List attachments in PDF",
|
||||
description =
|
||||
@@ -193,7 +197,8 @@ public class AttachmentController {
|
||||
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/rename-attachment")
|
||||
value = "/rename-attachment",
|
||||
resourceWeight = ResourceWeight.SMALL_WEIGHT)
|
||||
@StandardPdfResponse
|
||||
@Operation(
|
||||
summary = "Rename attachment in PDF",
|
||||
@@ -228,7 +233,8 @@ public class AttachmentController {
|
||||
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/delete-attachment")
|
||||
value = "/delete-attachment",
|
||||
resourceWeight = ResourceWeight.SMALL_WEIGHT)
|
||||
@StandardPdfResponse
|
||||
@Operation(
|
||||
summary = "Delete attachment from PDF",
|
||||
|
||||
+3
@@ -307,6 +307,9 @@ public class ConfigController {
|
||||
// Premium/Enterprise settings
|
||||
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
|
||||
|
||||
// AI Engine settings
|
||||
configData.put("aiEngineEnabled", applicationProperties.getAiEngine().isEnabled());
|
||||
|
||||
// Timestamp TSA settings — single source of truth for presets + admin URLs
|
||||
ApplicationProperties.Security.Timestamp tsConfig =
|
||||
applicationProperties.getSecurity().getTimestamp();
|
||||
|
||||
+5
-1
@@ -13,6 +13,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import stirling.software.SPDF.config.swagger.MarkdownConversionResponse;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.ConvertApi;
|
||||
import stirling.software.common.enumeration.ResourceWeight;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.util.PDFToFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
@@ -23,7 +24,10 @@ public class ConvertPDFToMarkdown {
|
||||
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/markdown")
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/pdf/markdown",
|
||||
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
|
||||
@MarkdownConversionResponse
|
||||
@Operation(
|
||||
summary = "Convert PDF to Markdown",
|
||||
|
||||
@@ -94,8 +94,8 @@ premium:
|
||||
key: 00000000-0000-0000-0000-000000000000
|
||||
enabled: false # Enable license key checks for pro/enterprise features
|
||||
proFeatures:
|
||||
SSOAutoLogin: false
|
||||
CustomMetadata:
|
||||
ssoAutoLogin: false
|
||||
customMetadata:
|
||||
autoUpdateMetadata: false
|
||||
author: username
|
||||
creator: Stirling-PDF
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||
import org.springframework.core.io.support.ResourcePatternResolver;
|
||||
import org.springframework.core.type.classreading.CachingMetadataReaderFactory;
|
||||
import org.springframework.core.type.classreading.MetadataReader;
|
||||
import org.springframework.core.type.classreading.MetadataReaderFactory;
|
||||
import org.springframework.core.type.filter.TypeFilter;
|
||||
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
|
||||
/**
|
||||
* Build-time guardrail: every {@link AutoJobPostMapping} method must declare an explicit {@code
|
||||
* resourceWeight}.
|
||||
*
|
||||
* <p>The credits interceptor multiplies {@code resourceWeight} into the per-call charge. An
|
||||
* endpoint that falls through to the annotation default produces a charge derived from a value
|
||||
* nobody chose — silently under- or over-billing depending on the endpoint's true cost. Forcing
|
||||
* each method to pick a value from {@link stirling.software.common.enumeration.ResourceWeight}
|
||||
* keeps the choice deliberate.
|
||||
*
|
||||
* <p>The annotation's default is {@link Integer#MIN_VALUE} (a sentinel). Runtime readers clamp the
|
||||
* value into {@code [1, 100]}, so a missed declaration can't crash production — this test is the
|
||||
* contract, the clamp is the safety net.
|
||||
*
|
||||
* <p>Lives in {@code :stirling-pdf} (core) because that's the module whose compile classpath
|
||||
* transitively sees every other module's controllers ({@code :common}, {@code :proprietary}, and
|
||||
* {@code :saas} when enabled).
|
||||
*/
|
||||
class AutoJobPostMappingWeightTest {
|
||||
|
||||
private static final String SCAN_BASE_PACKAGE = "stirling.software";
|
||||
|
||||
@Test
|
||||
void everyAutoJobPostMappingDeclaresExplicitResourceWeight() throws Exception {
|
||||
List<String> offenders = findOffendingMethods();
|
||||
|
||||
assertTrue(
|
||||
offenders.isEmpty(),
|
||||
() ->
|
||||
"The following @AutoJobPostMapping methods do not declare an explicit"
|
||||
+ " resourceWeight. Pick a value from"
|
||||
+ " stirling.software.common.enumeration.ResourceWeight (SMALL,"
|
||||
+ " MEDIUM, LARGE, XLARGE) and add it to the annotation:\n - "
|
||||
+ String.join("\n - ", offenders));
|
||||
}
|
||||
|
||||
private List<String> findOffendingMethods() throws IOException, ClassNotFoundException {
|
||||
List<String> offenders = new ArrayList<>();
|
||||
for (Class<?> candidate : scanForCandidateClasses()) {
|
||||
for (Method method : candidate.getDeclaredMethods()) {
|
||||
AutoJobPostMapping annotation = method.getAnnotation(AutoJobPostMapping.class);
|
||||
if (annotation == null) {
|
||||
continue;
|
||||
}
|
||||
if (annotation.resourceWeight() == Integer.MIN_VALUE) {
|
||||
offenders.add(candidate.getName() + "#" + method.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
return offenders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns every class under {@link #SCAN_BASE_PACKAGE} that has an @AutoJobPostMapping method.
|
||||
*/
|
||||
private List<Class<?>> scanForCandidateClasses() throws IOException, ClassNotFoundException {
|
||||
ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
|
||||
MetadataReaderFactory metadataReaderFactory = new CachingMetadataReaderFactory(resolver);
|
||||
|
||||
String pattern = "classpath*:" + SCAN_BASE_PACKAGE.replace('.', '/') + "/**/*.class";
|
||||
Resource[] resources = resolver.getResources(pattern);
|
||||
|
||||
// Pre-filter by reading annotation metadata from the class file so we don't have to load
|
||||
// every class on the test classpath just to find the few that are annotated.
|
||||
TypeFilter mentionsAutoJobPostMapping =
|
||||
(reader, factory) ->
|
||||
reader.getAnnotationMetadata()
|
||||
.getAnnotatedMethods(AutoJobPostMapping.class.getName())
|
||||
.size()
|
||||
> 0;
|
||||
|
||||
List<Class<?>> matches = new ArrayList<>();
|
||||
for (Resource resource : resources) {
|
||||
if (!resource.isReadable()) {
|
||||
continue;
|
||||
}
|
||||
MetadataReader reader = metadataReaderFactory.getMetadataReader(resource);
|
||||
if (!mentionsAutoJobPostMapping.match(reader, metadataReaderFactory)) {
|
||||
continue;
|
||||
}
|
||||
matches.add(Class.forName(reader.getClassMetadata().getClassName()));
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanity check that the classpath scan returns non-empty; otherwise the main test passes
|
||||
* vacuously.
|
||||
*/
|
||||
@Test
|
||||
void scannerFindsAtLeastOneAutoJobPostMapping() throws Exception {
|
||||
long count =
|
||||
scanForCandidateClasses().stream()
|
||||
.flatMap(c -> java.util.Arrays.stream(c.getDeclaredMethods()))
|
||||
.filter(m -> m.isAnnotationPresent(AutoJobPostMapping.class))
|
||||
.count();
|
||||
|
||||
assertTrue(
|
||||
count > 10,
|
||||
() ->
|
||||
"Expected the classpath scan to find many @AutoJobPostMapping methods but"
|
||||
+ " found only "
|
||||
+ count
|
||||
+ ". Scanner regression?");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static String describeCandidates(List<Class<?>> candidates) {
|
||||
return candidates.stream().map(Class::getName).collect(Collectors.joining(", "));
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package stirling.software.common.configuration;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.YamlHelper;
|
||||
|
||||
/**
|
||||
* End-to-end check of the container-restart path. {@link ConfigInitializer#ensureConfigExists()} is
|
||||
* what runs on every startup, merging the on-disk settings.yml with the bundled
|
||||
* settings.yml.template. These tests exercise it against the real template on the classpath to
|
||||
* prove admin-saved proFeatures values survive a restart - the bug behind "the SSO auto-login
|
||||
* button resets every time the container resets".
|
||||
*/
|
||||
class ConfigInitializerRestartTest {
|
||||
|
||||
private static String read(Path settings, String... keyPath) throws IOException {
|
||||
return String.valueOf(new YamlHelper(settings).getValueByExactKeyPath(keyPath));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ssoAutoLoginAndCustomMetadata_persistAcrossRestart(@TempDir Path tmp) throws Exception {
|
||||
Path settings = tmp.resolve("settings.yml");
|
||||
Path custom = tmp.resolve("custom_settings.yml");
|
||||
|
||||
try (MockedStatic<InstallationPathConfig> paths =
|
||||
mockStatic(InstallationPathConfig.class)) {
|
||||
paths.when(InstallationPathConfig::getSettingsPath).thenReturn(settings.toString());
|
||||
paths.when(InstallationPathConfig::getCustomSettingsPath).thenReturn(custom.toString());
|
||||
|
||||
ConfigInitializer init = new ConfigInitializer();
|
||||
|
||||
// First boot: settings.yml created from the bundled template (camelCase, default off).
|
||||
init.ensureConfigExists();
|
||||
assertEquals("false", read(settings, "premium", "proFeatures", "ssoAutoLogin"));
|
||||
|
||||
// Admin enables SSO auto-login and edits custom metadata via the exact save path the
|
||||
// admin settings controller uses.
|
||||
GeneralUtils.saveKeyToSettings("premium.proFeatures.ssoAutoLogin", true);
|
||||
GeneralUtils.saveKeyToSettings("premium.proFeatures.customMetadata.author", "acme");
|
||||
|
||||
// Container restart: ensureConfigExists merges the saved file with the template again.
|
||||
init.ensureConfigExists();
|
||||
|
||||
assertEquals("true", read(settings, "premium", "proFeatures", "ssoAutoLogin"));
|
||||
assertEquals(
|
||||
"acme", read(settings, "premium", "proFeatures", "customMetadata", "author"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyPascalCaseConfig_isMigratedAndPreservedOnRestart(@TempDir Path tmp)
|
||||
throws Exception {
|
||||
Path settings = tmp.resolve("settings.yml");
|
||||
Path custom = tmp.resolve("custom_settings.yml");
|
||||
|
||||
try (MockedStatic<InstallationPathConfig> paths =
|
||||
mockStatic(InstallationPathConfig.class)) {
|
||||
paths.when(InstallationPathConfig::getSettingsPath).thenReturn(settings.toString());
|
||||
paths.when(InstallationPathConfig::getCustomSettingsPath).thenReturn(custom.toString());
|
||||
|
||||
ConfigInitializer init = new ConfigInitializer();
|
||||
|
||||
// Seed a full settings.yml as an OLD install would have written it: PascalCase keys
|
||||
// with
|
||||
// SSO auto-login enabled.
|
||||
init.ensureConfigExists();
|
||||
String legacy =
|
||||
Files.readString(settings)
|
||||
.replace("ssoAutoLogin: false", "SSOAutoLogin: true")
|
||||
.replace("customMetadata:", "CustomMetadata:");
|
||||
Files.writeString(settings, legacy);
|
||||
|
||||
// Upgrade restart.
|
||||
init.ensureConfigExists();
|
||||
|
||||
// Value carried forward onto the new camelCase key; the legacy PascalCase key is gone.
|
||||
assertEquals("true", read(settings, "premium", "proFeatures", "ssoAutoLogin"));
|
||||
assertNull(
|
||||
new YamlHelper(settings)
|
||||
.getValueByExactKeyPath("premium", "proFeatures", "SSOAutoLogin"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -11,7 +11,7 @@ import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "Run an AI workflow against one or more PDF files")
|
||||
@Schema(description = "Run an AI workflow")
|
||||
public class AiWorkflowRequest {
|
||||
|
||||
@NotNull
|
||||
|
||||
+5
-1
@@ -17,6 +17,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.enumeration.ResourceWeight;
|
||||
import stirling.software.proprietary.security.model.api.Email;
|
||||
import stirling.software.proprietary.security.service.EmailService;
|
||||
|
||||
@@ -39,7 +40,10 @@ public class EmailController {
|
||||
* attachment.
|
||||
* @return ResponseEntity with success or error message.
|
||||
*/
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/send-email")
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/send-email",
|
||||
resourceWeight = ResourceWeight.SMALL_WEIGHT)
|
||||
@Operation(
|
||||
summary = "Send an email with an attachment",
|
||||
description =
|
||||
|
||||
@@ -5,18 +5,24 @@ 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. */
|
||||
/**
|
||||
* Registers the {@code :saas} module's entities and repositories with Spring Data JPA. Any new
|
||||
* package holding {@code @Repository} or {@code @Entity} classes must be added here, or the beans
|
||||
* won't wire at startup.
|
||||
*/
|
||||
@Configuration
|
||||
@Profile("saas")
|
||||
@EnableJpaRepositories(
|
||||
basePackages = {
|
||||
"stirling.software.saas.repository",
|
||||
"stirling.software.saas.billing.repository",
|
||||
"stirling.software.saas.ai.repository"
|
||||
"stirling.software.saas.ai.repository",
|
||||
"stirling.software.saas.payg.repository"
|
||||
})
|
||||
@EntityScan({
|
||||
"stirling.software.saas.model",
|
||||
"stirling.software.saas.billing.model",
|
||||
"stirling.software.saas.ai.model"
|
||||
"stirling.software.saas.ai.model",
|
||||
"stirling.software.saas.payg"
|
||||
})
|
||||
public class SaasJpaConfig {}
|
||||
|
||||
@@ -73,6 +73,13 @@ public class TeamMembership implements Serializable {
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* Optional per-member spend cap inside the team's wallet, in doc units. NULL means the member
|
||||
* is bounded only by the team-wide cap.
|
||||
*/
|
||||
@Column(name = "cap_units")
|
||||
private Long capUnits;
|
||||
|
||||
public boolean isLeader() {
|
||||
return role == TeamRole.LEADER;
|
||||
}
|
||||
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
package stirling.software.saas.payg.docs;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.jpdfium.PdfDocument;
|
||||
import stirling.software.saas.payg.policy.PricingPolicy;
|
||||
|
||||
/**
|
||||
* Reads pages via jpdfium for PDF inputs; treats every other content type as bytes-only.
|
||||
*
|
||||
* <p>For PDFs, units are the larger of {@code ceil(pages / docPagesPerUnit)} and {@code ceil(bytes
|
||||
* / docBytesPerUnit)}. For non-PDFs, only the bytes axis contributes. A single file is clamped to
|
||||
* {@code [1, policy.fileUnitCap]}; a multi-file group is clamped to {@code [1, policy.fileUnitCap *
|
||||
* file_count]} applied to the sum of raw per-file units. Malformed/encrypted PDFs fall back to
|
||||
* bytes-only.
|
||||
*
|
||||
* <p>{@code policy.minChargeUnits} is applied by the charge service, not here. The classifier only
|
||||
* enforces an absolute floor of {@link #MIN_UNITS_PER_NONEMPTY_FILE} so callers can rely on
|
||||
* "non-empty input → at least 1 unit".
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@Profile("saas")
|
||||
@RequiredArgsConstructor
|
||||
public class DefaultDocumentClassifier implements DocumentClassifier {
|
||||
|
||||
private static final String PDF_CONTENT_TYPE = "application/pdf";
|
||||
private static final String DEFAULT_CONTENT_TYPE = "application/octet-stream";
|
||||
|
||||
/** Floor for non-empty input. Distinct from {@code policy.minChargeUnits} (applied later). */
|
||||
private static final int MIN_UNITS_PER_NONEMPTY_FILE = 1;
|
||||
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@Override
|
||||
public DocumentMetrics classify(MultipartFile file, PricingPolicy policy) {
|
||||
Objects.requireNonNull(file, "file");
|
||||
Objects.requireNonNull(policy, "policy");
|
||||
|
||||
FileFacts facts = inspect(file);
|
||||
long rawUnits = computeRawUnits(facts.pages, facts.bytes, policy);
|
||||
// toIntExact: fail loud on overflow rather than silently wrapping a billing number.
|
||||
int units =
|
||||
Math.toIntExact(
|
||||
Math.max(
|
||||
MIN_UNITS_PER_NONEMPTY_FILE,
|
||||
Math.min(policy.getFileUnitCap(), rawUnits)));
|
||||
return new DocumentMetrics(facts.pages, facts.bytes, facts.contentType, units);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DocumentMetrics classify(List<MultipartFile> files, PricingPolicy policy) {
|
||||
Objects.requireNonNull(files, "files");
|
||||
Objects.requireNonNull(policy, "policy");
|
||||
if (files.isEmpty()) {
|
||||
throw new IllegalArgumentException("files must not be empty");
|
||||
}
|
||||
|
||||
int totalPages = 0;
|
||||
long totalBytes = 0;
|
||||
long rawUnitsSum = 0;
|
||||
String firstContentType = null;
|
||||
|
||||
for (MultipartFile file : files) {
|
||||
FileFacts facts = inspect(file);
|
||||
// Sum the *raw* (unclamped) per-file units so the group cap below can actually bind.
|
||||
// Per-file clamping in this loop would make the group cap a no-op.
|
||||
rawUnitsSum =
|
||||
saturatedAdd(rawUnitsSum, computeRawUnits(facts.pages, facts.bytes, policy));
|
||||
totalPages = saturatedAdd(totalPages, facts.pages);
|
||||
totalBytes = saturatedAdd(totalBytes, facts.bytes);
|
||||
if (firstContentType == null) {
|
||||
firstContentType = facts.contentType;
|
||||
}
|
||||
}
|
||||
|
||||
long groupCap = (long) policy.getFileUnitCap() * files.size();
|
||||
// toIntExact: fail loud on overflow rather than silently wrapping.
|
||||
int totalUnits =
|
||||
Math.toIntExact(
|
||||
Math.max(
|
||||
(long) MIN_UNITS_PER_NONEMPTY_FILE,
|
||||
Math.min(groupCap, rawUnitsSum)));
|
||||
|
||||
return new DocumentMetrics(
|
||||
totalPages,
|
||||
totalBytes,
|
||||
firstContentType != null ? firstContentType : DEFAULT_CONTENT_TYPE,
|
||||
totalUnits);
|
||||
}
|
||||
|
||||
private FileFacts inspect(MultipartFile file) {
|
||||
long bytes = file.getSize();
|
||||
String contentType =
|
||||
file.getContentType() != null ? file.getContentType() : DEFAULT_CONTENT_TYPE;
|
||||
int pages = isPdf(contentType, file.getOriginalFilename()) ? readPageCount(file) : 0;
|
||||
return new FileFacts(pages, bytes, contentType);
|
||||
}
|
||||
|
||||
private static long computeRawUnits(int pages, long bytes, PricingPolicy policy) {
|
||||
long pageUnits = pages > 0 ? ceilDiv(pages, policy.getDocPagesPerUnit()) : 0L;
|
||||
long byteUnits = ceilDiv(bytes, policy.getDocBytesPerUnit());
|
||||
return Math.max(pageUnits, byteUnits);
|
||||
}
|
||||
|
||||
private static long ceilDiv(long numerator, long divisor) {
|
||||
if (numerator <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return (numerator + divisor - 1) / divisor;
|
||||
}
|
||||
|
||||
private static boolean isPdf(String contentType, String filename) {
|
||||
if (PDF_CONTENT_TYPE.equalsIgnoreCase(contentType)) {
|
||||
return true;
|
||||
}
|
||||
return filename != null && filename.toLowerCase().endsWith(".pdf");
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialises the upload to a managed temp file and asks jpdfium for the page count. Returns 0
|
||||
* if the file can't be parsed — the byte-derived axis still produces a charge.
|
||||
*/
|
||||
private int readPageCount(MultipartFile file) {
|
||||
try (TempFile temp = tempFileManager.createManagedTempFile(".pdf")) {
|
||||
try (InputStream in = file.getInputStream();
|
||||
OutputStream out = Files.newOutputStream(temp.getPath())) {
|
||||
in.transferTo(out);
|
||||
}
|
||||
try (PdfDocument doc = PdfDocument.open(temp.getPath())) {
|
||||
return doc.pageCount();
|
||||
}
|
||||
} catch (IOException | RuntimeException e) {
|
||||
log.debug(
|
||||
"Could not read PDF page count for {} ({}); falling back to bytes-only units",
|
||||
file.getOriginalFilename(),
|
||||
e.getClass().getSimpleName());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static int saturatedAdd(int a, int b) {
|
||||
long sum = (long) a + b;
|
||||
if (sum > Integer.MAX_VALUE) {
|
||||
return Integer.MAX_VALUE;
|
||||
}
|
||||
return (int) sum;
|
||||
}
|
||||
|
||||
private static long saturatedAdd(long a, long b) {
|
||||
try {
|
||||
return Math.addExact(a, b);
|
||||
} catch (ArithmeticException e) {
|
||||
return Long.MAX_VALUE;
|
||||
}
|
||||
}
|
||||
|
||||
private record FileFacts(int pages, long bytes, String contentType) {}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package stirling.software.saas.payg.docs;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.saas.payg.policy.PricingPolicy;
|
||||
|
||||
/**
|
||||
* Computes the doc-unit cost of an uploaded file (or multi-file input) under a given policy.
|
||||
*
|
||||
* <p>Returns {@code docUnits} with an absolute floor of 1 for non-empty input. {@code
|
||||
* policy.minChargeUnits} is applied at charge time, not here.
|
||||
*/
|
||||
public interface DocumentClassifier {
|
||||
|
||||
/** Classify a single uploaded file. Returns at least 1 unit, capped at {@code fileUnitCap}. */
|
||||
DocumentMetrics classify(MultipartFile file, PricingPolicy policy);
|
||||
|
||||
/**
|
||||
* Classify a multi-file input (e.g. a merge or overlay). Returns the sum of each file's raw
|
||||
* units, capped at {@code fileUnitCap × files.size()} and floored at 1.
|
||||
*/
|
||||
DocumentMetrics classify(List<MultipartFile> files, PricingPolicy policy);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package stirling.software.saas.payg.docs;
|
||||
|
||||
/**
|
||||
* Output of {@link DocumentClassifier#classify}. {@code pages} is {@code 0} for non-PDF inputs.
|
||||
*
|
||||
* @param pages page count (0 for non-PDFs and for files whose page count couldn't be read)
|
||||
* @param bytes raw byte length of the file
|
||||
* @param contentType MIME type as reported by the upload, or {@code "application/octet-stream"}
|
||||
* when unknown
|
||||
* @param docUnits computed unit cost, clamped to the policy's {@code fileUnitCap}
|
||||
*/
|
||||
public record DocumentMetrics(int pages, long bytes, String contentType, int docUnits) {}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package stirling.software.saas.payg.entitlement;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Embeddable;
|
||||
import jakarta.persistence.EmbeddedId;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.saas.payg.model.EntitlementState;
|
||||
import stirling.software.saas.payg.model.FeatureGate;
|
||||
import stirling.software.saas.payg.model.FeatureSet;
|
||||
|
||||
/**
|
||||
* Cached entitlement state for the team (one row with {@code user_id = 0}, the team-wide sentinel)
|
||||
* plus optional per-member rows when a member sub-cap is configured. Read on the hot path by the
|
||||
* entitlement guard.
|
||||
*
|
||||
* <p>Composite PK {@code (team_id, user_id)} uses 0 as the team-wide sentinel because Postgres
|
||||
* treats {@code NULL} as not-equal-to-NULL in unique constraints — 0 keeps the PK well-defined.
|
||||
*
|
||||
* <p>No {@code @Version} — rows are produced by full-row recompute, no read-modify-write race.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "wallet_entitlement_snapshot")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class WalletEntitlementSnapshot implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public static final long TEAM_WIDE_USER_ID = 0L;
|
||||
|
||||
@EmbeddedId private WalletEntitlementSnapshotId id;
|
||||
|
||||
@Column(name = "period_start", nullable = false)
|
||||
private LocalDateTime periodStart;
|
||||
|
||||
@Column(name = "period_end", nullable = false)
|
||||
private LocalDateTime periodEnd;
|
||||
|
||||
@Column(name = "period_spend_units", nullable = false)
|
||||
private Long periodSpendUnits = 0L;
|
||||
|
||||
@Column(name = "period_cap_units")
|
||||
private Long periodCapUnits;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "state", nullable = false, length = 16)
|
||||
private EntitlementState state = EntitlementState.FULL;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "feature_set", nullable = false, length = 32)
|
||||
private FeatureSet featureSet = FeatureSet.FULL;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "enabled_gates", columnDefinition = "jsonb", nullable = false)
|
||||
private List<FeatureGate> enabledGates = new ArrayList<>();
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "computed_at", nullable = false, updatable = false)
|
||||
private LocalDateTime computedAt;
|
||||
|
||||
@Embeddable
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public static class WalletEntitlementSnapshotId implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Column(name = "team_id", nullable = false)
|
||||
private Long teamId;
|
||||
|
||||
/** Use {@link #TEAM_WIDE_USER_ID} for the team-wide row. */
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private Long userId;
|
||||
|
||||
public WalletEntitlementSnapshotId(Long teamId, Long userId) {
|
||||
this.teamId = teamId;
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof WalletEntitlementSnapshotId other)) return false;
|
||||
return Objects.equals(teamId, other.teamId) && Objects.equals(userId, other.userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(teamId, userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package stirling.software.saas.payg.job;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Embeddable;
|
||||
import jakarta.persistence.EmbeddedId;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.saas.payg.model.ArtifactKind;
|
||||
|
||||
/**
|
||||
* Per-step input/output content hash. Used by the lineage detector to decide whether a tool call
|
||||
* joins an open process (matching an earlier input or output) or opens a new one.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "job_artifact_hash")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class JobArtifactHash implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@EmbeddedId private JobArtifactHashId id;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Embeddable
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public static class JobArtifactHashId implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Column(name = "job_id", nullable = false)
|
||||
private UUID jobId;
|
||||
|
||||
/** {@code "type:value"} signature key; 128 chars fits SHA-256 plus future schemes. */
|
||||
@Column(name = "content_hash", nullable = false, length = 128)
|
||||
private String contentHash;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "kind", nullable = false, length = 8)
|
||||
private ArtifactKind kind;
|
||||
|
||||
public JobArtifactHashId(UUID jobId, String contentHash, ArtifactKind kind) {
|
||||
this.jobId = jobId;
|
||||
this.contentHash = contentHash;
|
||||
this.kind = kind;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof JobArtifactHashId other)) return false;
|
||||
return Objects.equals(jobId, other.jobId)
|
||||
&& Objects.equals(contentHash, other.contentHash)
|
||||
&& kind == other.kind;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(jobId, contentHash, kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package stirling.software.saas.payg.job;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.saas.payg.model.JobSource;
|
||||
import stirling.software.saas.payg.model.JobStatus;
|
||||
import stirling.software.saas.payg.model.ProcessType;
|
||||
|
||||
/**
|
||||
* One process — a workflow that may comprise multiple lineage-linked tool calls but is billed once
|
||||
* at process open. Closed by an explicit caller, by the frontend, or by the stale-close scheduler.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "processing_job")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class ProcessingJob implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "job_id")
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "owner_user_id", nullable = false)
|
||||
private Long ownerUserId;
|
||||
|
||||
@Column(name = "owner_team_id")
|
||||
private Long ownerTeamId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "process_type", nullable = false, length = 32)
|
||||
private ProcessType processType;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "source", nullable = false, length = 32)
|
||||
private JobSource source;
|
||||
|
||||
/** SHA-256 of the union of input file hashes; null if the input set is mixed or unknown. */
|
||||
@Column(name = "document_fingerprint", length = 64)
|
||||
private String documentFingerprint;
|
||||
|
||||
@Column(name = "doc_units", nullable = false)
|
||||
private Integer docUnits = 0;
|
||||
|
||||
@Column(name = "step_count", nullable = false)
|
||||
private Integer stepCount = 0;
|
||||
|
||||
@Column(name = "started_at", nullable = false)
|
||||
private LocalDateTime startedAt;
|
||||
|
||||
@Column(name = "last_step_at", nullable = false)
|
||||
private LocalDateTime lastStepAt;
|
||||
|
||||
@Column(name = "closed_at")
|
||||
private LocalDateTime closedAt;
|
||||
|
||||
@Column(name = "policy_id", nullable = false)
|
||||
private Long policyId;
|
||||
|
||||
/** Filled at close-time; absent while the job is still OPEN. */
|
||||
@Column(name = "charged_units")
|
||||
private Integer chargedUnits;
|
||||
|
||||
/** Cached money equivalent for receipts; not used by cap evaluation. */
|
||||
@Column(name = "charged_cents")
|
||||
private Integer chargedCents;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 32)
|
||||
private JobStatus status;
|
||||
|
||||
/** Stable idempotency key for the open-process Stripe meter event. */
|
||||
@Column(name = "idempotency_key", unique = true, length = 128)
|
||||
private String idempotencyKey;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "metadata", columnDefinition = "jsonb")
|
||||
private Map<String, Object> metadata = new HashMap<>();
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package stirling.software.saas.payg.job;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.saas.payg.model.JobStepStatus;
|
||||
|
||||
/** One tool invocation inside a {@link ProcessingJob}. Free after the first; carries audit data. */
|
||||
@Entity
|
||||
@Table(name = "processing_job_step")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class ProcessingJobStep implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "step_id")
|
||||
private Long id;
|
||||
|
||||
@Column(name = "job_id", nullable = false)
|
||||
private UUID jobId;
|
||||
|
||||
/** Endpoint path, e.g. {@code /api/v1/general/split-pages}. */
|
||||
@Column(name = "tool_id", nullable = false, length = 128)
|
||||
private String toolId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 32)
|
||||
private JobStepStatus status;
|
||||
|
||||
@Column(name = "started_at", nullable = false)
|
||||
private LocalDateTime startedAt;
|
||||
|
||||
@Column(name = "completed_at")
|
||||
private LocalDateTime completedAt;
|
||||
|
||||
@Column(name = "input_pages")
|
||||
private Integer inputPages;
|
||||
|
||||
@Column(name = "input_bytes")
|
||||
private Long inputBytes;
|
||||
|
||||
@Column(name = "error_code", length = 64)
|
||||
private String errorCode;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
/** Whether a recorded content hash belongs to a job step's input or its output. */
|
||||
public enum ArtifactKind {
|
||||
INPUT,
|
||||
OUTPUT
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
/**
|
||||
* Whether a team's tool calls auto-group into multi-step processes via content-hash lineage. {@code
|
||||
* OFF} forces every call into its own single-step process.
|
||||
*/
|
||||
public enum AutoGroupStrategy {
|
||||
AUTO,
|
||||
OFF
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
public enum CapPeriod {
|
||||
CALENDAR_MONTH,
|
||||
CALENDAR_QUARTER,
|
||||
CALENDAR_YEAR,
|
||||
BILLING_CYCLE
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
public enum EntitlementState {
|
||||
FULL,
|
||||
WARNED,
|
||||
DEGRADED
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
/** Coarse capability flags evaluated by the entitlement guard before letting a request proceed. */
|
||||
public enum FeatureGate {
|
||||
OFFSITE_PROCESSING,
|
||||
AUTOMATION,
|
||||
AI_SUPPORT,
|
||||
CLIENT_SIDE
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
/** Bundles of {@link FeatureGate}s exposed at the team / member level. */
|
||||
public enum FeatureSet {
|
||||
FULL,
|
||||
MINIMAL,
|
||||
CLIENT_ONLY
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
/**
|
||||
* Where a tool invocation originated on the client side. <strong>Caller surface only</strong> —
|
||||
* this enum does not encode whether the request was served by SaaS or by a self-hosted instance.
|
||||
* That distinction lives at the team / policy level: self-hosted instances bind to their own team
|
||||
* (via {@code license_keys.team_id}) which carries its own {@code pricing_policy_id}.
|
||||
*
|
||||
* <p>Used as the key for per-source step limits on {@code pricing_policy.step_limits}.
|
||||
*/
|
||||
public enum JobSource {
|
||||
WEB,
|
||||
API,
|
||||
PIPELINE,
|
||||
/**
|
||||
* The Tauri desktop client. Independent of whether it routes to SaaS or a self-hosted backend.
|
||||
*/
|
||||
DESKTOP_APP
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
public enum JobStatus {
|
||||
OPEN,
|
||||
CLOSED,
|
||||
REFUNDED,
|
||||
PARTIAL_REFUND,
|
||||
FAILED
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
public enum JobStepStatus {
|
||||
OK,
|
||||
FAILED,
|
||||
SKIPPED
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
/** Which pool a ledger entry touches. Debits flow CYCLE → BOUGHT → OVERAGE in that order. */
|
||||
public enum LedgerBucket {
|
||||
CYCLE,
|
||||
BOUGHT,
|
||||
OVERAGE
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
public enum LedgerEntryType {
|
||||
CYCLE_GRANT,
|
||||
DEBIT,
|
||||
REFUND,
|
||||
EXPIRE,
|
||||
OVERAGE_REPORTED,
|
||||
ADJUSTMENT,
|
||||
LEGACY_BACKFILL
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
/**
|
||||
* Shape of the workflow the job represents. Recorded for analytics; per-process step limits live on
|
||||
* {@link JobSource} now.
|
||||
*/
|
||||
public enum ProcessType {
|
||||
SINGLE_TOOL,
|
||||
CHAIN,
|
||||
AUTOMATION
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
/** What a {@code wallet_ledger.reference_id} points at. */
|
||||
public enum ReferenceType {
|
||||
JOB,
|
||||
INVOICE,
|
||||
STRIPE_EVENT,
|
||||
ADMIN
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
/** Which charging engine a wallet is running. Flipped per-team during cutover. */
|
||||
public enum WalletEngine {
|
||||
LEGACY,
|
||||
PAYG_SHADOW,
|
||||
PAYG
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package stirling.software.saas.payg.policy;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Sidecar carrying PAYG-only team fields. 1:1 with {@link Team} via shared PK so OSS Hibernate
|
||||
* (which only sees the proprietary {@link Team} entity) never tries to add PAYG columns to the
|
||||
* shared {@code teams} table. Mirrors the existing {@code SaasTeamExtensions} pattern.
|
||||
*
|
||||
* <p>Created lazily on first PAYG access for a team.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "payg_team_extensions")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class PaygTeamExtensions implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "team_id")
|
||||
private Long teamId;
|
||||
|
||||
@OneToOne(fetch = FetchType.LAZY)
|
||||
@MapsId
|
||||
@JoinColumn(name = "team_id")
|
||||
@OnDelete(action = OnDeleteAction.CASCADE)
|
||||
private Team team;
|
||||
|
||||
/** Per-team policy override; NULL means use the default row in {@code pricing_policy}. */
|
||||
@Column(name = "pricing_policy_id")
|
||||
private Long pricingPolicyId;
|
||||
|
||||
/** Stripe customer id for this team. Eager-created so every team has billing identity. */
|
||||
@Column(name = "stripe_customer_id", unique = true, length = 128)
|
||||
private String stripeCustomerId;
|
||||
|
||||
@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 PaygTeamExtensions(Team team) {
|
||||
this.team = team;
|
||||
this.teamId = team.getId();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package stirling.software.saas.payg.policy;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import jakarta.persistence.CollectionTable;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.ElementCollection;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.MapKeyColumn;
|
||||
import jakarta.persistence.MapKeyEnumerated;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.saas.payg.model.JobSource;
|
||||
|
||||
/**
|
||||
* Versioned pricing policy. Unit-calculation knobs, per-source step limits, and the per-currency
|
||||
* Stripe price IDs that turn doc-units into invoice amounts. Money lives in Stripe; this row
|
||||
* carries everything else.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "pricing_policy")
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class PricingPolicy implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "policy_id")
|
||||
private Long id;
|
||||
|
||||
/** Human-readable version label, e.g. {@code v1-2026-06}. Unique across all policies. */
|
||||
@Column(name = "version", nullable = false, unique = true, length = 32)
|
||||
private String version;
|
||||
|
||||
@Column(name = "effective_from", nullable = false)
|
||||
private LocalDateTime effectiveFrom;
|
||||
|
||||
/** Null while the policy is the current one in its lineage. */
|
||||
@Column(name = "effective_to")
|
||||
private LocalDateTime effectiveTo;
|
||||
|
||||
@Column(name = "doc_pages_per_unit", nullable = false)
|
||||
private Integer docPagesPerUnit;
|
||||
|
||||
@Column(name = "doc_bytes_per_unit", nullable = false)
|
||||
private Long docBytesPerUnit;
|
||||
|
||||
@Column(name = "min_charge_units", nullable = false)
|
||||
private Integer minChargeUnits = 1;
|
||||
|
||||
@Column(name = "file_unit_cap", nullable = false)
|
||||
private Integer fileUnitCap = 1000;
|
||||
|
||||
/**
|
||||
* Max tool steps allowed in one process before it splits, keyed by the caller's {@link
|
||||
* JobSource}. Self-hosted teams typically get a higher limit via a per-team policy override.
|
||||
*
|
||||
* <p>Persisted as a normalized child table {@code pricing_policy_step_limit (policy_id,
|
||||
* job_source, step_limit)} rather than JSONB — values are typed and queryable directly.
|
||||
*/
|
||||
@ElementCollection(fetch = FetchType.EAGER)
|
||||
@CollectionTable(
|
||||
name = "pricing_policy_step_limit",
|
||||
joinColumns = @JoinColumn(name = "policy_id"))
|
||||
@MapKeyEnumerated(EnumType.STRING)
|
||||
@MapKeyColumn(name = "job_source", length = 32)
|
||||
@Column(name = "step_limit", nullable = false)
|
||||
private Map<JobSource, Integer> stepLimits = new HashMap<>();
|
||||
|
||||
/**
|
||||
* Stripe Price IDs this policy resolves to — one per currency we support. Currency is not
|
||||
* stored here; it comes from {@code stripe.prices.currency} via Sync Engine when picking the
|
||||
* right Price for a customer's subscription. All prices must share the same Billing Meter and
|
||||
* the same free-tier upper bound in units (enforced by a deploy-time CI check).
|
||||
*
|
||||
* <p>Persisted as {@code pricing_policy_stripe_price (policy_id, stripe_price_id)}.
|
||||
*/
|
||||
@ElementCollection(fetch = FetchType.EAGER)
|
||||
@CollectionTable(
|
||||
name = "pricing_policy_stripe_price",
|
||||
joinColumns = @JoinColumn(name = "policy_id"))
|
||||
@Column(name = "stripe_price_id", nullable = false, length = 128)
|
||||
private Set<String> stripePriceIds = new HashSet<>();
|
||||
|
||||
/**
|
||||
* Exactly one row in the table has {@code is_default = true}; enforced by partial unique idx.
|
||||
*/
|
||||
@Column(name = "is_default", nullable = false)
|
||||
private Boolean isDefault = false;
|
||||
|
||||
@Column(name = "notes", columnDefinition = "text")
|
||||
private String notes;
|
||||
|
||||
@Column(name = "created_by", length = 255)
|
||||
private String createdBy;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* Convenience ctor for the unit-calc-only fields used by the document classifier and tests.
|
||||
* Other fields are filled with sensible defaults; persistence callers should set the rest
|
||||
* before saving.
|
||||
*/
|
||||
public PricingPolicy(
|
||||
int docPagesPerUnit, long docBytesPerUnit, int minChargeUnits, int fileUnitCap) {
|
||||
if (docPagesPerUnit <= 0) {
|
||||
throw new IllegalArgumentException("docPagesPerUnit must be > 0");
|
||||
}
|
||||
if (docBytesPerUnit <= 0) {
|
||||
throw new IllegalArgumentException("docBytesPerUnit must be > 0");
|
||||
}
|
||||
if (minChargeUnits < 1) {
|
||||
throw new IllegalArgumentException("minChargeUnits must be >= 1");
|
||||
}
|
||||
if (fileUnitCap < 1) {
|
||||
throw new IllegalArgumentException("fileUnitCap must be >= 1");
|
||||
}
|
||||
this.docPagesPerUnit = docPagesPerUnit;
|
||||
this.docBytesPerUnit = docBytesPerUnit;
|
||||
this.minChargeUnits = minChargeUnits;
|
||||
this.fileUnitCap = fileUnitCap;
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package stirling.software.saas.payg.repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
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.payg.job.JobArtifactHash;
|
||||
import stirling.software.saas.payg.job.JobArtifactHash.JobArtifactHashId;
|
||||
import stirling.software.saas.payg.model.JobStatus;
|
||||
|
||||
@Repository
|
||||
public interface JobArtifactHashRepository
|
||||
extends JpaRepository<JobArtifactHash, JobArtifactHashId> {
|
||||
|
||||
/**
|
||||
* Lineage lookup: find the open job (if any) whose recorded input/output hashes include the
|
||||
* supplied content hash, scoped to one user and the workflow window.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT j.ownerUserId, h.id.jobId FROM JobArtifactHash h"
|
||||
+ " JOIN ProcessingJob j ON j.id = h.id.jobId"
|
||||
+ " WHERE j.ownerUserId = :userId"
|
||||
+ " AND j.status = :openStatus"
|
||||
+ " AND j.lastStepAt > :since"
|
||||
+ " AND h.id.contentHash = :contentHash")
|
||||
List<Object[]> findLineageMatches(
|
||||
@Param("userId") Long userId,
|
||||
@Param("openStatus") JobStatus openStatus,
|
||||
@Param("since") LocalDateTime since,
|
||||
@Param("contentHash") String contentHash);
|
||||
|
||||
/** Prunes rows older than {@code cutoff}; run from a scheduled task. */
|
||||
@Modifying
|
||||
@Query("DELETE FROM JobArtifactHash h WHERE h.createdAt < :cutoff")
|
||||
int deleteOlderThan(@Param("cutoff") LocalDateTime cutoff);
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package stirling.software.saas.payg.repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
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.payg.shadow.PaygShadowCharge;
|
||||
|
||||
@Repository
|
||||
public interface PaygShadowChargeRepository extends JpaRepository<PaygShadowCharge, Long> {
|
||||
|
||||
@Query(
|
||||
"SELECT s FROM PaygShadowCharge s"
|
||||
+ " WHERE s.occurredAt >= :from AND s.occurredAt < :to"
|
||||
+ " ORDER BY s.occurredAt DESC")
|
||||
List<PaygShadowCharge> findInWindow(
|
||||
@Param("from") LocalDateTime from, @Param("to") LocalDateTime to);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package stirling.software.saas.payg.repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.saas.payg.policy.PaygTeamExtensions;
|
||||
|
||||
@Repository
|
||||
public interface PaygTeamExtensionsRepository extends JpaRepository<PaygTeamExtensions, Long> {
|
||||
|
||||
Optional<PaygTeamExtensions> findByStripeCustomerId(String stripeCustomerId);
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package stirling.software.saas.payg.repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.saas.payg.policy.PricingPolicy;
|
||||
|
||||
@Repository
|
||||
public interface PricingPolicyRepository extends JpaRepository<PricingPolicy, Long> {
|
||||
|
||||
Optional<PricingPolicy> findByVersion(String version);
|
||||
|
||||
Optional<PricingPolicy> findFirstByIsDefaultTrue();
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package stirling.software.saas.payg.repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
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.payg.job.ProcessingJob;
|
||||
import stirling.software.saas.payg.model.JobStatus;
|
||||
|
||||
@Repository
|
||||
public interface ProcessingJobRepository extends JpaRepository<ProcessingJob, UUID> {
|
||||
|
||||
List<ProcessingJob> findByOwnerUserIdAndStatus(Long ownerUserId, JobStatus status);
|
||||
|
||||
/**
|
||||
* Jobs left {@code OPEN} past the workflow window; the stale-close scheduler picks these up.
|
||||
*/
|
||||
@Query("SELECT j FROM ProcessingJob j WHERE j.status = :status AND j.lastStepAt < :cutoff")
|
||||
List<ProcessingJob> findStale(
|
||||
@Param("status") JobStatus status, @Param("cutoff") LocalDateTime cutoff);
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package stirling.software.saas.payg.repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.saas.payg.job.ProcessingJobStep;
|
||||
|
||||
@Repository
|
||||
public interface ProcessingJobStepRepository extends JpaRepository<ProcessingJobStep, Long> {
|
||||
|
||||
List<ProcessingJobStep> findByJobIdOrderByStartedAtAsc(UUID jobId);
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package stirling.software.saas.payg.repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.saas.payg.entitlement.WalletEntitlementSnapshot;
|
||||
import stirling.software.saas.payg.entitlement.WalletEntitlementSnapshot.WalletEntitlementSnapshotId;
|
||||
|
||||
@Repository
|
||||
public interface WalletEntitlementSnapshotRepository
|
||||
extends JpaRepository<WalletEntitlementSnapshot, WalletEntitlementSnapshotId> {
|
||||
|
||||
/** Team-wide snapshot lookup. */
|
||||
default Optional<WalletEntitlementSnapshot> findTeamWide(Long teamId) {
|
||||
return findById(
|
||||
new WalletEntitlementSnapshotId(
|
||||
teamId, WalletEntitlementSnapshot.TEAM_WIDE_USER_ID));
|
||||
}
|
||||
|
||||
/** Per-member snapshot lookup. */
|
||||
default Optional<WalletEntitlementSnapshot> findForMember(Long teamId, Long userId) {
|
||||
return findById(new WalletEntitlementSnapshotId(teamId, userId));
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package stirling.software.saas.payg.repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
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.payg.model.LedgerEntryType;
|
||||
import stirling.software.saas.payg.wallet.WalletLedgerEntry;
|
||||
|
||||
@Repository
|
||||
public interface WalletLedgerRepository extends JpaRepository<WalletLedgerEntry, Long> {
|
||||
|
||||
List<WalletLedgerEntry> findByTeamIdOrderByOccurredAtDesc(Long teamId);
|
||||
|
||||
/** Sum of signed amounts over a team's entries — the wallet's current balance in units. */
|
||||
@Query(
|
||||
"SELECT COALESCE(SUM(e.amountUnits), 0) FROM WalletLedgerEntry e WHERE e.teamId = :teamId")
|
||||
long sumBalanceForTeam(@Param("teamId") Long teamId);
|
||||
|
||||
/** Period-bounded spend for one team in units (debits only). */
|
||||
@Query(
|
||||
"SELECT COALESCE(SUM(e.amountUnits), 0) FROM WalletLedgerEntry e"
|
||||
+ " WHERE e.teamId = :teamId"
|
||||
+ " AND e.entryType = :entryType"
|
||||
+ " AND e.occurredAt >= :periodStart"
|
||||
+ " AND e.occurredAt < :periodEnd")
|
||||
long sumPeriodAmount(
|
||||
@Param("teamId") Long teamId,
|
||||
@Param("entryType") LedgerEntryType entryType,
|
||||
@Param("periodStart") LocalDateTime periodStart,
|
||||
@Param("periodEnd") LocalDateTime periodEnd);
|
||||
|
||||
/** Per-member period spend (only when the member has a sub-cap configured). */
|
||||
@Query(
|
||||
"SELECT COALESCE(SUM(e.amountUnits), 0) FROM WalletLedgerEntry e"
|
||||
+ " WHERE e.teamId = :teamId AND e.actorUserId = :actorUserId"
|
||||
+ " AND e.entryType = :entryType"
|
||||
+ " AND e.occurredAt >= :periodStart"
|
||||
+ " AND e.occurredAt < :periodEnd")
|
||||
long sumPeriodAmountForMember(
|
||||
@Param("teamId") Long teamId,
|
||||
@Param("actorUserId") Long actorUserId,
|
||||
@Param("entryType") LedgerEntryType entryType,
|
||||
@Param("periodStart") LocalDateTime periodStart,
|
||||
@Param("periodEnd") LocalDateTime periodEnd);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package stirling.software.saas.payg.repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.saas.payg.wallet.WalletPolicy;
|
||||
|
||||
@Repository
|
||||
public interface WalletPolicyRepository extends JpaRepository<WalletPolicy, Long> {
|
||||
|
||||
Optional<WalletPolicy> findByTeamId(Long teamId);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package stirling.software.saas.payg.shadow;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Per-job comparison row written while a team is in {@code PAYG_SHADOW} mode: what the legacy
|
||||
* engine actually charged vs. what the PAYG engine would have charged. Aggregated daily by the
|
||||
* shadow-reconciliation report; deletable after promotion.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "payg_shadow_charge")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class PaygShadowCharge implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "shadow_id")
|
||||
private Long id;
|
||||
|
||||
@Column(name = "team_id", nullable = false)
|
||||
private Long teamId;
|
||||
|
||||
@Column(name = "job_id", nullable = false)
|
||||
private UUID jobId;
|
||||
|
||||
@Column(name = "policy_id", nullable = false)
|
||||
private Long policyId;
|
||||
|
||||
@Column(name = "payg_units", nullable = false)
|
||||
private Integer paygUnits;
|
||||
|
||||
@Column(name = "legacy_credits_charged", nullable = false)
|
||||
private Integer legacyCreditsCharged;
|
||||
|
||||
/** Signed percent difference: {@code 100 * (payg - legacy) / max(1, legacy)}. */
|
||||
@Column(name = "diff_pct", nullable = false)
|
||||
private Integer diffPct;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "occurred_at", nullable = false, updatable = false)
|
||||
private LocalDateTime occurredAt;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package stirling.software.saas.payg.wallet;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.saas.payg.model.LedgerBucket;
|
||||
import stirling.software.saas.payg.model.LedgerEntryType;
|
||||
import stirling.software.saas.payg.model.ReferenceType;
|
||||
|
||||
/**
|
||||
* Append-only ledger keyed on {@code team_id}. {@code amount_units} is signed (positive = credit,
|
||||
* negative = debit). Two unique indexes (reference triple, stripe event id) prevent double-posting.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "wallet_ledger")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class WalletLedgerEntry implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "entry_id")
|
||||
private Long id;
|
||||
|
||||
@Column(name = "team_id", nullable = false)
|
||||
private Long teamId;
|
||||
|
||||
/** Which team member triggered this entry; null for system grants. */
|
||||
@Column(name = "actor_user_id")
|
||||
private Long actorUserId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "entry_type", nullable = false, length = 32)
|
||||
private LedgerEntryType entryType;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "bucket", nullable = false, length = 16)
|
||||
private LedgerBucket bucket;
|
||||
|
||||
/** Signed: positive = credit, negative = debit. The only quantity the app tracks. */
|
||||
@Column(name = "amount_units", nullable = false)
|
||||
private Integer amountUnits;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "reference_type", nullable = false, length = 32)
|
||||
private ReferenceType referenceType;
|
||||
|
||||
@Column(name = "reference_id", nullable = false, length = 128)
|
||||
private String referenceId;
|
||||
|
||||
@Column(name = "policy_id")
|
||||
private Long policyId;
|
||||
|
||||
@Column(name = "stripe_event_id", length = 128)
|
||||
private String stripeEventId;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "occurred_at", nullable = false, updatable = false)
|
||||
private LocalDateTime occurredAt;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "metadata", columnDefinition = "jsonb")
|
||||
private Map<String, Object> metadata = new HashMap<>();
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package stirling.software.saas.payg.wallet;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.saas.payg.model.AutoGroupStrategy;
|
||||
import stirling.software.saas.payg.model.CapPeriod;
|
||||
import stirling.software.saas.payg.model.FeatureSet;
|
||||
import stirling.software.saas.payg.model.WalletEngine;
|
||||
|
||||
/**
|
||||
* Per-team wallet configuration: charging engine, period spend cap, warn/degrade thresholds, the
|
||||
* degraded feature set, and the lineage-detection strategy.
|
||||
*
|
||||
* <p>No {@code @Version} — admin-only writes, no concurrent writers on a single row.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "wallet_policy")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class WalletPolicy implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "policy_id")
|
||||
private Long id;
|
||||
|
||||
@Column(name = "team_id", nullable = false, unique = true)
|
||||
private Long teamId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "engine", nullable = false, length = 16)
|
||||
private WalletEngine engine = WalletEngine.LEGACY;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "cap_period", nullable = false, length = 16)
|
||||
private CapPeriod capPeriod = CapPeriod.CALENDAR_MONTH;
|
||||
|
||||
/** Null = unlimited. Doc-units per period. */
|
||||
@Column(name = "cap_units")
|
||||
private Long capUnits;
|
||||
|
||||
/**
|
||||
* Original money cap input ("$50/month") in smallest currency unit; null if set as units. The
|
||||
* currency comes from {@code stripe.customers.currency} at recompute time — we don't duplicate
|
||||
* it here.
|
||||
*/
|
||||
@Column(name = "cap_source_money")
|
||||
private Long capSourceMoney;
|
||||
|
||||
@Column(name = "warn_at_pct", nullable = false)
|
||||
private Integer warnAtPct = 80;
|
||||
|
||||
@Column(name = "degrade_at_pct", nullable = false)
|
||||
private Integer degradeAtPct = 100;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "degraded_feature_set", nullable = false, length = 32)
|
||||
private FeatureSet degradedFeatureSet = FeatureSet.MINIMAL;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "auto_group_strategy", nullable = false, length = 16)
|
||||
private AutoGroupStrategy autoGroupStrategy = AutoGroupStrategy.AUTO;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "notification_emails", columnDefinition = "jsonb", nullable = false)
|
||||
private List<String> notificationEmails = new ArrayList<>();
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.Gauge;
|
||||
@@ -55,6 +57,7 @@ public class CreditService {
|
||||
private final Counter creditsConsumedCounter;
|
||||
private final Counter creditConsumptionFailuresCounter;
|
||||
private final Counter cycleResetCounter;
|
||||
private final Counter stripeReportFailuresCounter;
|
||||
|
||||
public CreditService(
|
||||
UserCreditRepository userCreditRepository,
|
||||
@@ -90,6 +93,10 @@ public class CreditService {
|
||||
Counter.builder("credits.cycle_reset")
|
||||
.description("Number of credit cycle resets performed")
|
||||
.register(meterRegistry);
|
||||
this.stripeReportFailuresCounter =
|
||||
Counter.builder("credits.stripe_report.failures")
|
||||
.description("Stripe meter post failed after the DB debit committed")
|
||||
.register(meterRegistry);
|
||||
|
||||
// Active gauges for current credit levels
|
||||
Gauge.builder("credits.total_available", this, CreditService::getTotalAvailableCredits)
|
||||
@@ -296,7 +303,8 @@ public class CreditService {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// Partial or full overage: consume free credits and report overage to Stripe
|
||||
// Partial or full overage: consume free credits in this tx, report the overage
|
||||
// to Stripe after commit (see scheduleStripeReportAfterCommit).
|
||||
int freeCreditsUsed =
|
||||
userCredits.getCycleCreditsRemaining() != null
|
||||
? userCredits.getCycleCreditsRemaining()
|
||||
@@ -328,55 +336,27 @@ public class CreditService {
|
||||
}
|
||||
}
|
||||
|
||||
// Stable idempotency key per (user, amount, operation) so retries dedupe.
|
||||
String operationId = MDC.get("requestId");
|
||||
if (operationId == null || operationId.isBlank()) {
|
||||
operationId = UUID.randomUUID().toString();
|
||||
}
|
||||
String idempotencyKey =
|
||||
stripeUsageReportingService.generateIdempotencyKey(
|
||||
supabaseId, overageCredits, operationId);
|
||||
|
||||
log.info(
|
||||
"[CREDIT-CONSUME] Calling Stripe reporting service - User: {}, Overage credits: {}, Idempotency key: {}",
|
||||
scheduleStripeReportAfterCommit(
|
||||
supabaseId,
|
||||
overageCredits,
|
||||
idempotencyKey);
|
||||
|
||||
boolean reported =
|
||||
stripeUsageReportingService.reportUsageToStripe(
|
||||
supabaseId, overageCredits, idempotencyKey);
|
||||
|
||||
log.info(
|
||||
"[CREDIT-CONSUME] Stripe reporting result: {} for user: {}",
|
||||
reported ? "SUCCESS" : "FAILED",
|
||||
supabaseId);
|
||||
|
||||
if (reported) {
|
||||
creditsConsumedCounter.increment(creditAmount);
|
||||
log.info(
|
||||
"[USAGE-BASED] User {} consumed {} free + {} overage credits (total: {})",
|
||||
supabaseId,
|
||||
freeCreditsUsed,
|
||||
overageCredits,
|
||||
creditAmount);
|
||||
return true;
|
||||
} else {
|
||||
log.error(
|
||||
"[USAGE-BASED] Failed to report {} overage credits to Stripe for user: {}",
|
||||
overageCredits,
|
||||
supabaseId);
|
||||
log.error(
|
||||
"[USAGE-BASED] Throwing exception to fail the operation; metering must succeed");
|
||||
creditConsumptionFailuresCounter.increment();
|
||||
throw new RuntimeException(
|
||||
"Unable to report usage to Stripe. Operation cannot proceed without metering. Please try again or contact support if the issue persists.");
|
||||
}
|
||||
idempotencyKey,
|
||||
creditAmount,
|
||||
freeCreditsUsed);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Free credits were sufficient; already consumed and returned above
|
||||
// If we reach here, there's a logic error
|
||||
log.error("[USAGE-BASED] Unexpected code path reached for user: {}", supabaseId);
|
||||
// Lost a concurrent-debit race: the in-memory balance check passed but the atomic
|
||||
// UPDATE found insufficient credits. Surface the failure so the caller can retry.
|
||||
log.warn(
|
||||
"[USAGE-BASED] Concurrent-debit race lost the free-tier consumption for"
|
||||
+ " user {}; caller should retry.",
|
||||
supabaseId);
|
||||
creditConsumptionFailuresCounter.increment();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -411,17 +391,6 @@ public class CreditService {
|
||||
creditConsumptionFailuresCounter.increment();
|
||||
return false;
|
||||
} catch (RuntimeException e) {
|
||||
// Metering failures are critical and should fail the operation.
|
||||
// This ensures users aren't charged for operations that weren't metered.
|
||||
if (e.getMessage() != null
|
||||
&& e.getMessage().contains("Unable to report usage to Stripe")) {
|
||||
log.error(
|
||||
"[CREDIT-CONSUME] Metering failure; rethrowing exception to fail operation");
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Other runtime exceptions are logged but don't fail the operation.
|
||||
// This prevents transient errors from blocking user operations.
|
||||
log.error(
|
||||
"[CREDIT-CONSUME] Unexpected runtime error consuming credits for user: {} - {}",
|
||||
supabaseId,
|
||||
@@ -451,6 +420,87 @@ public class CreditService {
|
||||
return saasUserExtensionService.isMeteredBillingEnabled(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts the Stripe meter event for an overage debit in a {@code TransactionSynchronization}
|
||||
* afterCommit hook, so the DB row lock is released before the HTTP call to Stripe.
|
||||
*
|
||||
* <p>If no transaction is active (e.g. a test calling consume directly) the report runs
|
||||
* synchronously instead, so the meter event still fires.
|
||||
*/
|
||||
private void scheduleStripeReportAfterCommit(
|
||||
String supabaseId,
|
||||
int overageCredits,
|
||||
String idempotencyKey,
|
||||
int creditAmount,
|
||||
int freeCreditsUsed) {
|
||||
|
||||
Runnable reportToStripe =
|
||||
() -> {
|
||||
log.info(
|
||||
"[CREDIT-CONSUME] Posting Stripe meter event - User: {}, Overage: {},"
|
||||
+ " Idempotency: {}",
|
||||
supabaseId,
|
||||
overageCredits,
|
||||
idempotencyKey);
|
||||
|
||||
boolean reported;
|
||||
try {
|
||||
reported =
|
||||
stripeUsageReportingService.reportUsageToStripe(
|
||||
supabaseId, overageCredits, idempotencyKey);
|
||||
} catch (RuntimeException e) {
|
||||
// Don't let a Stripe exception unwind the afterCommit chain — the DB
|
||||
// debit has already committed.
|
||||
log.error(
|
||||
"[CREDIT-CONSUME] Stripe meter post threw for user {} (overage {});"
|
||||
+ " usage owed-but-unbilled until a retry succeeds",
|
||||
supabaseId,
|
||||
overageCredits,
|
||||
e);
|
||||
stripeReportFailuresCounter.increment();
|
||||
return;
|
||||
}
|
||||
|
||||
if (reported) {
|
||||
creditsConsumedCounter.increment(creditAmount);
|
||||
log.info(
|
||||
"[USAGE-BASED] User {} consumed {} free + {} overage credits"
|
||||
+ " (total: {}); Stripe meter posted.",
|
||||
supabaseId,
|
||||
freeCreditsUsed,
|
||||
overageCredits,
|
||||
creditAmount);
|
||||
} else {
|
||||
// DB has the debit, Stripe doesn't. The idempotency key is stable, so a
|
||||
// replay with the same key recovers the meter event without
|
||||
// double-charging.
|
||||
stripeReportFailuresCounter.increment();
|
||||
log.error(
|
||||
"[USAGE-BASED] Failed to post Stripe meter event for user {}"
|
||||
+ " (overage {}); usage owed-but-unbilled. Idempotency key"
|
||||
+ " is stable: replay with key '{}' to recover.",
|
||||
supabaseId,
|
||||
overageCredits,
|
||||
idempotencyKey);
|
||||
}
|
||||
};
|
||||
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.registerSynchronization(
|
||||
new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
reportToStripe.run();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
log.warn(
|
||||
"[CREDIT-CONSUME] No active transaction; reporting Stripe usage synchronously."
|
||||
+ " Expected only in tests.");
|
||||
reportToStripe.run();
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if a user has credits available by Supabase ID (unified approach). */
|
||||
public boolean hasCreditsAvailableBySupabaseId(String supabaseId) {
|
||||
Optional<UserCredit> credits = getUserCreditsBySupabaseId(supabaseId);
|
||||
@@ -1054,48 +1104,23 @@ public class CreditService {
|
||||
// STEP 4: Try metered billing (check flag, not role)
|
||||
if (saasUserExtensionService.isMeteredBillingEnabled(user)) {
|
||||
log.info(
|
||||
"[WATERFALL] User {} has metered billing enabled; reporting {} credits to Stripe",
|
||||
"[WATERFALL] User {} has metered billing enabled; scheduling {} credits for"
|
||||
+ " Stripe report (after commit)",
|
||||
user.getUsername(),
|
||||
creditAmount);
|
||||
|
||||
try {
|
||||
String operationId = MDC.get("requestId");
|
||||
if (operationId == null || operationId.isBlank()) {
|
||||
operationId = UUID.randomUUID().toString();
|
||||
}
|
||||
String idempotencyKey =
|
||||
stripeUsageReportingService.generateIdempotencyKey(
|
||||
supabaseId.toString(), creditAmount, operationId);
|
||||
String operationId = MDC.get("requestId");
|
||||
String idempotencyKey =
|
||||
stripeUsageReportingService.generateIdempotencyKey(
|
||||
supabaseId.toString(), creditAmount, operationId);
|
||||
|
||||
boolean reported =
|
||||
stripeUsageReportingService.reportUsageToStripe(
|
||||
supabaseId.toString(), creditAmount, idempotencyKey);
|
||||
|
||||
if (reported) {
|
||||
creditsConsumedCounter.increment(creditAmount);
|
||||
|
||||
log.info(
|
||||
"[WATERFALL] Reported {} overage credits to Stripe for user: {}",
|
||||
creditAmount,
|
||||
user.getUsername());
|
||||
return CreditConsumptionResult.success("METERED_SUBSCRIPTION");
|
||||
} else {
|
||||
log.error(
|
||||
"[WATERFALL] Failed to report usage to Stripe for user: {}",
|
||||
user.getUsername());
|
||||
creditConsumptionFailuresCounter.increment();
|
||||
return CreditConsumptionResult.failure("Failed to report usage to Stripe");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"[WATERFALL] Exception while reporting to Stripe for user {}: {}",
|
||||
user.getUsername(),
|
||||
e.getMessage(),
|
||||
e);
|
||||
creditConsumptionFailuresCounter.increment();
|
||||
return CreditConsumptionResult.failure(
|
||||
"Error reporting usage to Stripe: " + e.getMessage());
|
||||
}
|
||||
scheduleStripeReportAfterCommit(
|
||||
supabaseId.toString(),
|
||||
creditAmount,
|
||||
idempotencyKey,
|
||||
creditAmount,
|
||||
/* freeCreditsUsed= */ 0);
|
||||
return CreditConsumptionResult.success("METERED_SUBSCRIPTION");
|
||||
} else if (user.getRolesAsString().contains("ROLE_PRO_USER")) {
|
||||
// Pro user without metered billing enabled; reject with helpful message
|
||||
log.warn(
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
-- PAYG data model: pricing policy, processing jobs + lineage, wallet ledger, wallet policy,
|
||||
-- entitlement snapshots, shadow-mode comparison rows, plus a payg_team_extensions sidecar table
|
||||
-- carrying team-level PAYG fields, and a cap_units column on team_memberships.
|
||||
--
|
||||
-- Sidecar pattern (mirrors saas_team_extensions): PAYG-only team fields don't sit directly on
|
||||
-- `teams`, so OSS deployments running Hibernate ddl-auto=update against the proprietary Team
|
||||
-- entity never see PAYG columns they don't have entities for.
|
||||
--
|
||||
-- Everything is purely additive. No existing rows are modified, no columns are dropped.
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 1. pricing_policy — versioned economic config (units, lifecycle metadata).
|
||||
-- step_limits and stripe_price_ids live on normalised child tables below — typed columns, no
|
||||
-- JSON parsing, queryable directly.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS pricing_policy (
|
||||
policy_id BIGSERIAL PRIMARY KEY,
|
||||
version VARCHAR(32) NOT NULL UNIQUE,
|
||||
effective_from TIMESTAMP NOT NULL,
|
||||
effective_to TIMESTAMP,
|
||||
doc_pages_per_unit INTEGER NOT NULL,
|
||||
doc_bytes_per_unit BIGINT NOT NULL,
|
||||
min_charge_units INTEGER NOT NULL DEFAULT 1,
|
||||
file_unit_cap INTEGER NOT NULL DEFAULT 1000,
|
||||
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
notes TEXT,
|
||||
created_by VARCHAR(255),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_pricing_policy_default
|
||||
ON pricing_policy (is_default) WHERE is_default = TRUE;
|
||||
|
||||
-- Max steps allowed per process for each caller surface (JobSource).
|
||||
CREATE TABLE IF NOT EXISTS pricing_policy_step_limit (
|
||||
policy_id BIGINT NOT NULL REFERENCES pricing_policy(policy_id) ON DELETE CASCADE,
|
||||
job_source VARCHAR(32) NOT NULL,
|
||||
step_limit INTEGER NOT NULL,
|
||||
PRIMARY KEY (policy_id, job_source)
|
||||
);
|
||||
|
||||
-- Stripe Price IDs this policy resolves to, one per supported currency. Currency itself isn't
|
||||
-- stored here — it lives on stripe.prices.currency and is looked up via Sync Engine when picking
|
||||
-- the right Price for a customer's subscription. All prices in one policy must share the same
|
||||
-- Billing Meter and the same first-tier upper bound in units (deploy-time CI check).
|
||||
CREATE TABLE IF NOT EXISTS pricing_policy_stripe_price (
|
||||
policy_id BIGINT NOT NULL REFERENCES pricing_policy(policy_id) ON DELETE CASCADE,
|
||||
stripe_price_id VARCHAR(128) NOT NULL,
|
||||
PRIMARY KEY (policy_id, stripe_price_id)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 2. payg_team_extensions — sidecar carrying PAYG-only team fields. 1:1 with teams via shared PK.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS payg_team_extensions (
|
||||
team_id BIGINT PRIMARY KEY REFERENCES teams(team_id) ON DELETE CASCADE,
|
||||
pricing_policy_id BIGINT REFERENCES pricing_policy(policy_id),
|
||||
stripe_customer_id VARCHAR(128) UNIQUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
version BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN payg_team_extensions.pricing_policy_id IS
|
||||
'Override policy for this team. NULL means use the row in pricing_policy with is_default=TRUE.';
|
||||
COMMENT ON COLUMN payg_team_extensions.stripe_customer_id IS
|
||||
'Stripe customer id for this team. Eager-created so every team has billing identity on file.';
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 3. team_memberships column addition: optional per-member sub-cap. Lives directly on the table
|
||||
-- because team_memberships is already a SaaS-only table.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
ALTER TABLE team_memberships
|
||||
ADD COLUMN IF NOT EXISTS cap_units BIGINT;
|
||||
COMMENT ON COLUMN team_memberships.cap_units IS
|
||||
'Per-period spend cap for this member inside their team wallet, in doc units. NULL = no member-level cap.';
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 4. processing_job — one billable process; step_count and last_step_at track the workflow window.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS processing_job (
|
||||
job_id UUID PRIMARY KEY,
|
||||
owner_user_id BIGINT NOT NULL,
|
||||
owner_team_id BIGINT,
|
||||
process_type VARCHAR(32) NOT NULL,
|
||||
source VARCHAR(32) NOT NULL,
|
||||
document_fingerprint VARCHAR(64),
|
||||
doc_units INTEGER NOT NULL DEFAULT 0,
|
||||
step_count INTEGER NOT NULL DEFAULT 0,
|
||||
started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_step_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
closed_at TIMESTAMP,
|
||||
policy_id BIGINT NOT NULL REFERENCES pricing_policy(policy_id),
|
||||
charged_units INTEGER,
|
||||
charged_cents INTEGER,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
idempotency_key VARCHAR(128) UNIQUE,
|
||||
metadata JSONB
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_processing_job_owner_open
|
||||
ON processing_job (owner_user_id, status) WHERE status = 'OPEN';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_processing_job_last_step
|
||||
ON processing_job (status, last_step_at) WHERE status = 'OPEN';
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 5. processing_job_step — per-tool-call audit within a job.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS processing_job_step (
|
||||
step_id BIGSERIAL PRIMARY KEY,
|
||||
job_id UUID NOT NULL REFERENCES processing_job(job_id) ON DELETE CASCADE,
|
||||
tool_id VARCHAR(128) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
input_pages INTEGER,
|
||||
input_bytes BIGINT,
|
||||
error_code VARCHAR(64)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_processing_job_step_job
|
||||
ON processing_job_step (job_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 6. job_artifact_hash — per-step input/output content hashes used by the lineage detector.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- content_hash holds "type:value" signature keys; VARCHAR(128) fits SHA-256 and future schemes.
|
||||
CREATE TABLE IF NOT EXISTS job_artifact_hash (
|
||||
job_id UUID NOT NULL REFERENCES processing_job(job_id) ON DELETE CASCADE,
|
||||
content_hash VARCHAR(128) NOT NULL,
|
||||
kind VARCHAR(8) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (job_id, content_hash, kind)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_artifact_hash_lookup
|
||||
ON job_artifact_hash (content_hash, created_at);
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 7. wallet_ledger — append-only signed-amount ledger keyed on team_id.
|
||||
-- amount_units is INTEGER (per-row delta, always small); cap and rollup columns are BIGINT
|
||||
-- because they accumulate across a billing period.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS wallet_ledger (
|
||||
entry_id BIGSERIAL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL REFERENCES teams(team_id) ON DELETE CASCADE,
|
||||
actor_user_id BIGINT,
|
||||
entry_type VARCHAR(32) NOT NULL,
|
||||
bucket VARCHAR(16) NOT NULL,
|
||||
amount_units INTEGER NOT NULL,
|
||||
reference_type VARCHAR(32) NOT NULL,
|
||||
reference_id VARCHAR(128) NOT NULL,
|
||||
policy_id BIGINT,
|
||||
stripe_event_id VARCHAR(128),
|
||||
occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
metadata JSONB
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wallet_ledger_team
|
||||
ON wallet_ledger (team_id, occurred_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wallet_ledger_actor
|
||||
ON wallet_ledger (team_id, actor_user_id, occurred_at) WHERE actor_user_id IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_wallet_ledger_ref
|
||||
ON wallet_ledger (reference_type, reference_id, entry_type, bucket);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_wallet_ledger_stripe_event
|
||||
ON wallet_ledger (stripe_event_id) WHERE stripe_event_id IS NOT NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 8. wallet_policy — per-team charging engine, cap, degradation rules, lineage strategy.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS wallet_policy (
|
||||
policy_id BIGSERIAL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL UNIQUE REFERENCES teams(team_id) ON DELETE CASCADE,
|
||||
engine VARCHAR(16) NOT NULL DEFAULT 'LEGACY',
|
||||
cap_period VARCHAR(16) NOT NULL DEFAULT 'CALENDAR_MONTH',
|
||||
cap_units BIGINT,
|
||||
-- Customer's money intent ("I want $50/month"); the currency comes from the team's Stripe
|
||||
-- customer at recompute time, not stored separately here.
|
||||
cap_source_money BIGINT,
|
||||
warn_at_pct INTEGER NOT NULL DEFAULT 80,
|
||||
degrade_at_pct INTEGER NOT NULL DEFAULT 100,
|
||||
degraded_feature_set VARCHAR(32) NOT NULL DEFAULT 'MINIMAL',
|
||||
auto_group_strategy VARCHAR(16) NOT NULL DEFAULT 'AUTO',
|
||||
notification_emails JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 9. wallet_entitlement_snapshot — hot-path state for the entitlement guard.
|
||||
-- user_id = 0 is the team-wide sentinel (Postgres treats NULL as not-equal-to-NULL in unique
|
||||
-- constraints, so 0 is the cleaner choice for a composite PK).
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS wallet_entitlement_snapshot (
|
||||
team_id BIGINT NOT NULL REFERENCES teams(team_id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL DEFAULT 0,
|
||||
period_start TIMESTAMP NOT NULL,
|
||||
period_end TIMESTAMP NOT NULL,
|
||||
period_spend_units BIGINT NOT NULL DEFAULT 0,
|
||||
period_cap_units BIGINT,
|
||||
state VARCHAR(16) NOT NULL DEFAULT 'FULL',
|
||||
feature_set VARCHAR(32) NOT NULL DEFAULT 'FULL',
|
||||
enabled_gates JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
computed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (team_id, user_id)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 10. payg_shadow_charge — per-job legacy-vs-PAYG diff during PAYG_SHADOW engine mode.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS payg_shadow_charge (
|
||||
shadow_id BIGSERIAL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL REFERENCES teams(team_id) ON DELETE CASCADE,
|
||||
job_id UUID NOT NULL,
|
||||
policy_id BIGINT NOT NULL REFERENCES pricing_policy(policy_id),
|
||||
payg_units INTEGER NOT NULL,
|
||||
legacy_credits_charged INTEGER NOT NULL,
|
||||
diff_pct INTEGER NOT NULL,
|
||||
occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_payg_shadow_team_time
|
||||
ON payg_shadow_charge (team_id, occurred_at);
|
||||
@@ -0,0 +1,60 @@
|
||||
package stirling.software.saas.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.persistence.autoconfigure.EntityScan;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
|
||||
/**
|
||||
* Guards {@link SaasJpaConfig}'s scan paths from drifting out of sync with the actual entity and
|
||||
* repository packages — without this, a missing package goes undetected until a runtime "No
|
||||
* qualifying bean of type" startup failure that Mockito-based tests can't catch.
|
||||
*
|
||||
* <p>Reflection-based rather than a real Spring boot because the production schema uses
|
||||
* Postgres-specific features H2 doesn't fully support.
|
||||
*/
|
||||
class SaasJpaConfigScanTest {
|
||||
|
||||
private static final List<String> EXPECTED_REPO_PACKAGES =
|
||||
List.of(
|
||||
"stirling.software.saas.repository",
|
||||
"stirling.software.saas.billing.repository",
|
||||
"stirling.software.saas.ai.repository",
|
||||
"stirling.software.saas.payg.repository");
|
||||
|
||||
private static final List<String> EXPECTED_ENTITY_PACKAGES =
|
||||
List.of(
|
||||
"stirling.software.saas.model",
|
||||
"stirling.software.saas.billing.model",
|
||||
"stirling.software.saas.ai.model",
|
||||
// Recursive — covers all payg.* sub-packages.
|
||||
"stirling.software.saas.payg");
|
||||
|
||||
@Test
|
||||
void enableJpaRepositoriesIncludesAllExpectedPackages() {
|
||||
EnableJpaRepositories annotation =
|
||||
SaasJpaConfig.class.getAnnotation(EnableJpaRepositories.class);
|
||||
assertThat(annotation).as("SaasJpaConfig must carry @EnableJpaRepositories").isNotNull();
|
||||
|
||||
Set<String> actual = Set.copyOf(Arrays.asList(annotation.basePackages()));
|
||||
assertThat(actual)
|
||||
.as("Every package holding @Repository interfaces must be listed")
|
||||
.containsAll(EXPECTED_REPO_PACKAGES);
|
||||
}
|
||||
|
||||
@Test
|
||||
void entityScanIncludesAllExpectedPackages() {
|
||||
EntityScan annotation = SaasJpaConfig.class.getAnnotation(EntityScan.class);
|
||||
assertThat(annotation).as("SaasJpaConfig must carry @EntityScan").isNotNull();
|
||||
|
||||
Set<String> actual = Set.copyOf(Arrays.asList(annotation.value()));
|
||||
assertThat(actual)
|
||||
.as("Every package holding @Entity classes must be listed")
|
||||
.containsAll(EXPECTED_ENTITY_PACKAGES);
|
||||
}
|
||||
}
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
package stirling.software.saas.payg.docs;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.encryption.AccessPermission;
|
||||
import org.apache.pdfbox.pdmodel.encryption.StandardProtectionPolicy;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.TempFileRegistry;
|
||||
import stirling.software.saas.payg.policy.PricingPolicy;
|
||||
|
||||
class DefaultDocumentClassifierTest {
|
||||
|
||||
/** Same shape as the V1 default we'd seed in pricing_policy. */
|
||||
private static final PricingPolicy DEFAULT_POLICY =
|
||||
new PricingPolicy(
|
||||
/* docPagesPerUnit= */ 25,
|
||||
/* docBytesPerUnit= */ 10L * 1024 * 1024,
|
||||
/* minChargeUnits= */ 1,
|
||||
/* fileUnitCap= */ 1000);
|
||||
|
||||
private final DefaultDocumentClassifier classifier =
|
||||
new DefaultDocumentClassifier(buildTempFileManager());
|
||||
|
||||
@Test
|
||||
void singlePagePdf_isOneUnit() throws Exception {
|
||||
MultipartFile pdf = pdf("one.pdf", 1);
|
||||
|
||||
DocumentMetrics metrics = classifier.classify(pdf, DEFAULT_POLICY);
|
||||
|
||||
assertThat(metrics.pages()).isEqualTo(1);
|
||||
assertThat(metrics.docUnits()).isEqualTo(1);
|
||||
assertThat(metrics.contentType()).isEqualTo("application/pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
void multiPagePdf_chargesByPageAxisWhenBytesAreTiny() throws Exception {
|
||||
// 100 pages, well under 10 MiB → page axis dominates. ceil(100 / 25) = 4 units.
|
||||
MultipartFile pdf = pdf("hundred.pdf", 100);
|
||||
|
||||
DocumentMetrics metrics = classifier.classify(pdf, DEFAULT_POLICY);
|
||||
|
||||
assertThat(metrics.pages()).isEqualTo(100);
|
||||
assertThat(metrics.docUnits()).isEqualTo(4);
|
||||
}
|
||||
|
||||
@Test
|
||||
void bytesAxisDominatesWhenFileIsLargeButFewPages() {
|
||||
// Use a KiB-scale unit so the test allocation stays small.
|
||||
PricingPolicy bytesy = new PricingPolicy(25, 10L * 1024, 1, 1000); // 10 KiB per unit
|
||||
// 30 KiB / 10 KiB = 3 units.
|
||||
byte[] payload = new byte[30 * 1024];
|
||||
MultipartFile blob = new MockMultipartFile("file", "scan.tiff", "image/tiff", payload);
|
||||
|
||||
DocumentMetrics metrics = classifier.classify(blob, bytesy);
|
||||
|
||||
assertThat(metrics.pages()).isZero();
|
||||
assertThat(metrics.docUnits()).isEqualTo(3);
|
||||
assertThat(metrics.contentType()).isEqualTo("image/tiff");
|
||||
}
|
||||
|
||||
@Test
|
||||
void singleFileFileUnitCap_clampsExtremelyLargeInputs() {
|
||||
PricingPolicy tightCap = new PricingPolicy(25, 10L * 1024, 1, /* fileUnitCap= */ 10);
|
||||
// 200 KiB → 20 raw units; per-file cap pins to 10.
|
||||
byte[] payload = new byte[200 * 1024];
|
||||
MultipartFile blob =
|
||||
new MockMultipartFile("file", "huge.bin", "application/octet-stream", payload);
|
||||
|
||||
DocumentMetrics metrics = classifier.classify(blob, tightCap);
|
||||
|
||||
assertThat(metrics.docUnits()).isEqualTo(10);
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyFile_chargesTheOneUnitFloor() {
|
||||
MultipartFile empty =
|
||||
new MockMultipartFile("file", "empty.pdf", "application/pdf", new byte[0]);
|
||||
|
||||
DocumentMetrics metrics = classifier.classify(empty, DEFAULT_POLICY);
|
||||
|
||||
assertThat(metrics.bytes()).isZero();
|
||||
assertThat(metrics.docUnits()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedPdf_fallsBackToBytesOnlyClassification() {
|
||||
byte[] junk = "%PDF-not-really-a-pdf-but-claims-to-be".getBytes();
|
||||
MultipartFile bad = new MockMultipartFile("file", "broken.pdf", "application/pdf", junk);
|
||||
|
||||
DocumentMetrics metrics = classifier.classify(bad, DEFAULT_POLICY);
|
||||
|
||||
assertThat(metrics.pages()).isZero();
|
||||
assertThat(metrics.docUnits()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void encryptedPdf_isStillClassifiable() throws Exception {
|
||||
byte[] bytes = encryptedPdfBytes(5, "ownerpwd", "userpwd");
|
||||
MultipartFile encrypted =
|
||||
new MockMultipartFile("file", "secret.pdf", "application/pdf", bytes);
|
||||
|
||||
DocumentMetrics metrics = classifier.classify(encrypted, DEFAULT_POLICY);
|
||||
|
||||
// Page count behaviour on encrypted PDFs varies by reader; the stable property is that
|
||||
// the byte axis still produces a charge.
|
||||
assertThat(metrics.docUnits()).isGreaterThanOrEqualTo(1);
|
||||
assertThat(metrics.bytes()).isEqualTo(bytes.length);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullContentType_defaultsToOctetStream() {
|
||||
MultipartFile noType =
|
||||
new MockMultipartFile(
|
||||
"file", "unknown.dat", /* contentType= */ null, new byte[100]);
|
||||
|
||||
DocumentMetrics metrics = classifier.classify(noType, DEFAULT_POLICY);
|
||||
|
||||
assertThat(metrics.contentType()).isEqualTo("application/octet-stream");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pdfDetectedByExtension_whenContentTypeIsGeneric() throws Exception {
|
||||
byte[] pdfBytes = pdfBytes(50);
|
||||
MultipartFile pdf =
|
||||
new MockMultipartFile("file", "report.pdf", "application/octet-stream", pdfBytes);
|
||||
|
||||
DocumentMetrics metrics = classifier.classify(pdf, DEFAULT_POLICY);
|
||||
|
||||
assertThat(metrics.pages()).isEqualTo(50);
|
||||
}
|
||||
|
||||
@Test
|
||||
void multiFile_aggregatesUnits() throws Exception {
|
||||
// Two 50-page PDFs: each is ceil(50/25) = 2 raw units; total = 4. Group cap of 1000 × 2
|
||||
// doesn't bind.
|
||||
DocumentMetrics metrics =
|
||||
classifier.classify(List.of(pdf("a.pdf", 50), pdf("b.pdf", 50)), DEFAULT_POLICY);
|
||||
|
||||
assertThat(metrics.docUnits()).isEqualTo(4);
|
||||
assertThat(metrics.pages()).isEqualTo(100);
|
||||
}
|
||||
|
||||
@Test
|
||||
void multiFile_groupCapBindsOnSumOfRawUnits() {
|
||||
// Asymmetric file sizes are required to actually exercise the group cap:
|
||||
// File A: 50 raw units (well over fileUnitCap)
|
||||
// File B: 1 raw unit
|
||||
// Raw sum: 51
|
||||
// Group cap = fileUnitCap (25) × file_count (2) = 50
|
||||
//
|
||||
// With a buggy per-file clamp inside the loop: (25, 1) → sum 26.
|
||||
// With the fixed group cap on the raw sum: min(50, 51) = 50.
|
||||
PricingPolicy policy =
|
||||
new PricingPolicy(
|
||||
/* docPagesPerUnit= */ 25,
|
||||
/* docBytesPerUnit= */ 1L * 1024, // 1 KiB per unit
|
||||
/* minChargeUnits= */ 1,
|
||||
/* fileUnitCap= */ 25);
|
||||
|
||||
byte[] big = new byte[50 * 1024]; // 50 KiB → 50 raw units
|
||||
byte[] small = new byte[1 * 1024]; // 1 KiB → 1 raw unit
|
||||
MultipartFile a = new MockMultipartFile("file", "a.bin", "application/octet-stream", big);
|
||||
MultipartFile b = new MockMultipartFile("file", "b.bin", "application/octet-stream", small);
|
||||
|
||||
DocumentMetrics metrics = classifier.classify(List.of(a, b), policy);
|
||||
|
||||
assertThat(metrics.docUnits())
|
||||
.as(
|
||||
"Group cap should clamp the raw sum (51) to fileUnitCap × fileCount (50)."
|
||||
+ " A result of 26 here means per-file clamping has snuck back in"
|
||||
+ " and the group cap is dead.")
|
||||
.isEqualTo(50);
|
||||
}
|
||||
|
||||
@Test
|
||||
void multiFile_emptyListRejected() {
|
||||
assertThatThrownBy(() -> classifier.classify(List.of(), DEFAULT_POLICY))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
// --- Fixture helpers ------------------------------------------------------------------------
|
||||
|
||||
private static MultipartFile pdf(String name, int pages) throws IOException {
|
||||
return new MockMultipartFile("file", name, "application/pdf", pdfBytes(pages));
|
||||
}
|
||||
|
||||
private static byte[] pdfBytes(int pages) throws IOException {
|
||||
try (PDDocument doc = new PDDocument();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
for (int i = 0; i < pages; i++) {
|
||||
doc.addPage(new PDPage());
|
||||
}
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] encryptedPdfBytes(int pages, String ownerPwd, String userPwd)
|
||||
throws IOException {
|
||||
try (PDDocument doc = new PDDocument();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
for (int i = 0; i < pages; i++) {
|
||||
doc.addPage(new PDPage());
|
||||
}
|
||||
doc.protect(new StandardProtectionPolicy(ownerPwd, userPwd, new AccessPermission()));
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a real {@link TempFileManager} backed by the OS temp dir. Cheaper and more
|
||||
* faithful than mocking — the classifier exercises the actual write+read+delete path the way it
|
||||
* would in production.
|
||||
*/
|
||||
private static TempFileManager buildTempFileManager() {
|
||||
return new TempFileManager(new TempFileRegistry(), new ApplicationProperties());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.saas.payg.entitlement.WalletEntitlementSnapshot;
|
||||
import stirling.software.saas.payg.entitlement.WalletEntitlementSnapshot.WalletEntitlementSnapshotId;
|
||||
import stirling.software.saas.payg.job.JobArtifactHash;
|
||||
import stirling.software.saas.payg.job.JobArtifactHash.JobArtifactHashId;
|
||||
import stirling.software.saas.payg.job.ProcessingJob;
|
||||
import stirling.software.saas.payg.job.ProcessingJobStep;
|
||||
import stirling.software.saas.payg.policy.PricingPolicy;
|
||||
import stirling.software.saas.payg.shadow.PaygShadowCharge;
|
||||
import stirling.software.saas.payg.wallet.WalletLedgerEntry;
|
||||
import stirling.software.saas.payg.wallet.WalletPolicy;
|
||||
|
||||
/**
|
||||
* Boots each PAYG entity via the no-arg constructor that JPA requires, exercises a few getter /
|
||||
* setter pairs, and confirms composite-key equality where applicable. Catches Lombok / annotation
|
||||
* regressions without needing a database.
|
||||
*/
|
||||
class PaygEntitiesSmokeTest {
|
||||
|
||||
@Test
|
||||
void pricingPolicy_instantiatesAndRoundTripsFields() {
|
||||
PricingPolicy p = new PricingPolicy();
|
||||
p.setVersion("v1-2026-06");
|
||||
p.setDocPagesPerUnit(25);
|
||||
p.setDocBytesPerUnit(10L * 1024 * 1024);
|
||||
p.setStepLimits(Map.of(JobSource.WEB, 10, JobSource.API, 20));
|
||||
p.setStripePriceIds(Set.of("price_abc", "price_def"));
|
||||
|
||||
assertThat(p.getVersion()).isEqualTo("v1-2026-06");
|
||||
assertThat(p.getStepLimits())
|
||||
.containsEntry(JobSource.WEB, 10)
|
||||
.containsEntry(JobSource.API, 20)
|
||||
.hasSize(2);
|
||||
assertThat(p.getStripePriceIds()).containsExactlyInAnyOrder("price_abc", "price_def");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pricingPolicy_convenienceCtorValidates() {
|
||||
// Existing classifier callsite uses this ctor — verify the validation it carries from the
|
||||
// previous record stays in place.
|
||||
PricingPolicy p = new PricingPolicy(25, 10L * 1024 * 1024, 1, 1000);
|
||||
assertThat(p.getDocPagesPerUnit()).isEqualTo(25);
|
||||
assertThat(p.getFileUnitCap()).isEqualTo(1000);
|
||||
}
|
||||
|
||||
@Test
|
||||
void processingJob_acceptsAllStatuses() {
|
||||
ProcessingJob job = new ProcessingJob();
|
||||
job.setId(UUID.randomUUID());
|
||||
job.setOwnerUserId(42L);
|
||||
job.setProcessType(ProcessType.CHAIN);
|
||||
job.setSource(JobSource.WEB);
|
||||
job.setStatus(JobStatus.OPEN);
|
||||
job.setStartedAt(LocalDateTime.now());
|
||||
job.setLastStepAt(LocalDateTime.now());
|
||||
|
||||
assertThat(job.getProcessType()).isEqualTo(ProcessType.CHAIN);
|
||||
assertThat(job.getStatus()).isEqualTo(JobStatus.OPEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void processingJobStep_isInstantiable() {
|
||||
ProcessingJobStep step = new ProcessingJobStep();
|
||||
step.setJobId(UUID.randomUUID());
|
||||
step.setToolId("/api/v1/general/compress");
|
||||
step.setStatus(JobStepStatus.OK);
|
||||
|
||||
assertThat(step.getStatus()).isEqualTo(JobStepStatus.OK);
|
||||
}
|
||||
|
||||
@Test
|
||||
void jobArtifactHash_compositeIdEqualityHolds() {
|
||||
UUID jobId = UUID.randomUUID();
|
||||
JobArtifactHashId a = new JobArtifactHashId(jobId, "abc123", ArtifactKind.INPUT);
|
||||
JobArtifactHashId b = new JobArtifactHashId(jobId, "abc123", ArtifactKind.INPUT);
|
||||
JobArtifactHashId different = new JobArtifactHashId(jobId, "abc123", ArtifactKind.OUTPUT);
|
||||
|
||||
assertThat(a).isEqualTo(b).hasSameHashCodeAs(b);
|
||||
assertThat(a).isNotEqualTo(different);
|
||||
|
||||
JobArtifactHash row = new JobArtifactHash();
|
||||
row.setId(a);
|
||||
assertThat(row.getId().getKind()).isEqualTo(ArtifactKind.INPUT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void walletLedgerEntry_signedAmountAllowed() {
|
||||
WalletLedgerEntry entry = new WalletLedgerEntry();
|
||||
entry.setTeamId(7L);
|
||||
entry.setEntryType(LedgerEntryType.DEBIT);
|
||||
entry.setBucket(LedgerBucket.CYCLE);
|
||||
entry.setAmountUnits(-4);
|
||||
entry.setReferenceType(ReferenceType.JOB);
|
||||
entry.setReferenceId("job:abc");
|
||||
|
||||
assertThat(entry.getAmountUnits()).isEqualTo(-4);
|
||||
}
|
||||
|
||||
@Test
|
||||
void walletPolicy_carriesSensibleDefaults() {
|
||||
WalletPolicy policy = new WalletPolicy();
|
||||
|
||||
assertThat(policy.getEngine()).isEqualTo(WalletEngine.LEGACY);
|
||||
assertThat(policy.getCapPeriod()).isEqualTo(CapPeriod.CALENDAR_MONTH);
|
||||
assertThat(policy.getWarnAtPct()).isEqualTo(80);
|
||||
assertThat(policy.getDegradeAtPct()).isEqualTo(100);
|
||||
assertThat(policy.getDegradedFeatureSet()).isEqualTo(FeatureSet.MINIMAL);
|
||||
assertThat(policy.getAutoGroupStrategy()).isEqualTo(AutoGroupStrategy.AUTO);
|
||||
}
|
||||
|
||||
@Test
|
||||
void walletEntitlementSnapshot_compositeIdHandlesTeamWideSentinel() {
|
||||
WalletEntitlementSnapshotId teamWide =
|
||||
new WalletEntitlementSnapshotId(7L, WalletEntitlementSnapshot.TEAM_WIDE_USER_ID);
|
||||
WalletEntitlementSnapshotId memberA = new WalletEntitlementSnapshotId(7L, 42L);
|
||||
|
||||
assertThat(teamWide).isNotEqualTo(memberA);
|
||||
assertThat(teamWide.getUserId()).isZero();
|
||||
|
||||
WalletEntitlementSnapshot snap = new WalletEntitlementSnapshot();
|
||||
snap.setId(teamWide);
|
||||
snap.setEnabledGates(List.of(FeatureGate.OFFSITE_PROCESSING, FeatureGate.AUTOMATION));
|
||||
|
||||
assertThat(snap.getState()).isEqualTo(EntitlementState.FULL);
|
||||
assertThat(snap.getEnabledGates()).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void paygShadowCharge_isInstantiable() {
|
||||
PaygShadowCharge row = new PaygShadowCharge();
|
||||
row.setTeamId(7L);
|
||||
row.setJobId(UUID.randomUUID());
|
||||
row.setPolicyId(1L);
|
||||
row.setPaygUnits(4);
|
||||
row.setLegacyCreditsCharged(20);
|
||||
row.setDiffPct(-80);
|
||||
|
||||
assertThat(row.getDiffPct()).isNegative();
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package stirling.software.saas.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
/**
|
||||
* Pins the contract {@code CreditService.scheduleStripeReportAfterCommit} relies on: a {@link
|
||||
* TransactionSynchronization#afterCommit()} hook fires after a successful commit and never on
|
||||
* rollback.
|
||||
*/
|
||||
class StripeAfterCommitOrderingTest {
|
||||
|
||||
@AfterEach
|
||||
void clearSynchronization() {
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void afterCommitRunsAfterCommit_notDuringTransaction() {
|
||||
List<String> order = new ArrayList<>();
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
try {
|
||||
order.add("inside-tx-before-register");
|
||||
TransactionSynchronizationManager.registerSynchronization(
|
||||
new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
order.add("after-commit-hook");
|
||||
}
|
||||
});
|
||||
order.add("inside-tx-after-register");
|
||||
|
||||
// Simulate commit by firing afterCommit on every registered synchronization.
|
||||
order.add("commit-triggered");
|
||||
for (TransactionSynchronization s :
|
||||
TransactionSynchronizationManager.getSynchronizations()) {
|
||||
s.afterCommit();
|
||||
}
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
|
||||
assertThat(order)
|
||||
.containsExactly(
|
||||
"inside-tx-before-register",
|
||||
"inside-tx-after-register",
|
||||
"commit-triggered",
|
||||
"after-commit-hook");
|
||||
}
|
||||
|
||||
@Test
|
||||
void afterCommitDoesNotRun_onRollback() {
|
||||
List<String> order = new ArrayList<>();
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
try {
|
||||
TransactionSynchronizationManager.registerSynchronization(
|
||||
new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
order.add("after-commit-hook-MUST-NOT-FIRE");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterCompletion(int status) {
|
||||
if (status == TransactionSynchronization.STATUS_ROLLED_BACK) {
|
||||
order.add("after-completion-rollback");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Simulate rollback: afterCompletion fires, afterCommit must not.
|
||||
for (TransactionSynchronization s :
|
||||
TransactionSynchronizationManager.getSynchronizations()) {
|
||||
s.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK);
|
||||
}
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
|
||||
assertThat(order)
|
||||
.containsExactly("after-completion-rollback")
|
||||
.doesNotContain("after-commit-hook-MUST-NOT-FIRE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void isSynchronizationActive_reflectsSpringTransactionalContext() {
|
||||
assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse();
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
try {
|
||||
assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isTrue();
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
|
||||
assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse();
|
||||
}
|
||||
}
|
||||
-132
@@ -1,132 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package stirling.software.saas.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import stirling.software.saas.billing.service.StripeUsageReportingService;
|
||||
import stirling.software.saas.config.SupabaseConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Pins the Stripe meter-event idempotency key as a deterministic function of (Supabase user,
|
||||
* overage amount, request id). Stripe collapses duplicates by this key, so a regression here means
|
||||
* customers get double-billed on a retry.
|
||||
*/
|
||||
class StripeUsageIdempotencyKeyTest {
|
||||
|
||||
private final StripeUsageReportingService service =
|
||||
new StripeUsageReportingService(Mockito.mock(SupabaseConfigurationProperties.class));
|
||||
|
||||
@Test
|
||||
void sameInputs_produceSameKey() {
|
||||
String first = service.generateIdempotencyKey("user-123", 10, "req-abc");
|
||||
String second = service.generateIdempotencyKey("user-123", 10, "req-abc");
|
||||
|
||||
assertThat(first)
|
||||
.as("Idempotency key must be stable across calls with identical inputs.")
|
||||
.isEqualTo(second);
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentRequestIds_produceDifferentKeys() {
|
||||
String reqA = service.generateIdempotencyKey("user-123", 10, "req-abc");
|
||||
String reqB = service.generateIdempotencyKey("user-123", 10, "req-xyz");
|
||||
|
||||
assertThat(reqA).isNotEqualTo(reqB);
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentOverageAmounts_produceDifferentKeys() {
|
||||
String tenCredits = service.generateIdempotencyKey("user-123", 10, "req-abc");
|
||||
String elevenCredits = service.generateIdempotencyKey("user-123", 11, "req-abc");
|
||||
|
||||
assertThat(tenCredits).isNotEqualTo(elevenCredits);
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentUsers_produceDifferentKeys() {
|
||||
String alice = service.generateIdempotencyKey("user-alice", 10, "req-abc");
|
||||
String bob = service.generateIdempotencyKey("user-bob", 10, "req-abc");
|
||||
|
||||
assertThat(alice).isNotEqualTo(bob);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keyShapeIncludesAllThreeDimensions() {
|
||||
// Format: usage_{supabaseId}_{credits}_{operationId}
|
||||
String key = service.generateIdempotencyKey("user-123", 42, "req-abc");
|
||||
|
||||
assertThat(key).contains("user-123").contains("42").contains("req-abc");
|
||||
}
|
||||
}
|
||||
@@ -2684,6 +2684,59 @@ title = "Change Permissions"
|
||||
[changePermissions.tooltip.warning]
|
||||
text = "To make these permissions unchangeable, use the Add Password tool to set an owner password."
|
||||
|
||||
[agents]
|
||||
section_title = "Agents"
|
||||
fullscreen_title = "Stirling Agents"
|
||||
stirling_name = "Stirling"
|
||||
stirling_full_name = "Stirling General Agent"
|
||||
stirling_tooltip = "Stirling agent"
|
||||
stirling_description = "Your general-purpose PDF assistant"
|
||||
stirling_long_description = "General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents."
|
||||
back_to_tools = "Back to tools"
|
||||
coming_soon = "Coming soon"
|
||||
view_all = "View all agents"
|
||||
show_less = "Show less"
|
||||
start_chat = "Start chatting"
|
||||
data_extraction_name = "Data Extraction"
|
||||
data_extraction_description = "Extract tables & structured data"
|
||||
doc_summary_name = "Summariser"
|
||||
doc_summary_description = "Summarise long documents"
|
||||
auto_redaction_name = "Auto Redaction"
|
||||
auto_redaction_description = "Redact PII automatically"
|
||||
compliance_name = "Compliance Check"
|
||||
compliance_description = "Audit documents for compliance"
|
||||
form_filler_name = "Form Filler"
|
||||
form_filler_description = "Fill PDF forms intelligently"
|
||||
pdf_to_markdown_name = "PDF to Markdown"
|
||||
pdf_to_markdown_description = "Convert PDFs to clean Markdown"
|
||||
|
||||
[chat.header]
|
||||
settings = "Agent settings"
|
||||
agentMenu = "Stirling agent options"
|
||||
clearChat = "Clear chat"
|
||||
|
||||
[chat.input]
|
||||
placeholder = "What do you want to do?"
|
||||
send = "Send message"
|
||||
attach = "Attach files"
|
||||
|
||||
[chat.quickActions]
|
||||
heading = "Get started"
|
||||
openFromComputer = "Open from computer"
|
||||
browseYourFiles = "Browse your files"
|
||||
rotateOne = "Rotate this document"
|
||||
rotateMany = "Rotate these documents"
|
||||
compressOne = "Compress this document"
|
||||
compressMany = "Compress these documents"
|
||||
mergeMany = "Merge these {{count}} documents into 1"
|
||||
splitOne = "Split this document"
|
||||
convertOne = "Convert this document to PDF"
|
||||
convertMany = "Convert these documents to PDF"
|
||||
fileSummary_one = "1 file in workbench ({{types}})"
|
||||
fileSummary_other = "{{count}} files in workbench ({{types}})"
|
||||
moreFiles = "+{{count}} more"
|
||||
removeFile = "Remove {{name}}"
|
||||
|
||||
[chat.progress]
|
||||
analyzing = "Analysing your request..."
|
||||
calling_engine = "AI is thinking..."
|
||||
@@ -2699,6 +2752,15 @@ whole_doc_read_done = "Finished reading the document..."
|
||||
whole_doc_read_started = "Reading the document..."
|
||||
whole_doc_slice_done = "Reading the document... ({{percent}}% complete)"
|
||||
|
||||
[chat.responses]
|
||||
done = "Done."
|
||||
need_clarification = "Could you clarify your request?"
|
||||
cannot_do = "I'm unable to do that."
|
||||
not_found = "I couldn't find the requested information."
|
||||
unsupported_capability = "Unsupported capability: {{capability}}"
|
||||
cannot_continue = "Something went wrong and I can't continue."
|
||||
processing = "Processing ({{outcome}})..."
|
||||
|
||||
[chat.toolsUsed]
|
||||
summary = "Ran {{count}} tools"
|
||||
summary_one = "Ran 1 tool"
|
||||
@@ -8072,6 +8134,11 @@ expand = "Expand panel"
|
||||
placeholder = "Choose a tool to get started"
|
||||
premiumFeature = "Premium feature:"
|
||||
search = "Search tools"
|
||||
toolsHeader = "Tools"
|
||||
viewAllTools = "View all tools"
|
||||
backToDefault = "Back"
|
||||
backToAllTools = "Back to all tools"
|
||||
goBack = "Go back"
|
||||
|
||||
[toolPanel.fullscreen]
|
||||
comingSoon = "Coming soon:"
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Core stubs for the right-rail Agents UI.
|
||||
*
|
||||
* The real implementations live in {@code proprietary/components/agents/AgentsPanel.tsx}
|
||||
* and shadow these stubs via the {@code @app/*} alias cascade when the proprietary
|
||||
* build is active. Core builds render nothing, so the right rail collapses to the
|
||||
* tool list unchanged.
|
||||
*/
|
||||
|
||||
/** Whether the right rail should reserve space for agents UI. False in core. */
|
||||
export function useAgentsEnabled(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the agent chat overlay is currently open. Core builds have no chat,
|
||||
* so this always returns false. Proprietary builds bridge to the ChatContext.
|
||||
* Used by {@code RightSidebar} so the fullscreen tool picker can yield to the
|
||||
* chat overlay just like it yields to a selected tool.
|
||||
*/
|
||||
export function useAgentChatOpen(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Inline "Agents" section rendered above the tool list in {@code ToolPicker}. */
|
||||
export function AgentsSection() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon-only agent button rendered in the collapsed (minimised) right rail.
|
||||
* Returns null in core; proprietary renders the Stirling agent shortcut.
|
||||
*/
|
||||
export function AgentsCollapsedButton(_props: { onExpand: () => void }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-rail chat overlay rendered inside {@code ToolPanel}. Covers the panel
|
||||
* (including the search bar) when an agent conversation is active.
|
||||
*/
|
||||
export function AgentsChatOverlay() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents card rendered inside the fullscreen tool picker. Matches the visual
|
||||
* language of the fullscreen category cards (gradient border, title, items).
|
||||
* Returns null in core; proprietary renders the Stirling agent.
|
||||
*/
|
||||
export function AgentsFullscreenSection() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import { usePreferences } from "@app/contexts/PreferencesContext";
|
||||
import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext";
|
||||
import {
|
||||
AgentsFullscreenSection,
|
||||
useAgentChatOpen,
|
||||
useAgentsEnabled,
|
||||
} from "@app/components/agents/AgentsPanel";
|
||||
import FullscreenToolSurface from "@app/components/tools/FullscreenToolSurface";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import type { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
|
||||
|
||||
/** Derives whether the fullscreen tool picker is currently expanded. */
|
||||
export function useIsFullscreenExpanded(): boolean {
|
||||
const { toolPanelMode, leftPanelView, readerMode } = useToolWorkflow();
|
||||
const isMobile = useIsMobile();
|
||||
const agentChatOpen = useAgentChatOpen();
|
||||
return (
|
||||
toolPanelMode === "fullscreen" &&
|
||||
leftPanelView === "toolPicker" &&
|
||||
!isMobile &&
|
||||
!readerMode &&
|
||||
!agentChatOpen
|
||||
);
|
||||
}
|
||||
|
||||
interface FullscreenToolPanelProps {
|
||||
geometry: ToolPanelGeometry | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-contained fullscreen tool picker. Renders null when inactive, and takes
|
||||
* over the right rail (via FullscreenToolSurface) when fullscreen mode is on.
|
||||
* Geometry is computed by the parent (RightSidebar) so its useLayoutEffect runs
|
||||
* after the ref div is committed, ensuring toolPanelRef.current is always set.
|
||||
*/
|
||||
export function FullscreenToolPanel({ geometry }: FullscreenToolPanelProps) {
|
||||
const {
|
||||
toolPanelMode,
|
||||
setToolPanelMode,
|
||||
leftPanelView,
|
||||
readerMode,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
filteredTools,
|
||||
toolRegistry,
|
||||
selectedToolKey,
|
||||
handleToolSelect,
|
||||
} = useToolWorkflow();
|
||||
const isMobile = useIsMobile();
|
||||
const agentsEnabled = useAgentsEnabled();
|
||||
const agentChatOpen = useAgentChatOpen();
|
||||
const { setAllButtonsDisabled } = useWorkbenchBar();
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
|
||||
const fullscreenExpanded =
|
||||
toolPanelMode === "fullscreen" &&
|
||||
leftPanelView === "toolPicker" &&
|
||||
!isMobile &&
|
||||
!readerMode &&
|
||||
!agentChatOpen;
|
||||
|
||||
useEffect(() => {
|
||||
setAllButtonsDisabled(fullscreenExpanded);
|
||||
}, [fullscreenExpanded, setAllButtonsDisabled]);
|
||||
|
||||
const matchedTextMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
filteredTools.forEach(({ item: [id], matchedText }) => {
|
||||
if (matchedText) map.set(id, matchedText);
|
||||
});
|
||||
return map;
|
||||
}, [filteredTools]);
|
||||
|
||||
if (!fullscreenExpanded) return null;
|
||||
|
||||
return (
|
||||
<FullscreenToolSurface
|
||||
searchQuery={searchQuery}
|
||||
toolRegistry={toolRegistry}
|
||||
filteredTools={filteredTools}
|
||||
selectedToolKey={selectedToolKey}
|
||||
showDescriptions={preferences.showLegacyToolDescriptions}
|
||||
matchedTextMap={matchedTextMap}
|
||||
onSearchChange={setSearchQuery}
|
||||
onSelect={(id: ToolId) => handleToolSelect(id)}
|
||||
onToggleDescriptions={() =>
|
||||
updatePreference(
|
||||
"showLegacyToolDescriptions",
|
||||
!preferences.showLegacyToolDescriptions,
|
||||
)
|
||||
}
|
||||
onExitFullscreenMode={() => setToolPanelMode("sidebar")}
|
||||
geometry={geometry}
|
||||
agentsSlot={
|
||||
agentsEnabled && !searchQuery ? <AgentsFullscreenSection /> : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ScrollArea, Switch } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
|
||||
@@ -6,8 +7,6 @@ import FullscreenToolList from "@app/components/tools/FullscreenToolList";
|
||||
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import { useFocusTrap } from "@app/hooks/useFocusTrap";
|
||||
import { LogoIcon } from "@app/components/shared/LogoIcon";
|
||||
import { Wordmark } from "@app/components/shared/Wordmark";
|
||||
import "@app/components/tools/ToolPanel.css";
|
||||
import { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
|
||||
|
||||
@@ -26,6 +25,8 @@ interface FullscreenToolSurfaceProps {
|
||||
onToggleDescriptions: () => void;
|
||||
onExitFullscreenMode: () => void;
|
||||
geometry: ToolPanelGeometry | null;
|
||||
/** Optional agents block rendered above the tool list. */
|
||||
agentsSlot?: React.ReactNode;
|
||||
}
|
||||
|
||||
const FullscreenToolSurface = ({
|
||||
@@ -40,6 +41,7 @@ const FullscreenToolSurface = ({
|
||||
onToggleDescriptions,
|
||||
onExitFullscreenMode: _onExitFullscreenMode,
|
||||
geometry,
|
||||
agentsSlot,
|
||||
}: FullscreenToolSurfaceProps) => {
|
||||
const { t } = useTranslation();
|
||||
const surfaceRef = useRef<HTMLDivElement>(null);
|
||||
@@ -47,18 +49,16 @@ const FullscreenToolSurface = ({
|
||||
// Enable focus trap when surface is active
|
||||
useFocusTrap(surfaceRef, true);
|
||||
|
||||
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
|
||||
if (!geometry) return null;
|
||||
|
||||
const style = geometry
|
||||
? {
|
||||
left: `${geometry.left}px`,
|
||||
top: `${geometry.top}px`,
|
||||
width: `${geometry.width}px`,
|
||||
height: `${geometry.height}px`,
|
||||
}
|
||||
: undefined;
|
||||
const style = {
|
||||
left: `${geometry.left}px`,
|
||||
top: `${geometry.top}px`,
|
||||
width: `${geometry.width}px`,
|
||||
height: `${geometry.height}px`,
|
||||
};
|
||||
|
||||
return (
|
||||
const surface = (
|
||||
<div
|
||||
className="tool-panel__fullscreen-surface"
|
||||
style={style}
|
||||
@@ -70,16 +70,6 @@ const FullscreenToolSurface = ({
|
||||
data-tour="tool-panel"
|
||||
>
|
||||
<div ref={surfaceRef} className="tool-panel__fullscreen-surface-inner">
|
||||
<header className="tool-panel__fullscreen-header">
|
||||
<div className="tool-panel__fullscreen-brand">
|
||||
<LogoIcon className="tool-panel__fullscreen-brand-icon" />
|
||||
<Wordmark
|
||||
alt={brandAltText}
|
||||
className="tool-panel__fullscreen-brand-text"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="tool-panel__fullscreen-controls">
|
||||
<ToolSearch
|
||||
value={searchQuery}
|
||||
@@ -102,6 +92,9 @@ const FullscreenToolSurface = ({
|
||||
className="tool-panel__fullscreen-scroll"
|
||||
offsetScrollbars
|
||||
>
|
||||
{agentsSlot && (
|
||||
<div className="tool-panel__fullscreen-agents">{agentsSlot}</div>
|
||||
)}
|
||||
<FullscreenToolList
|
||||
filteredTools={filteredTools}
|
||||
searchQuery={searchQuery}
|
||||
@@ -115,6 +108,9 @@ const FullscreenToolSurface = ({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (typeof document === "undefined") return surface;
|
||||
return createPortal(surface, document.body);
|
||||
};
|
||||
|
||||
export default FullscreenToolSurface;
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useSidebarContext } from "@app/contexts/SidebarContext";
|
||||
import rainbowStyles from "@app/styles/rainbow.module.css";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import ToolPanel from "@app/components/tools/ToolPanel";
|
||||
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
|
||||
import {
|
||||
AgentsChatOverlay,
|
||||
AgentsCollapsedButton,
|
||||
AgentsSection,
|
||||
useAgentsEnabled,
|
||||
} from "@app/components/agents/AgentsPanel";
|
||||
import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
|
||||
import { useToolSections } from "@app/hooks/useToolSections";
|
||||
import type { SubcategoryGroup } from "@app/hooks/useToolSections";
|
||||
import { ToolIcon } from "@app/components/shared/ToolIcon";
|
||||
import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip";
|
||||
import { withViewTransition } from "@app/utils/viewTransition";
|
||||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import type { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
|
||||
import {
|
||||
FullscreenToolPanel,
|
||||
useIsFullscreenExpanded,
|
||||
} from "@app/components/tools/FullscreenToolPanel";
|
||||
import { useToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
|
||||
import "@app/components/tools/ToolPanel.css";
|
||||
|
||||
/**
|
||||
* Right-side rail wrapping the tool panel.
|
||||
*
|
||||
* Owns the rail-level concerns: collapse/expand chrome, the AGENTS top label,
|
||||
* the collapsed strip (agent button + favourite/recommended icon shortcuts),
|
||||
* and the agents chat overlay. Fullscreen takeover lives in FullscreenToolPanel.
|
||||
*/
|
||||
export default function RightSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
const { toolPanelRef, quickAccessRef } = sidebarRefs;
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const {
|
||||
leftPanelView,
|
||||
isPanelVisible,
|
||||
searchQuery,
|
||||
filteredTools,
|
||||
toolRegistry,
|
||||
setSearchQuery,
|
||||
selectedToolKey,
|
||||
handleToolSelect,
|
||||
handleBackToTools,
|
||||
setLeftPanelView,
|
||||
setReaderMode,
|
||||
setSidebarsVisible,
|
||||
sidebarsVisible,
|
||||
readerMode,
|
||||
favoriteTools,
|
||||
} = useToolWorkflow();
|
||||
|
||||
const agentsEnabled = useAgentsEnabled();
|
||||
const fullscreenExpanded = useIsFullscreenExpanded();
|
||||
const fullscreenGeometry = useToolPanelGeometry({
|
||||
enabled: fullscreenExpanded,
|
||||
toolPanelRef,
|
||||
quickAccessRef,
|
||||
});
|
||||
|
||||
const handleExpand = () => {
|
||||
withViewTransition(() => {
|
||||
if (readerMode) setReaderMode(false);
|
||||
if (leftPanelView === "hidden") setLeftPanelView("toolPicker");
|
||||
if (!sidebarsVisible) setSidebarsVisible(true);
|
||||
});
|
||||
};
|
||||
|
||||
const handleCollapse = () => {
|
||||
withViewTransition(() => setLeftPanelView("hidden"));
|
||||
};
|
||||
|
||||
const [allToolsView, setAllToolsView] = useState(false);
|
||||
|
||||
const handleShowAllTools = () => {
|
||||
withViewTransition(() => setAllToolsView(true));
|
||||
};
|
||||
|
||||
const handleBackToDefault = () => {
|
||||
withViewTransition(() => {
|
||||
setAllToolsView(false);
|
||||
setSearchQuery("");
|
||||
});
|
||||
};
|
||||
|
||||
// The header shows [back] [search] when we have somewhere to go back to —
|
||||
// i.e. the user is in a specific tool, or already in the all-tools/search view.
|
||||
const inToolView = leftPanelView !== "toolPicker";
|
||||
// Show X (close) button only when there's somewhere to go back to.
|
||||
const showCloseButton = inToolView || allToolsView;
|
||||
// Show search input whenever there's a close button, or when agents are off and
|
||||
// we're in the default tool-picker view (search filters the full list inline).
|
||||
const showHeaderSearch =
|
||||
showCloseButton || (!agentsEnabled && leftPanelView === "toolPicker");
|
||||
|
||||
const handleHeaderBack = () => {
|
||||
if (inToolView) {
|
||||
withViewTransition(() => handleBackToTools());
|
||||
} else {
|
||||
handleBackToDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const handleToolSelectWithTransition = (id: ToolId) => {
|
||||
withViewTransition(() => handleToolSelect(id));
|
||||
};
|
||||
|
||||
// Typing in the header search while inside a tool exits the tool and lifts the
|
||||
// panel into the all-tools view so the user immediately sees search results.
|
||||
const handleHeaderSearchChange = (value: string) => {
|
||||
if (inToolView) {
|
||||
withViewTransition(() => {
|
||||
handleBackToTools();
|
||||
setAllToolsView(true);
|
||||
setSearchQuery(value);
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSearchQuery(value);
|
||||
};
|
||||
|
||||
const activeTool: ToolRegistryEntry | null =
|
||||
inToolView && selectedToolKey
|
||||
? (toolRegistry[selectedToolKey as ToolId] ?? null)
|
||||
: null;
|
||||
|
||||
// Agents header + section is hidden when:
|
||||
// - the AI engine is off, or
|
||||
// - the user is in the all-tools view, or
|
||||
// - a specific tool is being rendered (leftPanelView ≠ "toolPicker").
|
||||
const showAgents =
|
||||
agentsEnabled && !allToolsView && leftPanelView === "toolPicker";
|
||||
|
||||
const computedWidth = () => {
|
||||
if (isMobile) return "100%";
|
||||
if (!isPanelVisible) return "3.5rem";
|
||||
return "18.5rem";
|
||||
};
|
||||
|
||||
// Collapsed rail: show favourites + recommended tools as icons.
|
||||
const favoriteToolItems = useFavoriteToolItems(favoriteTools, toolRegistry);
|
||||
const { sections: collapsedSections } = useToolSections(filteredTools);
|
||||
const collapsedQuickSection = useMemo(
|
||||
() => collapsedSections.find((s) => s.key === "quick"),
|
||||
[collapsedSections],
|
||||
);
|
||||
const collapsedRecommendedItems = useMemo(() => {
|
||||
if (!collapsedQuickSection) return [];
|
||||
const items: Array<{ id: ToolId; tool: ToolRegistryEntry }> = [];
|
||||
collapsedQuickSection.subcategories.forEach((sc: SubcategoryGroup) =>
|
||||
sc.tools.forEach((entry) =>
|
||||
items.push({ id: entry.id as ToolId, tool: entry.tool }),
|
||||
),
|
||||
);
|
||||
return items;
|
||||
}, [collapsedQuickSection]);
|
||||
const collapsedRailItems = useMemo(() => {
|
||||
const map = new Map<ToolId, ToolRegistryEntry>();
|
||||
favoriteToolItems.forEach(({ id, tool }) => map.set(id, tool));
|
||||
collapsedRecommendedItems.forEach(({ id, tool }) => {
|
||||
if (!map.has(id)) map.set(id, tool);
|
||||
});
|
||||
return Array.from(map, ([id, tool]) => ({ id, tool }));
|
||||
}, [favoriteToolItems, collapsedRecommendedItems]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={toolPanelRef}
|
||||
data-sidebar="tool-panel"
|
||||
data-tour={fullscreenExpanded ? undefined : "tool-panel"}
|
||||
className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-l border-[var(--border-subtle)] transition-all duration-300 ease-out ${
|
||||
isRainbowMode ? rainbowStyles.rainbowPaper : ""
|
||||
} ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`}
|
||||
style={{
|
||||
width: computedWidth(),
|
||||
padding: "0",
|
||||
}}
|
||||
>
|
||||
{!fullscreenExpanded && !isPanelVisible && !isMobile && (
|
||||
<div className="tool-panel__collapsed-strip">
|
||||
<div className="tool-panel__collapsed-top">
|
||||
<ActionIcon
|
||||
variant="outline"
|
||||
color="gray.4"
|
||||
radius="xl"
|
||||
size="md"
|
||||
className="tool-panel__expand-btn"
|
||||
onClick={handleExpand}
|
||||
aria-label={t("toolPanel.expand", "Expand panel")}
|
||||
>
|
||||
<ChevronLeftIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
<AgentsCollapsedButton onExpand={handleExpand} />
|
||||
</div>
|
||||
<div className="tool-panel__collapsed-divider" />
|
||||
<div className="tool-panel__collapsed-tools">
|
||||
{collapsedRailItems.map(({ id, tool }) => (
|
||||
<AppTooltip
|
||||
key={id}
|
||||
content={tool.name}
|
||||
position="left"
|
||||
arrow
|
||||
delay={300}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="tool-panel__collapsed-tool-btn"
|
||||
data-selected={selectedToolKey === id}
|
||||
onClick={() => {
|
||||
handleExpand();
|
||||
handleToolSelectWithTransition(id);
|
||||
}}
|
||||
aria-label={tool.name}
|
||||
>
|
||||
<ToolIcon icon={tool.icon} marginRight="0" />
|
||||
</button>
|
||||
</AppTooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fullscreenExpanded && isPanelVisible && (
|
||||
<div
|
||||
/* Fixed width matches the expanded panel width so the inner content is
|
||||
laid out at its final size from the moment it mounts. The outer
|
||||
.tool-panel clips it (overflow-hidden) while it animates from the
|
||||
collapsed 3.5rem width — text/icons stay put and just come into view
|
||||
instead of jiggling as space becomes available. */
|
||||
style={{
|
||||
opacity: 1,
|
||||
transition: "opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
|
||||
height: "100%",
|
||||
width: isMobile ? "100%" : "18.5rem",
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div className="tool-panel__compact-header">
|
||||
{activeTool ? (
|
||||
<div
|
||||
className="tool-panel__active-tool-pill"
|
||||
aria-label={activeTool.name}
|
||||
>
|
||||
<span className="tool-panel__active-tool-pill-icon">
|
||||
<ToolIcon
|
||||
icon={activeTool.icon}
|
||||
marginRight="0"
|
||||
color="var(--mantine-color-blue-filled)"
|
||||
/>
|
||||
</span>
|
||||
<span className="tool-panel__active-tool-pill-label">
|
||||
{activeTool.name}
|
||||
</span>
|
||||
</div>
|
||||
) : showHeaderSearch ? (
|
||||
<div className="tool-panel__compact-header-search">
|
||||
<ToolSearch
|
||||
value={searchQuery}
|
||||
onChange={handleHeaderSearchChange}
|
||||
toolRegistry={toolRegistry}
|
||||
mode="filter"
|
||||
autoFocus={allToolsView && !inToolView}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
showAgents && (
|
||||
<span className="tool-panel__section-label">
|
||||
{t("agents.section_title", "Agents")}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{showCloseButton ? (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="xl"
|
||||
size="md"
|
||||
onClick={handleHeaderBack}
|
||||
aria-label={
|
||||
inToolView
|
||||
? t("toolPanel.backToAllTools", "Back to all tools")
|
||||
: t("toolPanel.goBack", "Go back")
|
||||
}
|
||||
className="tool-panel__expand-btn"
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<ActionIcon
|
||||
variant="outline"
|
||||
radius="xl"
|
||||
size="md"
|
||||
onClick={handleCollapse}
|
||||
aria-label={t("toolPanel.collapse", "Collapse panel")}
|
||||
className="tool-panel__expand-btn"
|
||||
>
|
||||
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAgents && <AgentsSection />}
|
||||
|
||||
<ToolPanel
|
||||
allToolsView={allToolsView}
|
||||
onShowAllTools={handleShowAllTools}
|
||||
onToolSelect={handleToolSelectWithTransition}
|
||||
compact={agentsEnabled && !allToolsView}
|
||||
/>
|
||||
<AgentsChatOverlay />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FullscreenToolPanel geometry={fullscreenGeometry} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -129,17 +129,70 @@
|
||||
transition:
|
||||
width 0.3s ease,
|
||||
max-width 0.3s ease;
|
||||
view-transition-name: tool-rail;
|
||||
}
|
||||
|
||||
.tool-panel__collapsed-strip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
padding-top: 10px;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tool-panel__collapsed-top {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 0 10px;
|
||||
}
|
||||
|
||||
.tool-panel__collapsed-divider {
|
||||
height: 1px;
|
||||
background: var(--border-subtle);
|
||||
margin: 0 0.5rem 8px;
|
||||
}
|
||||
|
||||
.tool-panel__collapsed-tools {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 0 12px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tool-panel__collapsed-tool-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
color: var(--tools-text-and-icon-color);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 120ms ease-out,
|
||||
border-color 120ms ease-out;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-panel__collapsed-tool-btn:hover {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.tool-panel__collapsed-tool-btn[data-selected="true"] {
|
||||
background: var(--mantine-color-default-hover);
|
||||
border-color: var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.tool-panel__expand-btn {
|
||||
@@ -174,6 +227,11 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-panel__back-bar {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-subtle) !important;
|
||||
}
|
||||
|
||||
.tool-panel--fullscreen-active {
|
||||
overflow: visible !important;
|
||||
}
|
||||
@@ -191,6 +249,128 @@
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.tool-panel__compact-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 52px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tool-panel__compact-header .tool-panel__expand-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.tool-panel__compact-header-search {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tool-panel__compact-header-search .search-input-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tool-panel__active-tool-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 0.35rem 0.75rem 0.35rem 0.4rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 9999px;
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.tool-panel__active-tool-pill-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--mantine-color-blue-light);
|
||||
color: var(--mantine-color-blue-filled);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .tool-panel__active-tool-pill-icon {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-filled) 18%,
|
||||
transparent
|
||||
);
|
||||
color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled));
|
||||
}
|
||||
|
||||
/* The inner .tool-button-icon wrapper carries its own transform/margin; reset
|
||||
them so the icon glyph sits dead-centre in the circular background. */
|
||||
.tool-panel__active-tool-pill-icon .tool-button-icon {
|
||||
margin: 0 !important;
|
||||
transform: none !important;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tool-panel__active-tool-pill-icon svg {
|
||||
font-size: 1rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.tool-panel__active-tool-pill-label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
::view-transition-old(tool-rail) {
|
||||
animation: vt-rail-fade-out 180ms ease-out forwards;
|
||||
}
|
||||
|
||||
::view-transition-new(tool-rail) {
|
||||
animation: vt-rail-fade-in 220ms ease-in forwards;
|
||||
}
|
||||
|
||||
@keyframes vt-rail-fade-out {
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes vt-rail-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
::view-transition-old(tool-rail),
|
||||
::view-transition-new(tool-rail) {
|
||||
animation-duration: 1ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-panel__section-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tool-panel__mode-toggle {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
@@ -652,6 +832,33 @@
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Agents card lives in its own column above the tool grid. It uses the same
|
||||
.tool-panel__fullscreen-group base styles but with a colourful gradient
|
||||
border so it stands out as a distinct surface (and doesn't blend into the
|
||||
neighbouring category cards). */
|
||||
.tool-panel__fullscreen-agents {
|
||||
padding: 1.5rem 1.75rem 0;
|
||||
}
|
||||
|
||||
.tool-panel__fullscreen-group--agents {
|
||||
position: relative;
|
||||
background:
|
||||
linear-gradient(var(--fullscreen-bg-group), var(--fullscreen-bg-group))
|
||||
padding-box,
|
||||
linear-gradient(
|
||||
135deg,
|
||||
var(--mantine-color-blue-6) 0%,
|
||||
var(--mantine-color-violet-6) 45%,
|
||||
var(--mantine-color-pink-6) 100%
|
||||
)
|
||||
border-box;
|
||||
border: 1.5px solid transparent;
|
||||
}
|
||||
|
||||
.tool-panel__fullscreen-section-icon--agents {
|
||||
color: var(--mantine-color-blue-6);
|
||||
}
|
||||
|
||||
@keyframes tool-panel-fullscreen-slide-in {
|
||||
from {
|
||||
transform: translateX(6%) scaleX(0.85);
|
||||
|
||||
@@ -1,303 +1,85 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
||||
import {
|
||||
useToolWorkflow,
|
||||
useToolWorkflowActions,
|
||||
} from "@app/contexts/ToolWorkflowContext";
|
||||
import { usePreferences } from "@app/contexts/PreferencesContext";
|
||||
import { ScrollArea } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import ToolPicker from "@app/components/tools/ToolPicker";
|
||||
import SearchResults from "@app/components/tools/SearchResults";
|
||||
import ToolRenderer from "@app/components/tools/ToolRenderer";
|
||||
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
|
||||
import { useSidebarContext } from "@app/contexts/SidebarContext";
|
||||
import rainbowStyles from "@app/styles/rainbow.module.css";
|
||||
import { ActionIcon, Button, ScrollArea } from "@mantine/core";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FullscreenToolSurface from "@app/components/tools/FullscreenToolSurface";
|
||||
import { ToolPanelViewerBar } from "@app/components/tools/ToolPanelViewerBar";
|
||||
import { useToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
|
||||
import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext";
|
||||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import "@app/components/tools/ToolPanel.css";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
|
||||
// No props needed - component uses context
|
||||
interface ToolPanelProps {
|
||||
/** Whether to expand into the full categorised tools view (with search). */
|
||||
allToolsView: boolean;
|
||||
/** Trigger to enter the all-tools view (from the "View all tools" button). */
|
||||
onShowAllTools: () => void;
|
||||
/**
|
||||
* Tool-selection handler injected by {@code RightSidebar} so the click can
|
||||
* be wrapped in a View Transition (the tool card morphs into the header
|
||||
* pill). Falls back to the workflow context's handler when not provided.
|
||||
*/
|
||||
onToolSelect?: (id: ToolId) => void;
|
||||
/** Whether to render the compact (favourites + recommended only) view. */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export default function ToolPanel() {
|
||||
/** Tool list and renderer for the right rail; rail chrome lives in RightSidebar. */
|
||||
export default function ToolPanel({
|
||||
allToolsView,
|
||||
onShowAllTools,
|
||||
onToolSelect,
|
||||
compact: compactProp,
|
||||
}: ToolPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
const { toolPanelRef, quickAccessRef } = sidebarRefs;
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const {
|
||||
leftPanelView,
|
||||
isPanelVisible,
|
||||
searchQuery,
|
||||
filteredTools,
|
||||
toolRegistry,
|
||||
setSearchQuery,
|
||||
selectedToolKey,
|
||||
toolPanelMode,
|
||||
sidebarsVisible,
|
||||
readerMode,
|
||||
} = useToolWorkflow();
|
||||
const {
|
||||
handleToolSelect,
|
||||
handleBackToTools,
|
||||
setPreviewFile,
|
||||
setToolPanelMode,
|
||||
setLeftPanelView,
|
||||
setReaderMode,
|
||||
setSidebarsVisible,
|
||||
} = useToolWorkflowActions();
|
||||
|
||||
const { setAllButtonsDisabled } = useWorkbenchBar();
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
|
||||
const isFullscreenMode = toolPanelMode === "fullscreen";
|
||||
const toolPickerVisible = !readerMode;
|
||||
const fullscreenExpanded =
|
||||
isFullscreenMode &&
|
||||
leftPanelView === "toolPicker" &&
|
||||
!isMobile &&
|
||||
toolPickerVisible;
|
||||
|
||||
// Disable workbench bar buttons when fullscreen mode is active
|
||||
useEffect(() => {
|
||||
setAllButtonsDisabled(fullscreenExpanded);
|
||||
}, [fullscreenExpanded, setAllButtonsDisabled]);
|
||||
|
||||
const fullscreenGeometry = useToolPanelGeometry({
|
||||
enabled: fullscreenExpanded,
|
||||
toolPanelRef,
|
||||
quickAccessRef,
|
||||
});
|
||||
|
||||
const handleExpand = () => {
|
||||
if (readerMode) setReaderMode(false);
|
||||
if (leftPanelView === "hidden") setLeftPanelView("toolPicker");
|
||||
if (!sidebarsVisible) setSidebarsVisible(true);
|
||||
};
|
||||
|
||||
const handleCollapse = () => {
|
||||
setLeftPanelView("hidden");
|
||||
};
|
||||
|
||||
const [focusSearch, setFocusSearch] = useState(false);
|
||||
const focusSearchOnNextOpen = useRef(false);
|
||||
|
||||
const handleExpandAndSearch = () => {
|
||||
focusSearchOnNextOpen.current = true;
|
||||
handleExpand();
|
||||
};
|
||||
|
||||
// Once the panel becomes visible, consume the focus-search request
|
||||
useEffect(() => {
|
||||
if (isPanelVisible && focusSearchOnNextOpen.current) {
|
||||
focusSearchOnNextOpen.current = false;
|
||||
setFocusSearch(true);
|
||||
// Reset after one render so autoFocus doesn't re-fire on subsequent renders
|
||||
const id = setTimeout(() => setFocusSearch(false), 100);
|
||||
return () => clearTimeout(id);
|
||||
}
|
||||
}, [isPanelVisible]);
|
||||
|
||||
const computedWidth = () => {
|
||||
if (isMobile) {
|
||||
return "100%";
|
||||
}
|
||||
|
||||
if (!isPanelVisible) {
|
||||
return "3.5rem";
|
||||
}
|
||||
|
||||
return "18.5rem";
|
||||
};
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => handleToolSelect(id as ToolId),
|
||||
[handleToolSelect],
|
||||
);
|
||||
|
||||
const matchedTextMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
filteredTools.forEach(({ item: [id], matchedText }) => {
|
||||
if (matchedText) {
|
||||
map.set(id, matchedText);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [filteredTools]);
|
||||
} = useToolWorkflow();
|
||||
const selectTool = onToolSelect ?? handleToolSelect;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={toolPanelRef}
|
||||
data-sidebar="tool-panel"
|
||||
data-tour={fullscreenExpanded ? undefined : "tool-panel"}
|
||||
className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-l border-[var(--border-subtle)] transition-all duration-300 ease-out ${
|
||||
isRainbowMode ? rainbowStyles.rainbowPaper : ""
|
||||
} ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`}
|
||||
style={{
|
||||
width: computedWidth(),
|
||||
padding: "0",
|
||||
}}
|
||||
>
|
||||
{!fullscreenExpanded && !isPanelVisible && !isMobile && (
|
||||
<div className="tool-panel__collapsed-strip">
|
||||
<ActionIcon
|
||||
variant="outline"
|
||||
color="gray.4"
|
||||
radius="xl"
|
||||
size="md"
|
||||
className="tool-panel__expand-btn"
|
||||
onClick={handleExpand}
|
||||
aria-label={t("toolPanel.expand", "Expand panel")}
|
||||
>
|
||||
<ChevronLeftIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
className="tool-panel__collapsed-search-btn"
|
||||
onClick={handleExpandAndSearch}
|
||||
aria-label={t("toolPanel.search", "Search tools")}
|
||||
style={{ marginTop: "8px" }}
|
||||
>
|
||||
<SearchIcon sx={{ fontSize: "1.25rem" }} />
|
||||
</ActionIcon>
|
||||
<>
|
||||
{/* Viewer mode tools — annotate, redact, form fill */}
|
||||
<ToolPanelViewerBar />
|
||||
|
||||
{allToolsView && searchQuery.trim().length > 0 ? (
|
||||
<div className="flex-1 flex flex-col overflow-y-auto">
|
||||
<SearchResults
|
||||
filteredTools={filteredTools}
|
||||
onSelect={(id) => selectTool(id as ToolId)}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fullscreenExpanded && isPanelVisible && (
|
||||
<div
|
||||
style={{
|
||||
opacity: 1,
|
||||
transition: "opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* Viewer mode tools — annotate, redact, form fill */}
|
||||
<ToolPanelViewerBar />
|
||||
|
||||
<div
|
||||
className="tool-panel__search-row"
|
||||
style={{
|
||||
backgroundColor: "transparent",
|
||||
borderBottom: "1px solid var(--border-subtle)",
|
||||
}}
|
||||
>
|
||||
<ToolSearch
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
toolRegistry={toolRegistry}
|
||||
mode="filter"
|
||||
autoFocus={focusSearch}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="outline"
|
||||
radius="xl"
|
||||
size="md"
|
||||
onClick={handleCollapse}
|
||||
aria-label={t("toolPanel.collapse", "Collapse panel")}
|
||||
className="tool-panel__expand-btn"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
|
||||
{searchQuery.trim().length > 0 ? (
|
||||
<div className="flex-1 flex flex-col overflow-y-auto">
|
||||
<SearchResults
|
||||
filteredTools={filteredTools}
|
||||
onSelect={handleSelect}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
) : leftPanelView === "toolPicker" ? (
|
||||
<div className="flex-1 flex flex-col overflow-auto">
|
||||
<ToolPicker
|
||||
) : leftPanelView === "toolPicker" ? (
|
||||
<div className="flex-1 flex flex-col overflow-auto">
|
||||
<ToolPicker
|
||||
selectedToolKey={selectedToolKey}
|
||||
onSelect={(id) => selectTool(id as ToolId)}
|
||||
filteredTools={filteredTools}
|
||||
isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)}
|
||||
compact={compactProp ?? !allToolsView}
|
||||
onShowAllTools={onShowAllTools}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ScrollArea h="100%">
|
||||
{selectedToolKey ? (
|
||||
<ToolRenderer
|
||||
selectedToolKey={selectedToolKey}
|
||||
onSelect={handleSelect}
|
||||
filteredTools={filteredTools}
|
||||
isSearching={Boolean(
|
||||
searchQuery && searchQuery.trim().length > 0,
|
||||
)}
|
||||
onPreviewFile={setPreviewFile}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div
|
||||
style={{
|
||||
borderBottom: "1px solid var(--border-subtle)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
size="sm"
|
||||
fullWidth
|
||||
radius={0}
|
||||
leftSection={<ArrowBackIcon sx={{ fontSize: "0.9rem" }} />}
|
||||
onClick={handleBackToTools}
|
||||
aria-label={t("toolPanel.backToTools", "Back to tools")}
|
||||
styles={{ root: { justifyContent: "flex-start" } }}
|
||||
>
|
||||
{t("toolPanel.backToTools", "Back to tools")}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="tool-panel__placeholder">
|
||||
{t("toolPanel.placeholder", "Choose a tool to get started")}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ScrollArea h="100%">
|
||||
{selectedToolKey ? (
|
||||
<ToolRenderer
|
||||
selectedToolKey={selectedToolKey}
|
||||
onPreviewFile={setPreviewFile}
|
||||
/>
|
||||
) : (
|
||||
<div className="tool-panel__placeholder">
|
||||
{t(
|
||||
"toolPanel.placeholder",
|
||||
"Choose a tool to get started",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fullscreenExpanded && (
|
||||
<FullscreenToolSurface
|
||||
searchQuery={searchQuery}
|
||||
toolRegistry={toolRegistry}
|
||||
filteredTools={filteredTools}
|
||||
selectedToolKey={selectedToolKey}
|
||||
showDescriptions={preferences.showLegacyToolDescriptions}
|
||||
matchedTextMap={matchedTextMap}
|
||||
onSearchChange={setSearchQuery}
|
||||
onSelect={(id: ToolId) => handleToolSelect(id)}
|
||||
onToggleDescriptions={() =>
|
||||
updatePreference(
|
||||
"showLegacyToolDescriptions",
|
||||
!preferences.showLegacyToolDescriptions,
|
||||
)
|
||||
}
|
||||
onExitFullscreenMode={() => setToolPanelMode("sidebar")}
|
||||
geometry={fullscreenGeometry}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { memo, useMemo, useRef } from "react";
|
||||
import { Box, Stack } from "@mantine/core";
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import { Box, Button, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
|
||||
import "@app/components/tools/toolPicker/ToolPicker.css";
|
||||
@@ -22,6 +22,10 @@ interface ToolPickerProps {
|
||||
matchedText?: string;
|
||||
}>;
|
||||
isSearching?: boolean;
|
||||
/** Compact "resting" view: favourites + recommended only, with a button to expand. */
|
||||
compact?: boolean;
|
||||
/** Called when the user clicks "View all tools" in compact mode. */
|
||||
onShowAllTools?: () => void;
|
||||
}
|
||||
|
||||
const EMPTY_FILTERED_TOOLS: ToolPickerProps["filteredTools"] = [];
|
||||
@@ -57,6 +61,8 @@ const ToolPicker = ({
|
||||
onSelect,
|
||||
filteredTools,
|
||||
isSearching = false,
|
||||
compact = false,
|
||||
onShowAllTools,
|
||||
}: ToolPickerProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -118,9 +124,60 @@ const ToolPicker = ({
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
) : compact ? (
|
||||
/* Resting state: flat list of pinned + recommended only. */
|
||||
<Box className="tool-picker__compact">
|
||||
<div style={HEADER_TEXT_STYLE}>
|
||||
{t("toolPanel.toolsHeader", "Tools")}
|
||||
</div>
|
||||
{favoriteToolItems.length === 0 && recommendedItems.length === 0 ? (
|
||||
<NoToolsFound />
|
||||
) : (
|
||||
<div className="tool-picker__compact-list">
|
||||
{favoriteToolItems.map(({ id, tool }) => (
|
||||
<ToolButton
|
||||
key={`fav-${id}`}
|
||||
id={id}
|
||||
tool={tool}
|
||||
isSelected={selectedToolKey === id}
|
||||
onSelect={onSelect}
|
||||
hasStars
|
||||
showDescription
|
||||
/>
|
||||
))}
|
||||
{recommendedItems
|
||||
.filter(
|
||||
({ id }) => !favoriteToolItems.some((fav) => fav.id === id),
|
||||
)
|
||||
.map(({ id, tool }) => (
|
||||
<ToolButton
|
||||
key={`rec-${id}`}
|
||||
id={id as ToolId}
|
||||
tool={tool}
|
||||
isSelected={selectedToolKey === id}
|
||||
onSelect={onSelect}
|
||||
hasStars
|
||||
showDescription
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{onShowAllTools && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
fullWidth
|
||||
onClick={onShowAllTools}
|
||||
className="tool-picker__view-all"
|
||||
aria-label={t("toolPanel.viewAllTools", "View all tools")}
|
||||
>
|
||||
{t("toolPanel.viewAllTools", "View all tools")}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
{/* Flat list: favorites and recommended first, then all subcategories */}
|
||||
{/* All-tools view: favourites + recommended + all subcategories. */}
|
||||
<Stack p="sm" gap="xs">
|
||||
{favoriteToolItems.length > 0 && (
|
||||
<Box w="100%">
|
||||
@@ -182,7 +239,6 @@ const ToolPicker = ({
|
||||
|
||||
{!quickSection && !allSection && <NoToolsFound />}
|
||||
|
||||
{/* bottom spacer to allow scrolling past the last row */}
|
||||
<div aria-hidden style={{ height: 200 }} />
|
||||
</>
|
||||
)}
|
||||
@@ -192,4 +248,4 @@ const ToolPicker = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ToolPicker);
|
||||
export default ToolPicker;
|
||||
|
||||
@@ -32,6 +32,7 @@ interface ToolButtonProps {
|
||||
disableNavigation?: boolean;
|
||||
matchedSynonym?: string;
|
||||
hasStars?: boolean;
|
||||
showDescription?: boolean;
|
||||
/** Called when an unavailable tool is clicked; if provided, overrides the default no-op */
|
||||
onUnavailableClick?: () => void;
|
||||
}
|
||||
@@ -44,6 +45,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
||||
disableNavigation = false,
|
||||
matchedSynonym,
|
||||
hasStars = false,
|
||||
showDescription = false,
|
||||
onUnavailableClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -183,6 +185,14 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
||||
)}
|
||||
{usesCloud && !visuallyUnavailable && <CloudBadge />}
|
||||
</div>
|
||||
{showDescription && tool.description && (
|
||||
<span
|
||||
className="tool-button__description"
|
||||
style={{ opacity: visuallyUnavailable ? 0.25 : 1 }}
|
||||
>
|
||||
{tool.description}
|
||||
</span>
|
||||
)}
|
||||
{matchedSynonym && (
|
||||
<span
|
||||
style={{
|
||||
@@ -205,8 +215,8 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
||||
handleUnlessSpecialClick(e, () => handleClick(id));
|
||||
};
|
||||
|
||||
const selectedStyles = isSelected
|
||||
? { backgroundColor: "#EAEAEA", color: "var(--tools-text-and-icon-color)" }
|
||||
const selectedBg = isSelected
|
||||
? { backgroundColor: "var(--tool-button-selected-bg)" }
|
||||
: {};
|
||||
|
||||
const buttonElement = navProps ? (
|
||||
@@ -227,7 +237,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
||||
borderRadius: 0,
|
||||
color: "var(--tools-text-and-icon-color)",
|
||||
overflow: "visible",
|
||||
...selectedStyles,
|
||||
...selectedBg,
|
||||
},
|
||||
label: { overflow: "visible" },
|
||||
}}
|
||||
@@ -254,7 +264,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
||||
borderRadius: 0,
|
||||
color: "var(--tools-text-and-icon-color)",
|
||||
overflow: "visible",
|
||||
...selectedStyles,
|
||||
...selectedBg,
|
||||
},
|
||||
label: { overflow: "visible" },
|
||||
}}
|
||||
@@ -279,7 +289,6 @@ const ToolButton: React.FC<ToolButtonProps> = ({
|
||||
color: "var(--tools-text-and-icon-color)",
|
||||
cursor: visuallyUnavailable ? "not-allowed" : undefined,
|
||||
overflow: "visible",
|
||||
...selectedStyles,
|
||||
},
|
||||
label: { overflow: "visible" },
|
||||
}}
|
||||
|
||||
@@ -55,6 +55,15 @@
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* Selected tool highlight — theme-aware via CSS variable */
|
||||
:root {
|
||||
--tool-button-selected-bg: var(--mantine-color-gray-2);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] {
|
||||
--tool-button-selected-bg: var(--mantine-color-dark-4);
|
||||
}
|
||||
|
||||
/* Compact tool buttons */
|
||||
.tool-button {
|
||||
font-size: 0.875rem;
|
||||
@@ -96,3 +105,37 @@
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tool-button__description {
|
||||
font-size: 0.75rem;
|
||||
color: var(--mantine-color-dimmed);
|
||||
line-height: 1.35;
|
||||
margin-top: 0.15rem;
|
||||
white-space: normal;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Resting / compact tool list — taller rows, room for description. */
|
||||
.tool-picker__compact {
|
||||
padding: 0.5rem var(--mantine-spacing-sm) var(--mantine-spacing-sm);
|
||||
}
|
||||
|
||||
.tool-picker__compact .tool-button {
|
||||
padding-top: 0.65rem;
|
||||
padding-bottom: 0.65rem;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.tool-picker__compact .tool-button .mantine-Button-label {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tool-picker__compact-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.tool-picker__view-all {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { PageEditorFunctions } from "@app/types/pageEditor";
|
||||
import {
|
||||
type ToolPanelMode,
|
||||
DEFAULT_TOOL_PANEL_MODE,
|
||||
} from "@app/constants/toolPanel";
|
||||
import { type ToolPanelMode } from "@app/constants/toolPanel";
|
||||
import { preferencesService } from "@app/services/preferencesService";
|
||||
|
||||
export interface ToolWorkflowState {
|
||||
// UI State
|
||||
@@ -43,7 +41,7 @@ export const baseState: Omit<ToolWorkflowState, "toolPanelMode"> = {
|
||||
|
||||
export const createInitialState = (): ToolWorkflowState => ({
|
||||
...baseState,
|
||||
toolPanelMode: DEFAULT_TOOL_PANEL_MODE,
|
||||
toolPanelMode: preferencesService.getPreference("defaultToolPanelMode"),
|
||||
});
|
||||
|
||||
export function toolWorkflowReducer(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { generateId } from "@app/utils/generateId";
|
||||
import {
|
||||
signatureStorageService,
|
||||
type StorageType,
|
||||
@@ -28,16 +29,6 @@ export type AddSignatureResult =
|
||||
const isSupportedEnvironment = () =>
|
||||
typeof window !== "undefined" && typeof window.localStorage !== "undefined";
|
||||
|
||||
const generateId = () => {
|
||||
if (
|
||||
typeof crypto !== "undefined" &&
|
||||
typeof crypto.randomUUID === "function"
|
||||
) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `sig_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
};
|
||||
|
||||
export const useSavedSignatures = () => {
|
||||
const [savedSignatures, setSavedSignatures] = useState<SavedSignature[]>([]);
|
||||
const [storageType, setStorageType] = useState<StorageType | null>(null);
|
||||
|
||||
@@ -13,6 +13,31 @@ interface UseToolPanelGeometryOptions {
|
||||
quickAccessRef: RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
function computeGeometry(
|
||||
panelEl: HTMLDivElement,
|
||||
quickAccessRef: RefObject<HTMLDivElement | null>,
|
||||
): ToolPanelGeometry {
|
||||
const rect = panelEl.getBoundingClientRect();
|
||||
const isRTL =
|
||||
typeof document !== "undefined" && document.documentElement.dir === "rtl";
|
||||
let width: number;
|
||||
let left: number;
|
||||
|
||||
if (isRTL) {
|
||||
// RTL: panel is on the left, expands rightward
|
||||
width = Math.max(360, window.innerWidth - rect.right);
|
||||
left = rect.right;
|
||||
} else {
|
||||
// LTR: panel is on the right, expands leftward to the file sidebar
|
||||
const quickAccessRect = quickAccessRef.current?.getBoundingClientRect();
|
||||
const leftOffset = quickAccessRect ? quickAccessRect.right : 0;
|
||||
width = Math.max(360, rect.right - leftOffset);
|
||||
left = leftOffset;
|
||||
}
|
||||
const height = Math.max(rect.height, window.innerHeight - rect.top);
|
||||
return { left, top: rect.top, width, height };
|
||||
}
|
||||
|
||||
export function useToolPanelGeometry({
|
||||
enabled,
|
||||
toolPanelRef,
|
||||
@@ -36,31 +61,7 @@ export function useToolPanelGeometry({
|
||||
let rafId: number | null = null;
|
||||
|
||||
const computeAndSetGeometry = () => {
|
||||
const rect = panelEl.getBoundingClientRect();
|
||||
const isRTL =
|
||||
typeof document !== "undefined" &&
|
||||
document.documentElement.dir === "rtl";
|
||||
let width: number;
|
||||
let left: number;
|
||||
|
||||
if (isRTL) {
|
||||
// RTL: panel is on the left, expands rightward
|
||||
width = Math.max(360, window.innerWidth - rect.right);
|
||||
left = rect.right;
|
||||
} else {
|
||||
// LTR: panel is on the right, expands leftward to the file sidebar
|
||||
const quickAccessRect = quickAccessRef.current?.getBoundingClientRect();
|
||||
const leftOffset = quickAccessRect ? quickAccessRect.right : 0;
|
||||
width = Math.max(360, rect.right - leftOffset);
|
||||
left = leftOffset;
|
||||
}
|
||||
const height = Math.max(rect.height, window.innerHeight - rect.top);
|
||||
setGeometry({
|
||||
left,
|
||||
top: rect.top,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
setGeometry(computeGeometry(panelEl, quickAccessRef));
|
||||
};
|
||||
|
||||
const scheduleUpdate = () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ import AppsIcon from "@mui/icons-material/AppsRounded";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
|
||||
|
||||
import ToolPanel from "@app/components/tools/ToolPanel";
|
||||
import RightSidebar from "@app/components/tools/RightSidebar";
|
||||
import Workbench from "@app/components/layout/Workbench";
|
||||
import FileSidebar from "@app/components/shared/FileSidebar";
|
||||
import FileManager from "@app/components/FileManager";
|
||||
@@ -380,7 +380,7 @@ export default function HomePage() {
|
||||
)}
|
||||
>
|
||||
<div className="mobile-slide-content">
|
||||
<ToolPanel />
|
||||
<RightSidebar />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -510,7 +510,7 @@ export default function HomePage() {
|
||||
/>
|
||||
<FolderTreePanel active={navigationState.workbench === "myFiles"} />
|
||||
<Workbench />
|
||||
{!hideToolPanel && <ToolPanel />}
|
||||
{!hideToolPanel && <RightSidebar />}
|
||||
<FileManager selectedTool={selectedTool} />
|
||||
<AppConfigModal
|
||||
opened={configModalOpen}
|
||||
|
||||
@@ -1099,3 +1099,17 @@
|
||||
animation-duration: 160ms;
|
||||
animation-timing-function: cubic-bezier(0.2, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Mantine animates a button's background-color and its text/icon colour on
|
||||
* separate transitions, so when a filled button flips state (selected ↔
|
||||
* unselected, enabled ↔ disabled) you briefly see e.g. a blue background
|
||||
* with the old dark-grey label still showing through. Drop the colour
|
||||
* tween — the swap is instant, but transforms/shadows/borders/opacity keep
|
||||
* animating for hover and press feedback. */
|
||||
.mantine-Button-root,
|
||||
.mantine-ActionIcon-root,
|
||||
.mantine-SegmentedControl-control,
|
||||
.mantine-SegmentedControl-label,
|
||||
.mantine-SegmentedControl-indicator {
|
||||
transition-property: transform, box-shadow, border-color, opacity !important;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect } from "@app/tests/helpers/test-base";
|
||||
import { ensureCookieConsent } from "@app/tests/helpers/login";
|
||||
import { bypassOnboarding } from "@app/tests/helpers/api-stubs";
|
||||
import { openSettings } from "@app/tests/helpers/ui-helpers";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect } from "@app/tests/helpers/test-base";
|
||||
import { ensureCookieConsent } from "@app/tests/helpers/login";
|
||||
import { bypassOnboarding } from "@app/tests/helpers/api-stubs";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect } from "@app/tests/helpers/test-base";
|
||||
import { ensureCookieConsent } from "@app/tests/helpers/login";
|
||||
import { bypassOnboarding } from "@app/tests/helpers/api-stubs";
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { test as base, expect } from "@playwright/test";
|
||||
import { test as base, expect } from "@app/tests/helpers/test-base";
|
||||
import {
|
||||
bypassOnboarding,
|
||||
mockAppApis,
|
||||
seedCookieConsent,
|
||||
skipOnboarding,
|
||||
type MockAppApiOptions,
|
||||
} from "@app/tests/helpers/api-stubs";
|
||||
@@ -57,7 +56,8 @@ export const test = base.extend<StubFixtures>({
|
||||
seedJwt: [false, { option: true }],
|
||||
|
||||
page: async ({ page, stubOptions, autoGoto, seedJwt }, use) => {
|
||||
await seedCookieConsent(page);
|
||||
// `page` comes from test-base, which has already seeded cookie consent
|
||||
// and attached the console-error recorder before any navigation runs.
|
||||
if (seedJwt) {
|
||||
// Logged-in users hit the orchestrator path that surfaces the
|
||||
// analytics opt-in / MFA prompts — use the stronger bypass-all flag
|
||||
|
||||
@@ -1,14 +1,216 @@
|
||||
import { test as base, expect } from "@playwright/test";
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Custom test fixture that auto-dismisses the cookie consent banner
|
||||
* before every test. The banner (#cc-main) overlays the page and
|
||||
* intercepts pointer events, causing click timeouts across all tests.
|
||||
* Console message types that should fail the test if they appear.
|
||||
* `console.error()` -> type "error", `console.warn()` -> type "warning".
|
||||
*/
|
||||
const FAILING_CONSOLE_TYPES = new Set(["error", "warning"]);
|
||||
|
||||
/**
|
||||
* Patterns ignored globally on every page. Keep this list small and
|
||||
* well-justified — each entry suppresses a genuine console warning for
|
||||
* every test, which means we lose detection of regressions in that
|
||||
* surface. Only add things that:
|
||||
*
|
||||
* - fire on first page render of *every* test (so per-test
|
||||
* suppression would just be ceremony), AND
|
||||
* - are environmental noise (third-party CDN, dev-server quirk,
|
||||
* known init-order quirk) rather than something a test could
|
||||
* reasonably assert.
|
||||
*
|
||||
* Anything that fires only on specific flows belongs in an inline
|
||||
* `expectConsoleError` / `suppressConsoleErrors` at the call site.
|
||||
*/
|
||||
const GLOBAL_IGNORE_PATTERNS: RegExp[] = [
|
||||
// Stripe.js logs an HTTP warning when loaded over localhost. Third-party,
|
||||
// expected in dev, no production impact.
|
||||
/You may test your Stripe\.js integration over HTTP/,
|
||||
// i18next's HTTP backend fails to load namespace files under Vite dev's
|
||||
// `@fs/` URLs; the app falls back to embedded English copy and tests
|
||||
// still pass functional assertions.
|
||||
/i18next::backendConnector: loading namespace/,
|
||||
// scarfTracking.firePixel() is invoked from a router effect on the first
|
||||
// route render, before the useScarfTracking hook has called
|
||||
// setScarfConfig(). Harmless (the pixel is a no-op on first call) but
|
||||
// worth a follow-up to reorder init. See utils/scarfTracking.ts.
|
||||
/\[scarfTracking\] firePixel\(\) called before setScarfConfig/,
|
||||
|
||||
// ── Vite dev-server flakiness under parallel-worker load ────────────────
|
||||
// The next block suppresses the entire cascade that follows when Vite's
|
||||
// dev server briefly stops accepting connections (because several workers
|
||||
// hit it simultaneously). In CI we serve a pre-built dist via
|
||||
// `vite preview`, where none of this happens; locally the cascade is just
|
||||
// environmental noise. None of these patterns mask production-only bugs.
|
||||
|
||||
// 1) Browser-level network failure for an unreachable URL.
|
||||
/Failed to load resource: net::ERR_/,
|
||||
// 2) Vite's lazy chunk loader sees the network failure and throws.
|
||||
/Failed to fetch dynamically imported module/,
|
||||
// 3) PDF.js / pdfium WASM streaming fetch trips on the same outage.
|
||||
/WebAssembly compilation aborted: Network error/,
|
||||
/wasm streaming compile failed/,
|
||||
/failed to asynchronously prepare wasm/,
|
||||
/falling back to ArrayBuffer instantiation/,
|
||||
// 4) React-dom logs its own wrapper line when the lazy chunk error reaches
|
||||
// a Suspense / ErrorBoundary. Suppress only this exact wrapper — real
|
||||
// React errors that aren't chunk-load failures still surface elsewhere.
|
||||
/The above error occurred in one of your React components/,
|
||||
// 5) Our ErrorBoundary's componentDidCatch dumps ~15 supplementary
|
||||
// diagnostic lines. They are useful in prod but in tests they are
|
||||
// pure noise on top of whatever already failed. Match by source URL.
|
||||
/\(https?:\/\/[^)]*\/src\/core\/components\/shared\/ErrorBoundary\.tsx:/,
|
||||
];
|
||||
|
||||
/**
|
||||
* Per-page collector for console errors / warnings / uncaught page errors.
|
||||
*
|
||||
* The fixture installs one of these on every page. Messages that aren't
|
||||
* absorbed by an active `expectConsoleError` / `suppressConsoleErrors`
|
||||
* scope are reported in fixture teardown and fail the test.
|
||||
*/
|
||||
class ConsoleErrorRecorder {
|
||||
private readonly failed: string[] = [];
|
||||
private readonly scopes: Array<{ pattern: RegExp; matched: boolean }> = [];
|
||||
|
||||
record(text: string): void {
|
||||
for (const pattern of GLOBAL_IGNORE_PATTERNS) {
|
||||
if (pattern.test(text)) return; // documented global noise
|
||||
}
|
||||
for (const scope of this.scopes) {
|
||||
if (scope.pattern.test(text)) {
|
||||
scope.matched = true;
|
||||
return; // absorbed by an active scope, not a failure
|
||||
}
|
||||
}
|
||||
this.failed.push(text);
|
||||
}
|
||||
|
||||
async withScope<T>(
|
||||
pattern: RegExp,
|
||||
fn: () => Promise<T>,
|
||||
requireMatch: boolean,
|
||||
): Promise<T> {
|
||||
const scope = { pattern, matched: false };
|
||||
this.scopes.push(scope);
|
||||
try {
|
||||
const result = await fn();
|
||||
if (requireMatch && !scope.matched) {
|
||||
throw new Error(
|
||||
`expectConsoleError: no console error/warning matched ${pattern} ` +
|
||||
`during the scoped action`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
const idx = this.scopes.indexOf(scope);
|
||||
if (idx >= 0) this.scopes.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
failures(): string[] {
|
||||
return this.failed;
|
||||
}
|
||||
}
|
||||
|
||||
const recordersByPage = new WeakMap<Page, ConsoleErrorRecorder>();
|
||||
|
||||
function attachConsoleErrorRecorder(page: Page): ConsoleErrorRecorder {
|
||||
const recorder = new ConsoleErrorRecorder();
|
||||
recordersByPage.set(page, recorder);
|
||||
|
||||
page.on("console", (msg) => {
|
||||
const type = msg.type();
|
||||
if (!FAILING_CONSOLE_TYPES.has(type)) return;
|
||||
const { url, lineNumber, columnNumber } = msg.location();
|
||||
const where = url ? ` (${url}:${lineNumber}:${columnNumber})` : "";
|
||||
recorder.record(`[console.${type}] ${msg.text()}${where}`);
|
||||
});
|
||||
|
||||
page.on("pageerror", (err) => {
|
||||
recorder.record(`[pageerror] ${err.message}`);
|
||||
});
|
||||
|
||||
return recorder;
|
||||
}
|
||||
|
||||
function getRecorder(page: Page, caller: string): ConsoleErrorRecorder {
|
||||
const recorder = recordersByPage.get(page);
|
||||
if (!recorder) {
|
||||
throw new Error(
|
||||
`${caller} requires the \`test\` exported from ` +
|
||||
`\`@app/tests/helpers/test-base\` (or stub-test-base). ` +
|
||||
`Are you importing \`test\` directly from "@playwright/test"?`,
|
||||
);
|
||||
}
|
||||
return recorder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` and *require* at least one console error / warning / page error
|
||||
* that matches `pattern` to occur during it. Matching messages are
|
||||
* absorbed (they don't fail the test); if none match, this throws.
|
||||
*
|
||||
* Use when a test deliberately exercises an error path and the error
|
||||
* surfaces in the console:
|
||||
*
|
||||
* await expectConsoleError(page, /Validation failed/, async () => {
|
||||
* await page.getByRole("button", { name: "Submit" }).click();
|
||||
* await expect(page.getByRole("alert")).toBeVisible();
|
||||
* });
|
||||
*
|
||||
* The scope only covers messages emitted while `fn` is awaiting, so
|
||||
* remember to `await` any UI assertion that the error has surfaced
|
||||
* *inside* the callback rather than after it returns.
|
||||
*/
|
||||
export async function expectConsoleError<T>(
|
||||
page: Page,
|
||||
pattern: RegExp,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
return getRecorder(page, "expectConsoleError").withScope(pattern, fn, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` and silently absorb any console errors / warnings / page
|
||||
* errors matching `pattern`, without asserting that one occurred. Use
|
||||
* sparingly — `expectConsoleError` is preferred because it also verifies
|
||||
* the error path actually fires.
|
||||
*
|
||||
* await suppressConsoleErrors(page, /MUI Grid v1 deprecated/, async () => {
|
||||
* await page.getByRole("button", { name: "Open settings" }).click();
|
||||
* });
|
||||
*/
|
||||
export async function suppressConsoleErrors<T>(
|
||||
page: Page,
|
||||
pattern: RegExp,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
return getRecorder(page, "suppressConsoleErrors").withScope(
|
||||
pattern,
|
||||
fn,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom test fixture shared across all Playwright suites. Two things
|
||||
* happen for every test that uses this base (directly or transitively
|
||||
* via `stub-test-base.ts`):
|
||||
*
|
||||
* 1. The cookie-consent cookie is seeded before any navigation so the
|
||||
* `#cc-main` banner never renders and never intercepts clicks.
|
||||
* 2. Console errors/warnings and uncaught page errors are captured.
|
||||
* If any unhandled message appears during the test, the fixture
|
||||
* throws during teardown and the test fails. Tests that legitimately
|
||||
* produce errors should wrap the offending step in
|
||||
* `expectConsoleError(page, /pattern/, async () => { ... })`.
|
||||
*
|
||||
* Usage: import { test, expect } from '@app/tests/helpers/test-base';
|
||||
*/
|
||||
export const test = base.extend({
|
||||
page: async ({ page }, use) => {
|
||||
const recorder = attachConsoleErrorRecorder(page);
|
||||
|
||||
// Set the cookie consent cookie before any navigation so the banner
|
||||
// never appears. The cookieconsent library (orestbida/cookieconsent)
|
||||
// reads this cookie on init and skips the banner if consent exists.
|
||||
@@ -29,6 +231,16 @@ export const test = base.extend({
|
||||
]);
|
||||
|
||||
await use(page);
|
||||
|
||||
const failures = recorder.failures();
|
||||
if (failures.length > 0) {
|
||||
throw new Error(
|
||||
`Test produced ${failures.length} unhandled console error(s)/warning(s):\n` +
|
||||
failures.map((m) => ` ${m}`).join("\n") +
|
||||
`\n\nIf any of these are expected, wrap the action in ` +
|
||||
`expectConsoleError(page, /pattern/, async () => { ... }).`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { test, expect } from "@app/tests/helpers/test-base";
|
||||
import type { Page } from "@playwright/test";
|
||||
import {
|
||||
bypassOnboarding,
|
||||
mockAppApis,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { test, expect } from "@app/tests/helpers/test-base";
|
||||
import type { Page } from "@playwright/test";
|
||||
import path from "path";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
* The Vite dev server must be running (handled by playwright.config.ts webServer).
|
||||
*/
|
||||
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { test, expect } from "@app/tests/helpers/test-base";
|
||||
import type { Page } from "@playwright/test";
|
||||
import path from "path";
|
||||
import { mockAppApis } from "@app/tests/helpers/api-stubs";
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* The Vite dev server must be running (handled by playwright.config.ts webServer).
|
||||
*/
|
||||
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { test, expect } from "@app/tests/helpers/test-base";
|
||||
import type { Page } from "@playwright/test";
|
||||
import path from "path";
|
||||
import { mockAppApis } from "@app/tests/helpers/api-stubs";
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* unlock-all-wrong-password - all transitively covered or low-value.
|
||||
*/
|
||||
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { test, expect } from "@app/tests/helpers/test-base";
|
||||
import type { Page } from "@playwright/test";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { mockAppApis } from "@app/tests/helpers/api-stubs";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { test, expect } from "@app/tests/helpers/test-base";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { mockAppApis, seedCookieConsent } from "@app/tests/helpers/api-stubs";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { test, expect } from "@app/tests/helpers/test-base";
|
||||
import type { Page } from "@playwright/test";
|
||||
import {
|
||||
bypassOnboarding,
|
||||
mockAppApis,
|
||||
|
||||
@@ -31,9 +31,9 @@ test.describe("Navigation", () => {
|
||||
await page.locator('a[href="/merge"]').first().click();
|
||||
await expect(page).toHaveURL(/\/merge/);
|
||||
|
||||
// In the redesigned UI, "Back to tools" button replaces the old "Tools" link
|
||||
// In the redesigned UI, "Back to all tools" button replaces the old "Tools" link
|
||||
await page
|
||||
.getByRole("button", { name: /Back to tools/i })
|
||||
.getByRole("button", { name: /Back to all tools/i })
|
||||
.first()
|
||||
.click();
|
||||
await expect(page).toHaveURL("/");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { test, expect } from "@app/tests/helpers/test-base";
|
||||
import type { Page } from "@playwright/test";
|
||||
import {
|
||||
bypassOnboarding,
|
||||
mockAppApis,
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* STIRLING_FLAVOR=saas ./gradlew :stirling-pdf:bootRun --args="--server.port=18083 --spring.profiles.include=dev"
|
||||
* STIRLING_SAAS_URL=http://localhost:18083 npx playwright test --project=stubbed saas-backend-smoke
|
||||
*/
|
||||
import { test, expect, request } from "@playwright/test";
|
||||
import { test, expect } from "@app/tests/helpers/test-base";
|
||||
import { request } from "@playwright/test";
|
||||
|
||||
const SAAS_URL = process.env.STIRLING_SAAS_URL ?? "http://localhost:18083";
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { test, expect } from "@app/tests/helpers/test-base";
|
||||
import type { Page } from "@playwright/test";
|
||||
import {
|
||||
bypassOnboarding,
|
||||
mockAppApis,
|
||||
|
||||
@@ -36,10 +36,10 @@ test.describe("4. PDF Tool Pages - Common Patterns", () => {
|
||||
await page.goto("/compress");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
|
||||
// Step 2: Click the "Back to tools" button in ToolPanel to go back to /.
|
||||
// Step 2: Click the "Back to all tools" button in ToolPanel to go back to /.
|
||||
// In the redesigned UI this replaces the old "Tools" sidebar link.
|
||||
const homeLink = page
|
||||
.getByRole("button", { name: /Back to tools/i })
|
||||
.getByRole("button", { name: /Back to all tools/i })
|
||||
.first();
|
||||
await homeLink.click();
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState, useContext, useCallback, useRef } from "react";
|
||||
import { generateId } from "@app/utils/generateId";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
@@ -578,10 +579,7 @@ const Annotate = (_props: BaseToolProps) => {
|
||||
const OFFSET = 20;
|
||||
const pasted: Record<string, unknown> = { ...annotation };
|
||||
// Assign a new id so EmbedPDF tracks the copy and delete works on it
|
||||
pasted.id =
|
||||
typeof crypto !== "undefined" && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: `paste-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
pasted.id = generateId();
|
||||
delete pasted.uid;
|
||||
// Remove appearance stream reference — the copy needs its own rendering
|
||||
delete pasted.appearanceModes;
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface AppConfig {
|
||||
timestampDefaultTsaUrl?: string;
|
||||
timestampCustomTsaUrls?: string[];
|
||||
timestampTsaPresets?: { label: string; url: string }[];
|
||||
aiEngineEnabled?: boolean;
|
||||
}
|
||||
|
||||
export type AppConfigBootstrapMode = "blocking" | "non-blocking";
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { PageOperation } from "@app/types/pageEditor";
|
||||
import { FileId, BaseFileMetadata } from "@app/types/file";
|
||||
import { generateId } from "@app/utils/generateId";
|
||||
|
||||
// Re-export FileId for convenience
|
||||
export type { FileId };
|
||||
@@ -58,18 +59,8 @@ export interface FileContextNormalizedFiles {
|
||||
byId: Record<FileId, StirlingFileStub>;
|
||||
}
|
||||
|
||||
// Helper functions - UUID-based primary keys (zero collisions, synchronous)
|
||||
export function createFileId(): FileId {
|
||||
// Use crypto.randomUUID for authoritative primary key
|
||||
if (typeof window !== "undefined" && window.crypto?.randomUUID) {
|
||||
return window.crypto.randomUUID() as FileId;
|
||||
}
|
||||
// Fallback for environments without randomUUID
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c == "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
}) as FileId;
|
||||
return generateId() as FileId;
|
||||
}
|
||||
|
||||
// Generate quick deduplication key from file metadata
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* and may reference a parent folder; a `null` parent means the root.
|
||||
*/
|
||||
|
||||
import { generateId } from "@app/utils/generateId";
|
||||
|
||||
declare const folderTag: unique symbol;
|
||||
export type FolderId = string & { readonly [folderTag]: "FolderId" };
|
||||
|
||||
@@ -81,17 +83,7 @@ export function parseFolderId(value: unknown): FolderId {
|
||||
}
|
||||
|
||||
export function createFolderId(): FolderId {
|
||||
if (typeof window !== "undefined" && window.crypto?.randomUUID) {
|
||||
return window.crypto.randomUUID() as FolderId;
|
||||
}
|
||||
// Math.random fallback is non-cryptographic but acceptable here - these ids are
|
||||
// not used as security tokens, only as opaque local handles. The brand is the
|
||||
// contract; the entropy is best-effort.
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c == "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
}) as FolderId;
|
||||
return generateId() as FolderId;
|
||||
}
|
||||
|
||||
export function pickFolderColor(seed: string): FolderPaletteColor {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* Generates and persists a unique UUID in localStorage for WAU tracking
|
||||
*/
|
||||
|
||||
import { generateId } from "@app/utils/generateId";
|
||||
|
||||
const BROWSER_ID_KEY = "stirling_browser_id";
|
||||
|
||||
/**
|
||||
@@ -28,19 +30,6 @@ export function getBrowserId(): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a UUID v4
|
||||
*/
|
||||
function generateUUID(): string {
|
||||
// Use crypto.randomUUID if available (modern browsers)
|
||||
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
// Fallback to manual UUID generation
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
return generateId();
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user