diff --git a/.claude/skills/feature-walkthrough/SKILL.md b/.claude/skills/feature-walkthrough/SKILL.md new file mode 100644 index 0000000000..f2a4bfdc8b --- /dev/null +++ b/.claude/skills/feature-walkthrough/SKILL.md @@ -0,0 +1,97 @@ +--- +name: feature-walkthrough +description: >- + Explain the full logic and process of the current branch end-to-end so someone + with no prior knowledge of the task can understand, review, and reproduce it. + Scopes the change from the branch diff, traces the flow across every layer it + touches (frontend tool/hook/component, Java controller/service/endpoint, Python + engine, config, i18n, tests), and produces a self-contained walkthrough document + with Mermaid diagrams (sequence/flow/architecture), annotated file map with + clickable references, before/after behavior, screenshots where a UI is involved, + a "try it locally" section, and edge cases/risks. Use when asked for a feature or + branch walkthrough, "explain what this branch does", a design/logic writeup, PR + reviewer onboarding, or a hand-off doc. Pass --html to also emit a rendered HTML + version; --no-screens to skip screenshots. +argument-hint: "[branch-or-area] [--html] [--no-screens]" +allowed-tools: Read, Write, Edit, Glob, Grep, Bash +--- + +# Feature / Branch Walkthrough + +Turn the current branch into a walkthrough a newcomer can follow. Audience: +**someone who has never seen this task**. Explain the *why*, the *flow*, and *how to +try it* - not just a diff summary. + +`$ARGUMENTS` may name a branch or area to focus on; default is the current branch +vs `main`. Flags: `--html` (also emit a rendered HTML twin), `--no-screens`. + +## Process + +### 1. Scope the change +- `git log --oneline main..HEAD` and `git diff --stat main...HEAD` for the shape. +- Read the PR description / commit messages for stated intent. Do **not** invent + history or motivation that isn't evidenced (state current behavior in present tense). +- Classify touched files by layer: + - **Frontend**: tools (`frontend/editor/src/core/components/tools/*` or `.../core/tools/*`), + hooks (`core/hooks/tools/*`, `useToolOperation`), contexts, routes, i18n + (`public/locales/en-US`). + - **Java backend**: controllers (`.../controller/api/...`), services, models, config. + - **Engine**: `engine/src/stirling/{agents,contracts,api,services}`. + - **Config / build / docker / tests.** + +### 2. Trace the flow end-to-end +Follow one real path from user action to result. For a typical PDF tool that's: +UI control → `useToolOperation` hook → `POST /api/v1/...` → Spring controller → +service (PDFBox / LibreOffice / engine call) → response → review panel → download. +Read the actual files so the narrative is true to the code, and collect the exact +file:line anchors you'll cite. + +### 3. Draw the diagrams (Mermaid) +Pick what fits; usually 2-3 of: +- **Sequence diagram** - request/response across frontend → backend → engine. +- **Flowchart** - the core decision/branching logic of the feature. +- **Architecture/component** - new pieces and how they wire to existing ones. +- **State** - if the feature has modes/steps. +Keep nodes labeled in plain language. Validate the Mermaid parses before shipping. + +### 4. Screenshots (unless --no-screens) +If a UI is involved, capture key states with the stubbed Playwright harness +(see the **ui-walkthrough** skill and `files-page-screenshots.spec.ts` for the +pattern) or, for before/after, capture `main` then the branch. Drop PNGs in +`walkthrough//` and reference them from the doc. For backend-only +changes, show request/response examples (curl + JSON) instead. + +### 5. Write the walkthrough +Create `walkthrough//FEATURE-WALKTHROUGH.md` with: +1. **TL;DR** - what the branch does and who it's for, in 3-4 sentences. +2. **Problem & approach** - what wasn't possible before; the chosen solution. +3. **Architecture diagram** + 1-paragraph orientation. +4. **End-to-end flow** - the sequence diagram + a numbered walk of each step, + each citing the real file (clickable `path:line`). +5. **Key files** - annotated map (path → one line on its role). +6. **Logic deep-dive** - the flowchart + prose for the non-obvious decisions. +7. **Behavior** - before vs after; screenshots or request/response examples. +8. **Try it locally** - exact steps (`task dev` / `task dev:all`, the route to + open or the curl to run, any env like `DOCKER_ENABLE_SECURITY` or a test + license key). Make it copy-pasteable. +9. **Edge cases, risks, follow-ups** - what's untested, known limits, gotchas. + +Markdown is the primary deliverable - it renders with diagrams in GitHub PRs and +IDEs, no build step, ideal for review. + +### 6. If `--html` +Also emit `walkthrough//walkthrough.html`: the same content with Mermaid +rendered via `mermaid.initialize({startOnLoad:true})` (script from CDN; note in +the file that rendering diagrams needs network, the `.md` is the offline copy) and +screenshots inline. Keep it self-contained otherwise. + +### 7. Deliver +Give the doc path and a short chat summary. Offer to `SendUserFile` it. + +## Principles +- **True to the code.** Every claim traces to a file you read; cite `path:line`. + No fabricated migration/version history. +- **Newcomer-first.** Define repo-specific terms (FileContext, `useToolOperation`, + the `@app/*` layer cascade, stubbed vs live tests) on first use. +- **Show, don't assert.** Prefer a diagram + a real example over adjectives. +- Don't commit the `walkthrough/` output unless asked. diff --git a/.claude/skills/ui-before-after/SKILL.md b/.claude/skills/ui-before-after/SKILL.md new file mode 100644 index 0000000000..07a8561baa --- /dev/null +++ b/.claude/skills/ui-before-after/SKILL.md @@ -0,0 +1,122 @@ +--- +name: ui-before-after +description: >- + Analyse a branch or PR and automatically capture before/after screenshots of + every UI surface its changes touch, then pixel-diff the pairs to surface what + actually changed and assemble PR-ready before/after montage images. Generic and + diff-driven: it derives the capture targets from the diff (changed tools/routes → + URLs) instead of hand-listing screens, captures "before" from the base branch and + "after" from the head, then keeps only the views that visually differ. Each + comparison is auto-cropped to the region that actually changed (the bounding box of + differing pixels), falling back to the full page only when the change spans most of + it. Use for before/after shots, a visual diff of a branch/PR, "screenshots for the + PR description", "show what changed in the UI", or a side-by-side of UI changes. + Takes a PR number/URL (resolved via gh) or a branch; defaults to the current branch + vs its base. Flags: --scope , --base , --theme + light|dark|both, --all (capture every route, not just changed), --no-autocrop, + --pagewide , --threshold . +argument-hint: "[PR# | PR-url | branch] [--scope ] [--base ] [--theme both] [--all] [--no-autocrop]" +allowed-tools: Read, Write, Edit, Glob, Grep, Bash +--- + +# UI Before / After (generic visual diff) + +Point it at a branch or PR; it figures out which UI changed, screenshots every +affected surface **before** (base) and **after** (head), pixel-diffs the pairs, and +montages the ones that actually changed into images for the PR description. + +`$ARGUMENTS`: a PR number/URL, a branch, or nothing (current branch vs base). +By default it captures the full viewport and auto-crops each comparison to the region +that changed. Flags: `--scope ` (narrow the *capture* to a container, e.g. +`[data-sidebar="tool-panel"]`, when you already know where the change is), +`--no-autocrop` (keep full frames), `--pagewide ` (above this share of the +page, skip cropping; default 0.6), `--base `, +`--theme light|dark|both`, `--all` (walk every route, not just changed), +`--threshold ` (diff sensitivity, default 0.001). + +Shares the capture harness with **ui-walkthrough** - read its SKILL.md for the +stubbed-Playwright setup, worktree node_modules + `generate-icons`, the +stale-`:5173` gotcha, and the dark-mode init-script. Bundled helpers: +[capture-spec.template.ts](capture-spec.template.ts), [diff-shots.mjs](diff-shots.mjs), +[montage-template.html](montage-template.html), [shoot-sections.mjs](shoot-sections.mjs). + +## Process + +### 1. Resolve target + base +``` +gh pr view --json number,title,headRefName,baseRefName,url,files # PR +# or branch: base = merge-base(main, HEAD); head = HEAD +gh pr diff --name-only # or: git diff --name-only ...HEAD +``` + +### 2. Derive capture targets from the diff (the "analyse" step - no hand-listing) +Map changed frontend files to URLs generically: +- **Tools**: a changed `components/tools//…` or `hooks/tools//…` → + toolId → URL via the repo's own rule `getToolUrlPath` in + [toolsTaxonomy.ts:200](frontend/editor/src/core/data/toolsTaxonomy.ts): `/` + the + id kebab-cased (`addPageNumbers` → `/add-page-numbers`). +- **Pages/routes**: changed `filesPage/*` → `/files`, etc. +- `--all`: enumerate every tool in the registry instead of just changed ones. +Write `frontend/editor/screenshots/ui-diff/targets.json` = +`[{ "id":"compress", "url":"/compress", "name":"Compress" }]`. This is what makes +it generic - the spec never names a tool. + +### 3. Capture AFTER (head) then BEFORE (base) +Copy [capture-spec.template.ts](capture-spec.template.ts) → +`src/core/tests/stubbed/ui-before-after.spec.ts` (it loops `targets.json`, seeds a +sample PDF so file-dependent panels render, navigates to each URL, and screenshots +the full viewport - or the `--scope` container if given). Ensure the harness is ready +(node_modules + icons). +``` +# after = current head +cd frontend/editor && PR_SHOT_SIDE=after PR_SHOT_THEME=light \ + npx playwright test --project=stubbed ui-before-after.spec.ts +# before = base, in an isolated worktree (copy the spec + targets.json in) +git worktree add ../ba-base origin/ # or the merge-base +# set up its frontend, copy spec + screenshots/ui-diff/targets.json across, then: +cd ../ba-base/frontend/editor && PR_SHOT_SIDE=before PR_SHOT_THEME=light \ + npx playwright test --project=stubbed ui-before-after.spec.ts +# copy its screenshots/ui-diff/before/ back next to after/. Repeat with +# PR_SHOT_THEME=dark if --theme includes dark. Remove worktree when done. +``` + +### 4. Auto-diff (surface what changed) +``` +cd frontend/editor && node /diff-shots.mjs \ + screenshots/ui-diff/before screenshots/ui-diff/after screenshots/ui-diff +``` +Produces `diff-report.json` classifying each view `unchanged | changed | added | +removed`. For each changed view it computes the bounding box of differing pixels and +writes cropped `__before_crop.png` / `__after_crop.png` / `__diff.png` to that region +(+ padding) - **unless** the change covers more than `--pagewide` of the frame, where +it keeps the full frame (`pageWide:true`). Drop `unchanged` - that's the noise the +user doesn't want. + +### 5. Montage the changes +Build the manifest from the non-unchanged entries (group by tab/tool; each becomes a +state row with before/after). For changed views use the cropped `cropBefore` / +`cropAfter` from `diff-report.json` (tight on the affected region; full frame when +`pageWide`); `added`/`removed` render the "not present" placeholder. Fill +[montage-template.html](montage-template.html) (replace the `window.__BA__` data +block; base64-inline the PNGs for portability), then render one PNG per section with +[shoot-sections.mjs](shoot-sections.mjs). Optionally include the `__diff.png` overlay +as a third column. + +### 6. Deliver +Output the `montage_.png` files + a short summary (N changed / added / removed, +M unchanged skipped) and a paste-ready Markdown block. GitHub has no PR-body image +API, so tell the user to drag the PNGs into the description. Do **not** post to the +PR. + +## Gotchas +- Two installs (base worktree + head); junction main's node_modules only if its deps + match that ref, else `npm ci` (see ui-walkthrough's stale-dep note). +- A view that errors on one side (refactored/removed) → that side is missing; the + diff marks it added/removed rather than failing the run. +- Pixel diff needs equal dimensions, so capture at a fixed viewport (the template + does); a view whose size changed is reported as "changed (dimensions differ)", + uncropped. +- Auto-crop uses a single bounding box, so two far-apart changes give one large crop + (or trip `--pagewide`); narrow with `--scope` if that happens. +- `getToolUrlPath` is the source of truth for tool URLs - use it, don't guess slugs. +- Don't commit `screenshots/`, the throwaway spec, or the base worktree. diff --git a/.claude/skills/ui-before-after/capture-spec.template.ts b/.claude/skills/ui-before-after/capture-spec.template.ts new file mode 100644 index 0000000000..fd8882915b --- /dev/null +++ b/.claude/skills/ui-before-after/capture-spec.template.ts @@ -0,0 +1,67 @@ +// Generic before/after capturer. NOT app-specific: it walks a targets.json that +// the ui-before-after skill generates from the branch/PR diff, so nothing here is +// hand-listed. Copy to src/core/tests/stubbed/ui-before-after.spec.ts, then run +// once per (side, theme): +// PR_SHOT_SIDE=after PR_SHOT_THEME=light \ +// npx playwright test --project=stubbed ui-before-after.spec.ts +// +// targets.json shape: [{ "id":"compress", "url":"/compress", "name":"Compress", +// "needsFile": true }] +import { test } from "@app/tests/helpers/stub-test-base"; +import type { Page } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; + +const SIDE = process.env.PR_SHOT_SIDE ?? "after"; +const THEME = process.env.PR_SHOT_THEME ?? "light"; +// Capture the full viewport by default so the affected region is in frame +// wherever it is; diff-shots.mjs crops each comparison to what actually changed. +// Set PR_SHOT_SCOPE to a selector to narrow the capture to one container. +const SCOPE = process.env.PR_SHOT_SCOPE ?? ""; +const ROOT = path.resolve(process.cwd(), "screenshots", "ui-diff"); +const OUT = path.join(ROOT, SIDE); +// A tiny sample PDF so file-dependent tool panels render. Point at a real fixture. +const SAMPLE_PDF = process.env.PR_SHOT_SAMPLE ?? "src/core/tests/test-fixtures/sample.pdf"; + +type Target = { id: string; url: string; name?: string; needsFile?: boolean }; +const targets: Target[] = JSON.parse(fs.readFileSync(path.join(ROOT, "targets.json"), "utf-8")); + +test.use({ autoGoto: false, viewport: { width: 1600, height: 900 }, seedJwt: true }); + +async function applyTheme(page: Page): Promise { + if (THEME !== "dark") return; + await page.addInitScript(() => { + localStorage.setItem("mantine-color-scheme", "dark"); + localStorage.setItem("mantine-color-scheme-value", "dark"); + }); + await page.emulateMedia({ colorScheme: "dark" }); +} + +async function seedFile(page: Page): Promise { + if (!fs.existsSync(SAMPLE_PDF)) return; + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("files-button").click().catch(() => {}); + await page.locator('[data-testid="file-input"]').setInputFiles(SAMPLE_PDF).catch(() => {}); + await page.locator(".file-sidebar-file-item").first().isVisible({ timeout: 8_000 }).catch(() => {}); +} + +for (const t of targets) { + // One test per target so a single failure doesn't drop the rest. + test(`${SIDE}/${THEME} ${t.id}`, async ({ page }) => { + fs.mkdirSync(OUT, { recursive: true }); + await applyTheme(page); + if (t.needsFile !== false) await seedFile(page); + await page.goto(t.url, { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(400); // settle Mantine portals/transitions + const shot = path.join(OUT, `${t.id}__${THEME}.png`); + if (SCOPE) { + const scope = page.locator(SCOPE).first(); + if (await scope.isVisible({ timeout: 8_000 }).catch(() => false)) { + await scope.screenshot({ path: shot }); + return; + } + } + // Full viewport (fixed size → stable dimensions for pixel diffing). + await page.screenshot({ path: shot }); + }); +} diff --git a/.claude/skills/ui-before-after/diff-shots.mjs b/.claude/skills/ui-before-after/diff-shots.mjs new file mode 100644 index 0000000000..94ff5e2aad --- /dev/null +++ b/.claude/skills/ui-before-after/diff-shots.mjs @@ -0,0 +1,106 @@ +// Auto-diff before/ vs after/ screenshots, classify each as +// unchanged | changed | added | removed, and CROP each changed pair to the +// affected region (bounding box of differing pixels + padding) - unless the +// change spans most of the page, in which case the full frame is kept. +// Run from frontend/editor (so deps resolve): +// node /diff-shots.mjs [outDir] +// Env: +// DIFF_THRESHOLD min fraction of differing pixels to count as changed (default 0.001) +// DIFF_PAD padding px around the affected region (default 24) +// DIFF_PAGEWIDE if affected bbox area / image area exceeds this, keep full frame (default 0.6) +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; + +const require = createRequire(path.join(process.cwd(), "noop.js")); +const pm = require("pixelmatch"); +const pixelmatch = pm.default || pm; +const { PNG } = require("pngjs"); + +const beforeDir = path.resolve(process.argv[2]); +const afterDir = path.resolve(process.argv[3]); +const outDir = path.resolve(process.argv[4] || afterDir); +const THRESHOLD = Number(process.env.DIFF_THRESHOLD ?? "0.001"); +const PAD = Number(process.env.DIFF_PAD ?? "24"); +const PAGEWIDE = Number(process.env.DIFF_PAGEWIDE ?? "0.6"); + +const read = (p) => PNG.sync.read(fs.readFileSync(p)); +const isShot = (f) => f.endsWith(".png") && !/__(diff|before_crop|after_crop)\.png$/.test(f); +const list = (d) => (fs.existsSync(d) ? fs.readdirSync(d).filter(isShot) : []); +const names = [...new Set([...list(beforeDir), ...list(afterDir)])].sort(); +fs.mkdirSync(outDir, { recursive: true }); + +function cropPNG(src, x, y, w, h) { + const out = new PNG({ width: w, height: h }); + PNG.bitblt(src, out, x, y, w, h, 0, 0); + return out; +} +const writePNG = (p, png) => fs.writeFileSync(p, PNG.sync.write(png)); + +// Bounding box of differing pixels using a diff mask (alpha>0 where changed). +function changedBBox(before, after, w, h) { + const mask = new PNG({ width: w, height: h }); + pixelmatch(before.data, after.data, mask.data, w, h, { threshold: 0.1, diffMask: true }); + let minX = w, minY = h, maxX = -1, maxY = -1, count = 0; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + if (mask.data[(y * w + x) * 4 + 3] > 0) { + count++; + if (x < minX) minX = x; if (x > maxX) maxX = x; + if (y < minY) minY = y; if (y > maxY) maxY = y; + } + } + } + return maxX < 0 ? null : { minX, minY, maxX, maxY, count }; +} + +const report = []; +for (const name of names) { + const id = name.replace(/\.png$/, ""); + const bp = path.join(beforeDir, name), ap = path.join(afterDir, name); + const hasB = fs.existsSync(bp), hasA = fs.existsSync(ap); + if (hasB && !hasA) { report.push({ id, status: "removed", before: bp }); continue; } + if (!hasB && hasA) { report.push({ id, status: "added", after: ap }); continue; } + + const before = read(bp), after = read(ap); + if (before.width !== after.width || before.height !== after.height) { + report.push({ id, status: "changed", note: "dimensions differ", before: bp, after: ap }); + continue; + } + const w = after.width, h = after.height; + const overlay = new PNG({ width: w, height: h }); + const px = pixelmatch(before.data, after.data, overlay.data, w, h, { threshold: 0.1 }); + const ratio = px / (w * h); + if (ratio <= THRESHOLD) { report.push({ id, status: "unchanged", ratio: Number(ratio.toFixed(5)), before: bp, after: ap }); continue; } + + const box = changedBBox(before, after, w, h); + // Pad + clamp the affected region. + const x = Math.max(0, box.minX - PAD), y = Math.max(0, box.minY - PAD); + const x2 = Math.min(w, box.maxX + 1 + PAD), y2 = Math.min(h, box.maxY + 1 + PAD); + const bw = x2 - x, bh = y2 - y; + const pageWide = (bw * bh) / (w * h) > PAGEWIDE; + + const entry = { id, status: "changed", ratio: Number(ratio.toFixed(5)), before: bp, after: ap, pageWide }; + if (pageWide) { + // Change spans most of the page - keep the full frame, full overlay. + const dp = path.join(outDir, `${id}__diff.png`); writePNG(dp, overlay); + entry.diff = dp; + } else { + entry.bbox = { x, y, w: bw, h: bh }; + const cb = path.join(outDir, `${id}__before_crop.png`); writePNG(cb, cropPNG(before, x, y, bw, bh)); + const ca = path.join(outDir, `${id}__after_crop.png`); writePNG(ca, cropPNG(after, x, y, bw, bh)); + const dp = path.join(outDir, `${id}__diff.png`); writePNG(dp, cropPNG(overlay, x, y, bw, bh)); + entry.cropBefore = cb; entry.cropAfter = ca; entry.diff = dp; + } + report.push(entry); +} + +fs.writeFileSync(path.join(outDir, "diff-report.json"), JSON.stringify(report, null, 2)); +const changed = report.filter((r) => r.status !== "unchanged"); +console.log(`diffed ${report.length} view(s): ${changed.length} changed/added/removed, ${report.length - changed.length} unchanged`); +for (const r of changed) { + const tail = r.status !== "changed" ? "" + : r.pageWide ? " (page-wide → full frame)" + : ` (${(r.ratio * 100).toFixed(2)}%, cropped to ${r.bbox.w}×${r.bbox.h})`; + console.log(` ${r.status.padEnd(9)} ${r.id}${tail}${r.note ? " - " + r.note : ""}`); +} diff --git a/.claude/skills/ui-before-after/make_example.py b/.claude/skills/ui-before-after/make_example.py new file mode 100644 index 0000000000..ec5eeee029 --- /dev/null +++ b/.claude/skills/ui-before-after/make_example.py @@ -0,0 +1,48 @@ +"""Build EXAMPLE.html from montage-template.html using REAL files-page shots as +stand-in before/after pairs (layout demo, not an actual PR diff). Inlines PNGs as +data URIs so the HTML is portable. Run: python make_example.py""" +import base64 +import json +import pathlib +import re + +HERE = pathlib.Path(__file__).parent +SHOTS = pathlib.Path( + r"C:\Users\systo\git\Stirling-PDFNew\.claude\worktrees\kind-faraday-522a30" + r"\frontend\editor\screenshots\files-page" +) + + +def uri(fname): + p = SHOTS / fname + return "data:image/png;base64," + base64.b64encode(p.read_bytes()).decode() if p.exists() else None + + +data = { + "pr": "DEMO", + "title": "EXAMPLE — before/after montage (layout demo, real Files-page shots; not a real PR diff)", + "base": "main", "head": "demo-branch", + "cropSelector": "[data-sidebar=\"tool-panel\"] (real runs crop to the side; these demo shots are full-page)", + "tabs": [ + {"id": "files", "title": "Files page", "ctx": "Each row = one flow state; left = base branch, right = this PR.", + "states": [ + {"name": "Empty folder", "before": uri("01_empty_state_ctas.png"), "after": uri("02_empty_state_storage_off.png")}, + {"name": "Files + details panel", "before": uri("03_subtoolbar_with_files.png"), "after": uri("06_details_panel_save_to_server.png")}, + {"name": "Delete folder confirm", "before": None, "after": uri("19_delete_folder_dialog.png"), "note": "New in this PR"}, + ]}, + {"id": "move", "title": "Move-to-folder dialog", + "states": [ + {"name": "Dialog opened", "before": uri("07_move_dialog_collapsed.png"), "after": uri("08_move_dialog_create_folder_expanded.png")}, + {"name": "After folder created", "before": None, "after": uri("08b_move_dialog_after_create_folder.png"), "note": "New flow"}, + ]}, + ], +} + +tpl = (HERE / "montage-template.html").read_text(encoding="utf-8") +out = re.sub( + r"/\*__DATA__\*/.*?/\*__END__\*/", + lambda _m: "/*__DATA__*/" + json.dumps(data) + "/*__END__*/", + tpl, count=1, flags=re.S, +) +(HERE / "EXAMPLE.html").write_text(out, encoding="utf-8") +print("wrote", HERE / "EXAMPLE.html", "(", (HERE / "EXAMPLE.html").stat().st_size // 1024, "KB )") diff --git a/.claude/skills/ui-before-after/montage-template.html b/.claude/skills/ui-before-after/montage-template.html new file mode 100644 index 0000000000..ca0f69d4cf --- /dev/null +++ b/.claude/skills/ui-before-after/montage-template.html @@ -0,0 +1,106 @@ + + + + + +Before / After + + + +
+ + + + + diff --git a/.claude/skills/ui-before-after/shoot-sections.mjs b/.claude/skills/ui-before-after/shoot-sections.mjs new file mode 100644 index 0000000000..504e531998 --- /dev/null +++ b/.claude/skills/ui-before-after/shoot-sections.mjs @@ -0,0 +1,25 @@ +// Render each .tab-section of a montage HTML into its own PNG (the PR-ready image). +// Run from frontend/editor (so @playwright/test resolves): +// node /shoot-sections.mjs +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { createRequire } from "node:module"; + +const require = createRequire(path.join(process.cwd(), "noop.js")); +const { chromium } = require("@playwright/test"); + +const htmlPath = path.resolve(process.argv[2]); +const outDir = path.resolve(process.argv[3] || path.dirname(htmlPath)); + +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: 1200, height: 1200 }, deviceScaleFactor: 2 }); +await page.goto(pathToFileURL(htmlPath).href, { waitUntil: "load" }); +await page.waitForTimeout(250); // let images/fonts paint +const ids = await page.$$eval(".tab-section", (els) => els.map((e) => e.id)); +if (!ids.length) { console.error("no .tab-section found"); process.exit(1); } +for (const id of ids) { + const name = id.replace(/^section-/, ""); + await page.locator("#" + id).screenshot({ path: path.join(outDir, `montage_${name}.png`) }); + console.log("wrote montage_" + name + ".png"); +} +await browser.close(); diff --git a/.claude/skills/ui-walkthrough/SKILL.md b/.claude/skills/ui-walkthrough/SKILL.md new file mode 100644 index 0000000000..ef6d4b9dec --- /dev/null +++ b/.claude/skills/ui-walkthrough/SKILL.md @@ -0,0 +1,120 @@ +--- +name: ui-walkthrough +description: >- + Full UI investigation of the current branch's feature. Enumerates every view + and state (empty, populated, loading, error, each dialog/menu/panel, responsive + breakpoints, light + dark + RTL), captures them with the stubbed Playwright + harness, assembles a single-image HTML walkthrough with a global light/dark + toggle slider, then runs two review passes: visual/consistency (alignment, + spacing, professionalism, dark/light parity, contrast, truncation) and + UX/ease-of-use (flow, discoverability, affordances, empty/error states, + expectations). Use when asked for a UI walkthrough, screenshot review, design + or QA pass, "find anywhere to make it easier/better for users", or before + merging frontend work. Pass --fix to auto-apply safe frontend fixes and + re-capture; --theme to limit themes; --no-rtl to skip RTL. +argument-hint: "[feature/area] [--fix] [--theme light|dark|both] [--no-rtl] [--breakpoints]" +allowed-tools: Read, Write, Edit, Glob, Grep, Bash +--- + +# UI Walkthrough + +Produce a reviewable HTML walkthrough of a feature's UI in every state and theme, +then critique it. Optionally auto-fix and re-capture. + +`$ARGUMENTS` may name the feature/area to focus on. If empty, scope from the +current branch diff. Flags: `--fix`, `--theme light|dark|both` (default both), +`--no-rtl`, `--breakpoints` (also capture phone/narrow widths). + +## What this repo gives you (use it, don't reinvent) + +- **Stubbed Playwright project** = backend-free screenshots via `page.route()` mocks. + Reference implementation: `frontend/editor/src/core/tests/stubbed/files-page-screenshots.spec.ts`. + It already shows the light / **dark** / **RTL** passes, JWT seeding, IndexedDB + seeding, and dumping PNGs to a `screenshots//` folder. Copy its shape. +- Helpers: `frontend/editor/src/core/tests/helpers/ui-helpers.ts` + (`uploadFiles`, `openSettings`, `waitForModalOpen`, `dismissTourTooltip`, …) + and the `stub-test-base` fixtures (`autoGoto`, `seedJwt`, `viewport`). +- Config: `frontend/editor/playwright.config.ts` (run from `frontend/editor/`). +- Report template: [report-template.html](report-template.html) - self-contained, + one big image at a time, a global light/dark slider that flips every shot, + thumbnail rail, prev/next + arrow keys, and a Findings tab. + +## Process + +### 1. Scope the feature +- If `$ARGUMENTS` is empty: `git diff --name-only main...HEAD` and read the PR/commits. + Identify changed pages, tools (`core/components/tools/` or `core/tools/`), + dialogs, panels, and routes. +- Enumerate **every view and state** to capture, e.g.: + empty / populated / loading / error / disabled; each dialog, menu, popover, tooltip; + each tab or step; selection + multi-select; success/result panel; and (if relevant) + permission/role variants. Write the list down before capturing - it's the report's spine. + +### 2. Prepare the harness (worktree-safe) +Worktrees have no `node_modules` and no generated icons. From repo root: +``` +cd frontend && npm ci # or junction main's node_modules (see memory) +cd frontend/editor && node scripts/generate-icons.js +``` +Kill any stale dev server first (it serves old modules): +`Get-NetTCPConnection -LocalPort 5173 -State Listen | %{ Stop-Process -Id $_.OwningProcess -Force }` + +### 3. Write the capture spec +Create `frontend/editor/src/core/tests/stubbed/-walkthrough.spec.ts`, +modeled on `files-page-screenshots.spec.ts`. For each enumerated view: +- stub the APIs it needs, drive the UI to that state, wait on a real locator + (not a fixed sleep), `await settle(page)` for Mantine portals, then + `page.screenshot({ path: shotPath("NN_name_") })`. +- Capture each view in **light and dark** (and RTL unless `--no-rtl`). Reuse the + `enableDarkMode` / `enableRtl` init-script pattern from the reference spec + (`localStorage["mantine-color-scheme"]="dark"` + `emulateMedia({colorScheme:"dark"})`). +- Name shots `NN__.png` so light/dark pair up by suffix. +- Prefer **stable test-ids** over translated accessible names (RTL/i18n breaks text locators). + +Run it: `cd frontend/editor && npx playwright test --project=stubbed -walkthrough.spec.ts`. +Add `--project=stubbed-firefox`/`-webkit` only if cross-browser layout matters. + +### 4. Build the report +- Copy `report-template.html` to `screenshots//walkthrough.html` (so the + relative `screenshots/...` image paths resolve, or rewrite paths to sit beside it). +- Build the manifest and inject it: replace the JSON between the + `/*__DATA__*/` … `/*__END__*/` markers with one `views[]` entry per view + (`{id,title,light,dark,viewport,notes}`) and an empty `findings` object you'll + fill in step 5. Keep `light`/`dark` as relative paths. +- The toggle slider answers the "one big image + flip light/dark for all" request: + it shows a single large screenshot, and switching the slider re-themes every view. + +### 5. Review pass 1 - visual & consistency +Open each screenshot (Read the PNG) and judge against the others: +alignment & spacing rhythm, control placement, button hierarchy, typography, +**light/dark parity** (contrast, invisible borders, washed-out text, wrong tokens), +truncation/overflow, RTL mirroring, focus states, icon consistency, professional polish. +Record each issue as a finding `{severity:high|med|low, view, title, detail, fix}`. + +### 6. Review pass 2 - UX & ease of use +Walk the flow as a first-time user: discoverability, number of steps, affordance +clarity, empty-state guidance, error recovery, destructive-action confirmation, +defaults, loading feedback, mobile reachability, accessible names, and whether the +UI matches user expectations for this kind of tool. Record findings the same way. + +Write both finding lists into the report's `findings.visual` / `findings.ux`, +and add short per-view `notes`. Re-inject the manifest. + +### 7. If `--fix` +Only safe, self-contained frontend fixes (spacing, alignment, tokens, missing +dark-mode colors, labels, aria, obvious copy). For each: edit the component/CSS, +mark the finding `fixed:true` with what changed, then **re-run the spec** to +re-capture the affected shots and regenerate the report. Run `task frontend:check`. +Leave anything risky or ambiguous as a finding, not a change. + +### 8. Deliver +Tell the user the report path and give a tight chat summary: N views × +themes captured, top findings by severity, and (if `--fix`) what changed. +Optionally `SendUserFile` the `walkthrough.html`. + +## Gotchas +- Stale `:5173` server serves old bundles - kill it before capturing (see step 2). +- Missing `material-symbols-icons.json` → blank app → every shot times out. Run + `generate-icons.js` first. +- `await settle(page)` before shots or portals/transitions tear mid-capture. +- Don't commit the generated `screenshots/` or the throwaway spec unless asked. diff --git a/.claude/skills/ui-walkthrough/make_example.py b/.claude/skills/ui-walkthrough/make_example.py new file mode 100644 index 0000000000..b65c2a010a --- /dev/null +++ b/.claude/skills/ui-walkthrough/make_example.py @@ -0,0 +1,116 @@ +"""Build a self-contained EXAMPLE.html from report-template.html with mock +light/dark screenshots, so the viewer + global theme slider can be demoed +without a real capture run. Run: python make_example.py""" +import base64 +import json +import pathlib +import re + +HERE = pathlib.Path(__file__).parent + + +def svg(bg, fg, panel, accent, muted, label, kind): + """A simple fake 'screen' SVG: title bar, sidebar, content varies by kind.""" + parts = [ + f'', + f'', + # top bar + f'', + f'', + f'', + f'', + # left sidebar + f'', + ] + for i in range(6): + y = 100 + i * 56 + parts.append(f'') + if kind == "empty": + parts += [ + f'', + f'', + f'{label}', + ] + elif kind == "form": + for i in range(4): + y = 140 + i * 90 + parts.append(f'') + parts.append(f'') + parts.append(f'') + parts.append(f'{label}') + else: # dialog + parts += [ + f'', + f'', + f'', + f'', + f'', + f'', + f'', + f'{label}', + ] + parts.append("") + return "".join(parts) + + +def data_uri(s): + return "data:image/svg+xml;base64," + base64.b64encode(s.encode()).decode() + + +LIGHT = dict(bg="#ffffff", fg="#111418", panel="#f1f3f6", accent="#2f6fed", muted="#c2c8d0") +DARK = dict(bg="#16181c", fg="#000000", panel="#1f232a", accent="#5b8cff", muted="#3a414b") + + +def pair(kind, label): + return ( + data_uri(svg(LIGHT["bg"], LIGHT["fg"], LIGHT["panel"], LIGHT["accent"], LIGHT["muted"], label, kind)), + data_uri(svg(DARK["bg"], DARK["fg"], DARK["panel"], DARK["accent"], DARK["muted"], label, kind)), + ) + + +views = [] +for idx, (kind, title, label) in enumerate([ + ("empty", "Empty state", "Drop a PDF to start"), + ("form", "Tool options panel", "Compress options"), + ("dialog", "Confirm dialog", "Replace original file?"), +], start=1): + light, dark = pair(kind, label) + views.append({ + "id": f"{idx:02d}_{kind}", + "title": title, + "light": light, + "dark": dark, + "viewport": "1600x900", + "notes": ["This is mock data to demo the viewer."], + }) + +data = { + "feature": "EXAMPLE - Compress PDF (mock data)", + "branch": "demo", + "generated": "example", + "views": views, + "findings": { + "visual": [ + {"severity": "high", "view": "03_dialog", "title": "Dialog buttons too close", + "detail": "Cancel/Confirm have only 8px gap; easy to misclick.", + "fix": "Increase gap to var(--mantine-spacing-md)."}, + {"severity": "low", "view": "02_form", "title": "Field labels low contrast in dark mode", + "detail": "Muted token fails WCAG AA on the dark panel.", + "fix": "Use --mantine-color-dimmed instead of a hard-coded grey."}, + ], + "ux": [ + {"severity": "med", "view": "01_empty", "title": "Primary CTA below the dropzone", + "detail": "Users expect the action button adjacent to the dropzone.", + "fix": "Move the button directly under the dashed zone."}, + ], + }, +} + +tpl = (HERE / "report-template.html").read_text(encoding="utf-8") +out = re.sub( + r"/\*__DATA__\*/.*?/\*__END__\*/", + lambda _m: "/*__DATA__*/" + json.dumps(data) + "/*__END__*/", + tpl, count=1, flags=re.S, +) +(HERE / "EXAMPLE.html").write_text(out, encoding="utf-8") +print("wrote", (HERE / "EXAMPLE.html")) diff --git a/.claude/skills/ui-walkthrough/report-template.html b/.claude/skills/ui-walkthrough/report-template.html new file mode 100644 index 0000000000..3e96216a3b --- /dev/null +++ b/.claude/skills/ui-walkthrough/report-template.html @@ -0,0 +1,298 @@ + + + + + + +UI Walkthrough + + + +
+
+

UI Walkthrough

+
+
+
+
+ + +
+
+ +
+ +
+ +
+
+ + +
+ +
+
+

+
+
    +
    +
    +
    + +
    + + + + + diff --git a/.github/aur/stirling-pdf-desktop/PKGBUILD b/.github/aur/stirling-pdf-desktop/PKGBUILD index c02bde345e..5d92425b19 100644 --- a/.github/aur/stirling-pdf-desktop/PKGBUILD +++ b/.github/aur/stirling-pdf-desktop/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Stirling PDF Inc pkgname=stirling-pdf-desktop -pkgver=2.14.0 +pkgver=2.14.1 pkgrel=1 pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)" arch=('x86_64') diff --git a/.github/aur/stirling-pdf-server-bin/PKGBUILD b/.github/aur/stirling-pdf-server-bin/PKGBUILD index c73b1c5087..f5a2bf3c6c 100644 --- a/.github/aur/stirling-pdf-server-bin/PKGBUILD +++ b/.github/aur/stirling-pdf-server-bin/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Stirling PDF Inc pkgname=stirling-pdf-server-bin -pkgver=2.14.0 +pkgver=2.14.1 pkgrel=1 pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)" arch=('any') diff --git a/.github/config/.files.yaml b/.github/config/.files.yaml index 2d617f8f20..5e85976937 100644 --- a/.github/config/.files.yaml +++ b/.github/config/.files.yaml @@ -87,6 +87,21 @@ engine: &engine - Taskfile.yml - .taskfiles/engine.yml +# Files that can make the committed generated API models (frontend tool API +# types + engine tool models) go stale: the Java tool surfaces they derive from, +# the generators, the generated files themselves (to catch a hand-edit), and the +# tasks that drive generation. Deliberately excludes the broad frontend/docker/ +# testing globs, so a CSS-only PR does not boot the backend to rebuild the spec. +generated-models: &generated-models + - *openapi + - frontend/editor/scripts/generate-tool-api-types.mts + - frontend/editor/src/core/types/toolApiTypes.ts + - engine/scripts/generate_tool_models.py + - engine/src/stirling/models/tool_models.py + - .taskfiles/frontend.yml + - .taskfiles/engine.yml + - .github/workflows/check-generated-models.yml + licenses-frontend: &licenses-frontend - ".github/workflows/frontend-backend-licenses-update.yml" - "frontend/package.json" diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index b6ab8d34c8..31135cfb4b 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -116,6 +116,9 @@ jobs: env: USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }} DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }} + # Single source of truth for whether this preview embeds the admin portal: + # drives the image build-arg and the deployment comment. + BUILD_PORTAL: "true" steps: - name: Harden Runner @@ -246,7 +249,9 @@ jobs: file: ./docker/embedded/Dockerfile push: true tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }} - build-args: VERSION_TAG=v2-alpha + build-args: | + VERSION_TAG=v2-alpha + BUILD_PORTAL=${{ env.BUILD_PORTAL }} platforms: linux/amd64 - name: Build and push V2 image (Docker fork fallback) @@ -259,7 +264,9 @@ jobs: cache-from: type=gha,scope=stirling-pdf-latest cache-to: type=gha,mode=max,scope=stirling-pdf-latest tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }} - build-args: VERSION_TAG=v2-alpha + build-args: | + VERSION_TAG=v2-alpha + BUILD_PORTAL=${{ env.BUILD_PORTAL }} platforms: linux/amd64 - name: Set up SSH @@ -290,6 +297,8 @@ jobs: - /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/storage:/storage:rw environment: DISABLE_ADDITIONAL_FEATURES: "false" + POLICIES_ENABLED: "true" + STIRLING_BILLING_ACCOUNT_LINK_ENABLED: "true" SECURITY_ENABLELOGIN: "true" SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}" SECURITY_INITIALLOGIN_PASSWORD: "${{ secrets.TEST_LOGIN_PASSWORD }}" @@ -359,12 +368,19 @@ jobs: } const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${v2Port}`; - const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`; + + // Only mention the portal when this image actually embeds it. + // Use the direct IP URL - the SSL hostname isn't supported yet. + const withPortal = "${{ env.BUILD_PORTAL }}" === "true"; + const portalNote = withPortal + ? `🧩 **Admin portal** included - try it at [${deploymentUrl}/portal](${deploymentUrl}/portal).\n\n` + : ``; const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` + `Your V2 PR with embedded architecture has been deployed!\n\n` + `🔗 **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` + - `🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` + + `🔐 **Secure HTTPS URL**: unsupported currently\n\n` + + portalNote + `_This deployment will be automatically cleaned up when the PR is closed._\n\n` + `🔄 **Auto-deployed** for approved V2 contributors.`; diff --git a/.github/workflows/ai-engine.yml b/.github/workflows/ai-engine.yml index 223490f45f..554100f535 100644 --- a/.github/workflows/ai-engine.yml +++ b/.github/workflows/ai-engine.yml @@ -1,9 +1,9 @@ name: AI Engine CI -# 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. +# 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. Freshness of the generated tool_models.py is checked +# by the shared check-generated-models workflow. on: workflow_call: push: @@ -34,104 +34,9 @@ jobs: with: enable-cache: true - - name: Set up JDK 25 - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 - with: - java-version: "25" - distribution: "temurin" - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 - with: - gradle-version: 9.6.0 - - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 - - name: Regenerate tool models - 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 = ''; - 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: | - 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: 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 = ''; - 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: Quality-check engine id: engine-check run: task engine:check diff --git a/.github/workflows/build-enterprise.yml b/.github/workflows/build-enterprise.yml index d29002ac37..9e1a4efb83 100644 --- a/.github/workflows/build-enterprise.yml +++ b/.github/workflows/build-enterprise.yml @@ -2,7 +2,7 @@ name: Enterprise E2E (Playwright) # Enterprise Playwright suite — exercises premium-key gated features (audit, # teams, analytics) plus full OAuth + SAML logins via the Keycloak compose -# stacks under testing/compose. Slow and secret-gated, so it runs in three +# stacks under testing/compose. Slow and secret-gated, so it runs in four # situations: # # - PRs that touch proprietary / premium / SSO compose / enterprise tests @@ -12,8 +12,6 @@ name: Enterprise E2E (Playwright) # - on a nightly cron schedule (catches Keycloak image drift, license # expiry, upstream proprietary changes), # - manual workflow_dispatch. -# -# Auto-skipped when secrets.PREMIUM_KEY_ENTERPRISE is missing (forks, dependabot). on: workflow_call: @@ -52,6 +50,10 @@ jobs: playwright-e2e-enterprise: needs: pick + # Skip on fork PRs / untrusted authors: they have no PREMIUM_KEY_ENTERPRISE + # (nor DEPOT_TOKEN), so the suite can't boot premium and would fail. See the + # header comment. GitHub reports the skipped reusable workflow as success. + if: needs.pick.outputs.is_fork != 'true' runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }} timeout-minutes: 45 env: @@ -165,6 +167,8 @@ jobs: wait_for_backend - name: Run enterprise OAuth Playwright tests id: oauth-tests + env: + PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-oauth.json run: task e2e:enterprise -- --grep "OAuth" - name: Stop backend + tear down OAuth Keycloak if: always() @@ -238,6 +242,8 @@ jobs: wait_for_backend - name: Run enterprise SAML Playwright tests id: saml-tests + env: + PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-saml.json run: task e2e:enterprise -- --grep "SAML" - name: Stop backend + tear down SAML Keycloak if: always() @@ -268,6 +274,8 @@ jobs: wait_for_backend - name: Run enterprise feature Playwright tests id: feature-tests + env: + PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-feature.json run: task e2e:enterprise -- --grep "Enterprise license" - name: Print backend log on failure if: failure() @@ -280,10 +288,23 @@ jobs: run: | source /tmp/helpers.sh stop_backend + - name: Flag flaky tests + # Runs regardless of the test outcomes: a flaky test (passed on retry) + # leaves its step green, so this is the only place it surfaces. Merges + # all three phase reports (some may be absent if an earlier phase hard- + # failed and skipped the rest). Emits ::warning:: annotations + a job + # summary; never fails the job. + if: always() + working-directory: frontend + run: > + npx tsx editor/scripts/report-flaky-tests.mts + "${{ github.workspace }}/frontend/playwright-report/results-oauth.json" + "${{ github.workspace }}/frontend/playwright-report/results-saml.json" + "${{ github.workspace }}/frontend/playwright-report/results-feature.json" - name: Upload Playwright report if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: playwright-report-enterprise-${{ github.run_id }} - path: frontend/editor/playwright-report/ + path: frontend/playwright-report/ retention-days: 7 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7018a38120..8142c37fca 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -43,6 +43,7 @@ jobs: docker-base: ${{ steps.changes.outputs.docker-base }} tauri: ${{ steps.changes.outputs.tauri }} engine: ${{ steps.changes.outputs.engine }} + generated-models: ${{ steps.changes.outputs.generated-models }} proprietary: ${{ steps.changes.outputs.proprietary }} steps: - name: Harden the runner (Audit all outbound calls) @@ -171,6 +172,20 @@ jobs: uses: ./.github/workflows/ai-engine.yml secrets: inherit + # The generated frontend types and engine tool models are both derived from + # the Java OpenAPI spec. This job regenerates and diffs them; it boots the + # backend, so it is gated on the narrow generated-models filter (spec source, + # generators, generated files, generation tasks) rather than the broad + # frontend filter, so a CSS-only PR does not pay for a backend build. + generated-models: + if: needs.files-changed.outputs.generated-models == 'true' + needs: [files-changed] + permissions: + contents: read + pull-requests: write + uses: ./.github/workflows/check-generated-models.yml + secrets: inherit + pre-commit: needs: [files-changed] permissions: @@ -202,6 +217,9 @@ jobs: contents: read uses: ./.github/workflows/coverage-aggregate.yml secrets: inherit + with: + frontend-validation-result: ${{ needs.frontend-validation.result }} + playwright-e2e-live-result: ${{ needs.playwright-e2e-live.result }} # Single status check that branch protection should mark as required. # Succeeds when every upstream job is either `success` or `skipped` (path- @@ -225,6 +243,7 @@ jobs: - test-build-docker-images - tauri-build - ai-engine + - generated-models - pre-commit - dependency-review runs-on: ubuntu-latest @@ -250,6 +269,7 @@ jobs: test-build-docker-images=${{ needs.test-build-docker-images.result }} tauri-build=${{ needs.tauri-build.result }} ai-engine=${{ needs.ai-engine.result }} + generated-models=${{ needs.generated-models.result }} pre-commit=${{ needs.pre-commit.result }} dependency-review=${{ needs.dependency-review.result }} run: | diff --git a/.github/workflows/check-generated-models.yml b/.github/workflows/check-generated-models.yml new file mode 100644 index 0000000000..db9c49fba2 --- /dev/null +++ b/.github/workflows/check-generated-models.yml @@ -0,0 +1,148 @@ +name: Check generated models + +# Verifies the committed generated API models are still in sync with the Java +# OpenAPI spec: the frontend tool API types +# (frontend/editor/src/core/types/toolApiTypes.ts) and the engine tool +# models (engine/src/stirling/models/tool_models.py). Regenerates both with the +# single top-level `task tool-models` and fails if either committed file is +# out of date. Called from build.yml when the backend Java, frontend, or engine +# changes; also runs on push to main as a post-merge safety net. +on: + workflow_call: + push: + branches: [main] + +permissions: + contents: read + +jobs: + generated-models: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + env: + DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + egress-policy: audit + + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + + - name: Set up JDK 25 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + java-version: "25" + distribution: "temurin" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + with: + gradle-version: 9.6.0 + + - name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install Task + uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + + # Rebuilds the OpenAPI spec from the current Java and regenerates both the + # frontend types and the engine tool models from it. + - name: Regenerate generated models + run: task tool-models + + - name: Verify generated models are up to date + id: models-check + continue-on-error: true + run: | + git diff --exit-code \ + frontend/editor/src/core/types/toolApiTypes.ts \ + engine/src/stirling/models/tool_models.py + + - name: Comment on generated 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.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 = ''; + const body = [ + marker, + '### Generated Models Check Failed', + '', + 'The generated `frontend/editor/src/core/types/toolApiTypes.ts` and/or `engine/src/stirling/models/tool_models.py` are out of date with the Java OpenAPI spec and will need to be regenerated before they can be merged in.', + '', + 'Run `task tool-models` to regenerate both, then commit the updated files.', + ].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 generated models check failed + if: steps.models-check.outcome == 'failure' + run: | + echo "============================================" + echo " Generated Models Check Failed" + echo "============================================" + echo "" + echo "The generated frontend API types and/or engine tool" + echo "models are out of date with the Java OpenAPI spec and" + echo "will need to be regenerated before they can be merged in." + echo "" + echo "Run 'task tool-models' to regenerate both, then" + echo "commit the updated files." + echo "============================================" + exit 1 + + - name: Remove generated models check comment on success + if: steps.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 = ''; + 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, + }); + } diff --git a/.github/workflows/coverage-aggregate.yml b/.github/workflows/coverage-aggregate.yml index bbfaf09363..ce792bcadc 100644 --- a/.github/workflows/coverage-aggregate.yml +++ b/.github/workflows/coverage-aggregate.yml @@ -13,6 +13,17 @@ name: Aggregate backend coverage # producers themselves on: workflow_call: + inputs: + frontend-validation-result: + description: Result of the frontend-validation producer job + required: false + type: string + default: skipped + playwright-e2e-live-result: + description: Result of the playwright-e2e-live producer job + required: false + type: string + default: skipped permissions: contents: read @@ -196,9 +207,9 @@ jobs: # -------------------------------------------------------------- - name: Download vitest coverage artifact # frontend-validation uploads as `frontend-coverage`. Tolerate - # absence so a backend-only PR still produces the matrix with - # just backend rows populated. - if: always() + # absence on backend-only runs by skipping the download entirely + # when the producer job was not part of this workflow run. + if: inputs.frontend-validation-result == 'success' uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0 with: name: frontend-coverage @@ -206,12 +217,12 @@ jobs: continue-on-error: true - name: Download Playwright frontend coverage artifact - # e2e-live uploads as `playwright-frontend-coverage-`. - # Same tolerance as vitest - matrix script handles missing inputs. - if: always() + # e2e-live uploads the artifact with a stable name. Skip the + # download entirely when the producer job did not run. + if: inputs.playwright-e2e-live-result == 'success' uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0 with: - name: playwright-frontend-coverage-${{ github.run_id }} + name: playwright-frontend-coverage path: matrix-inputs/playwright/ continue-on-error: true diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml index eb6d8d7be5..57a4357dad 100644 --- a/.github/workflows/e2e-live.yml +++ b/.github/workflows/e2e-live.yml @@ -62,7 +62,17 @@ jobs: # .test-state/playwright/coverage-pw/ for the post-process step # to aggregate. Chromium-only - other engines silently skip. PW_COVERAGE: "1" + PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json run: task e2e:live + - name: Flag flaky tests + # Runs regardless of the test outcome: a flaky test (passed on retry) + # leaves the step green, so this is the only place it surfaces. Emits + # ::warning:: annotations + a job summary; never fails the job. + if: always() + working-directory: frontend + run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE" + env: + PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json - name: Generate JaCoCo report from e2e:live .exec if: always() id: live-coverage @@ -169,7 +179,7 @@ jobs: if: always() && steps.pw-frontend-coverage.outputs.summary == 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: playwright-frontend-coverage-${{ github.run_id }} + name: playwright-frontend-coverage path: | .test-state/playwright/coverage-pw-summary/ .test-state/playwright/coverage-pw/ diff --git a/.github/workflows/e2e-stubbed.yml b/.github/workflows/e2e-stubbed.yml index dd6bcc8ea7..ccfdf0052f 100644 --- a/.github/workflows/e2e-stubbed.yml +++ b/.github/workflows/e2e-stubbed.yml @@ -44,11 +44,22 @@ jobs: VITE_BUILD_FOR_PREVIEW: "1" run: task frontend:build - name: Run stubbed E2E tests (chromium) + env: + PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json run: task e2e:stubbed -- --workers=3 + - name: Flag flaky tests + # Runs regardless of the test outcome: a flaky test (passed on retry) + # leaves the step green, so this is the only place it surfaces. Emits + # ::warning:: annotations + a job summary; never fails the job. + if: always() + working-directory: frontend + run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE" + env: + PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json - name: Upload Playwright report if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: playwright-report-stubbed-${{ github.run_id }} - path: frontend/editor/playwright-report/ + path: frontend/playwright-report/ retention-days: 7 diff --git a/.github/workflows/frontend-backend-licenses-update.yml b/.github/workflows/frontend-backend-licenses-update.yml index 41d3f35c6e..cc9aa023e3 100644 --- a/.github/workflows/frontend-backend-licenses-update.yml +++ b/.github/workflows/frontend-backend-licenses-update.yml @@ -98,6 +98,13 @@ jobs: - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + + - name: Generate frontend license report (Push only) + if: github.event_name == 'push' + env: + PR_IS_FORK: "false" + run: task frontend:licenses:generate + - name: Generate frontend license report (internal PR) if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false env: @@ -353,6 +360,7 @@ jobs: - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + - name: Check licenses and generate report id: license-check run: task backend:licenses:generate || echo "LICENSE_CHECK_FAILED=true" >> $GITHUB_ENV diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 11a8ea2be5..1801876bcd 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -53,8 +53,8 @@ jobs: if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: playwright-nightly-${{ github.run_id }} - path: frontend/editor/playwright-report/ + name: playwright-report-nightly-${{ github.run_id }} + path: frontend/playwright-report/ retention-days: 14 # Builds all desktop platforms on a schedule so the Rust dependency cache is diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 52277b5b04..4f87226f93 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -396,6 +396,23 @@ tasks: # Code Generation # ============================================================ + tool-models: + desc: "Generate tool API types from the Java OpenAPI spec" + deps: [install, ":backend:swagger"] + cmds: + - npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts + sources: + - editor/scripts/generate-tool-api-types.mts + - ../SwaggerDoc.json + generates: + - editor/src/core/types/toolApiTypes.ts + + tool-models:check: + desc: "Fail if committed tool API types are out of date" + deps: [install, ":backend:swagger"] + cmds: + - npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --check + licenses:generate: desc: "Generate frontend license report" deps: [install] diff --git a/DeveloperGuide.md b/DeveloperGuide.md index 23b2652ce1..fbbf6478ac 100644 --- a/DeveloperGuide.md +++ b/DeveloperGuide.md @@ -92,7 +92,7 @@ Visit the [Lombok website](https://projectlombok.org/setup/) for installation in 5. Add environment variable For local testing, you should generally be testing the full 'Security' version of Stirling PDF. To do this, you must add the environment flag DISABLE_ADDITIONAL_FEATURES=false to your system and/or IDE build/run step. -5. **Frontend Setup (Required for Stirling 2.0)** +6. **Frontend Setup (Required for Stirling 2.0)** Navigate to the frontend directory and install dependencies using npm. ### Verify Setup @@ -275,7 +275,7 @@ Stirling-PDF uses different Docker images for various configurations. The build 1. Set the security environment variable: ```bash - export DISABLE_ADDITIONAL_FEATURES=true # or false for to enable login and security features for builds + export DISABLE_ADDITIONAL_FEATURES=true # or false to enable login and security features for builds ``` 2. Build the project: @@ -305,7 +305,7 @@ Stirling-PDF uses different Docker images for various configurations. The build docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat . ``` -Note: The `--no-cache` and `--pull` flags ensure that the build process uses the latest base images and doesn't use cached layers, which is useful for testing and ensuring reproducible builds. however to improve build times these can often be removed depending on your usecase +Note: The `--no-cache` and `--pull` flags ensure that the build process uses the latest base images and doesn't use cached layers, which is useful for testing and ensuring reproducible builds. However, to improve build times these can often be removed depending on your use case ## 7. Testing diff --git a/README.md b/README.md index c1d96eca1d..c6777f1032 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,8 @@ For full installation options (including desktop and Kubernetes), see our [Docum ## Support -- **Community** [Discord](https://discord.gg/HYmhKj45pU) -- **Bug Reports**: [Github issues](https://github.com/Stirling-Tools/Stirling-PDF/issues) +- **Community**: [Discord](https://discord.gg/HYmhKj45pU) +- **Bug Reports**: [GitHub Issues](https://github.com/Stirling-Tools/Stirling-PDF/issues) ## Contributing diff --git a/Taskfile.yml b/Taskfile.yml index 26895723d0..705ad4a1db 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -185,6 +185,16 @@ tasks: - task: frontend:format:check - task: engine:format:check + # ============================================================ + # Code generation + # ============================================================ + + tool-models: + desc: "Generate all API models from the Java OpenAPI spec" + cmds: + - task: frontend:tool-models + - task: engine:tool-models + # ============================================================ # Quality Gate # ============================================================ diff --git a/app/allowed-licenses.json b/app/allowed-licenses.json index cd2fe06a3b..9f1ff96359 100644 --- a/app/allowed-licenses.json +++ b/app/allowed-licenses.json @@ -80,10 +80,18 @@ "moduleName": ".*", "moduleLicense": "Apache License Version 2.0" }, + { + "moduleName": ".*", + "moduleLicense": "Apache License version 2.0" + }, { "moduleName": ".*", "moduleLicense": "Apache License, Version 2.0" }, + { + "moduleName": ".*", + "moduleLicense": "Apache License, version 2.0" + }, { "moduleName": ".*", "moduleLicense": "The Apache License, Version 2.0" @@ -108,6 +116,10 @@ "moduleName": ".*", "moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)" }, + { + "moduleName": ".*", + "moduleLicense": "Mozilla Public License Version 2.0" + }, { "moduleName": ".*", "moduleLicense": "CDDL+GPL License" @@ -172,6 +184,14 @@ "moduleName": ".*", "moduleLicense": "Eclipse Public License, Version 2.0" }, + { + "moduleName": ".*", + "moduleLicense": "EPL-2.0" + }, + { + "moduleName": ".*", + "moduleLicense": "LGPL-2.1-only" + }, { "moduleName": ".*", "moduleLicense": "Ubuntu Font Licence 1.0" diff --git a/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java b/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java index ba896b4e34..40c1c98f23 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java +++ b/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java @@ -132,7 +132,7 @@ public class AppConfig { return true; } Path mountInfo = Path.of("/proc/1/mountinfo"); - // this should always exist, if not some unknown usecase + // this should always exist, if not some unknown use case if (!Files.exists(mountInfo)) { return true; } diff --git a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index 74c713b6db..faf71c2f97 100644 --- a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -57,6 +57,15 @@ public class RequestUriUtils { return true; } + // Admin portal SPA shell. Served publicly like the editor root so a direct + // nav / refresh to /portal loads the app (the JWT lives in localStorage, not + // a cookie, so the server can't authenticate the navigation itself). The + // portal gates access via its own auth gate + RequirePortalAccess, and its + // data APIs stay protected, so serving the shell pre-auth is safe. + if (normalizedUri.equals("/portal") || normalizedUri.startsWith("/portal/")) { + return true; + } + // Treat common static file extensions as static resources return normalizedUri.endsWith(".svg") || normalizedUri.endsWith(".png") diff --git a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java index 4f0f3e372a..a13aeac82b 100644 --- a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java @@ -73,6 +73,14 @@ class RequestUriUtilsTest { assertTrue(RequestUriUtils.isStaticResource("/mobile-scanner")); } + @Test + void testIsStaticResource_portalShell() { + // The admin portal SPA shell is served pre-auth so it's directly navigable. + assertTrue(RequestUriUtils.isStaticResource("/portal")); + assertTrue(RequestUriUtils.isStaticResource("/portal/users")); + assertTrue(RequestUriUtils.isStaticResource("/app", "/app/portal")); + } + // --- isFrontendRoute tests --- @Test diff --git a/app/core/build.gradle b/app/core/build.gradle index d77fbdd438..6a55d89304 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -175,6 +175,14 @@ springBoot { // Frontend build tasks - only enabled with -PbuildWithFrontend=true def buildWithFrontend = project.hasProperty('buildWithFrontend') && project.property('buildWithFrontend') == 'true' def buildPrototypes = project.hasProperty('prototypesMode') && project.property('prototypesMode') == 'true' +// The admin portal ships as a lazy route inside the editor bundle (see +// proprietary/routes/adminRouteExtensions). -PbuildWithPortal=true includes that +// chunk via VITE_INCLUDE_PORTAL on the editor build; the deploy GHA sets it when +// the portal or AI layers change. Building the portal implies building the editor. +def buildWithPortal = project.hasProperty('buildWithPortal') && project.property('buildWithPortal') == 'true' +if (buildWithPortal) { + buildWithFrontend = true +} // Workspace root holds package.json and node_modules (shared across editor / // future portal). Editor-specific paths (src, public, dist, tauri) live one // level deeper under frontend/editor/. @@ -297,9 +305,11 @@ tasks.register('npmBuild', Exec) { // Override VITE_API_BASE_URL to use relative paths for production builds // This ensures JARs work regardless of how they're deployed (direct, proxied, etc.) environment 'VITE_API_BASE_URL', '/' + // Include the admin portal's lazy route/chunk in the editor build when requested. + environment 'VITE_INCLUDE_PORTAL', (buildWithPortal ? 'true' : 'false') doFirst { - println "Building editor frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/)" + println "Building editor frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/, portal=${buildWithPortal})" } } diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/general/PosterPdfRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/general/PosterPdfRequest.java index 20d7eaf70b..222b89022a 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/general/PosterPdfRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/general/PosterPdfRequest.java @@ -1,5 +1,7 @@ package stirling.software.SPDF.model.api.general; +import com.fasterxml.jackson.annotation.JsonProperty; + import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -17,20 +19,8 @@ public class PosterPdfRequest extends PDFFile { allowableValues = {"A4", "Letter", "A3", "A5", "Legal", "Tabloid"}) private String pageSize = "A4"; - @Schema( - description = "Horizontal decimation factor (how many columns to split into)", - requiredMode = Schema.RequiredMode.NOT_REQUIRED, - defaultValue = "2", - minimum = "1", - maximum = "10") private int xFactor = 2; - @Schema( - description = "Vertical decimation factor (how many rows to split into)", - requiredMode = Schema.RequiredMode.NOT_REQUIRED, - defaultValue = "2", - minimum = "1", - maximum = "10") private int yFactor = 2; @Schema( @@ -38,4 +28,36 @@ public class PosterPdfRequest extends PDFFile { requiredMode = Schema.RequiredMode.NOT_REQUIRED, defaultValue = "false") private boolean rightToLeft = false; + + @JsonProperty("xFactor") + @Schema( + description = "Horizontal decimation factor (how many columns to split into)", + requiredMode = Schema.RequiredMode.NOT_REQUIRED, + defaultValue = "2", + minimum = "1", + maximum = "10") + public int getXFactor() { + return xFactor; + } + + @JsonProperty("xFactor") + public void setXFactor(int xFactor) { + this.xFactor = xFactor; + } + + @JsonProperty("yFactor") + @Schema( + description = "Vertical decimation factor (how many rows to split into)", + requiredMode = Schema.RequiredMode.NOT_REQUIRED, + defaultValue = "2", + minimum = "1", + maximum = "10") + public int getYFactor() { + return yFactor; + } + + @JsonProperty("yFactor") + public void setYFactor(int yFactor) { + this.yFactor = yFactor; + } } diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/security/AddPasswordRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/security/AddPasswordRequest.java index 541a8717f3..2227803372 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/security/AddPasswordRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/security/AddPasswordRequest.java @@ -29,7 +29,8 @@ public class AddPasswordRequest extends PDFFile { description = "The length of the encryption key", type = "integer", allowableValues = {"40", "128", "256"}, - requiredMode = Schema.RequiredMode.REQUIRED) + requiredMode = Schema.RequiredMode.NOT_REQUIRED, + defaultValue = "256") private int keyLength = 256; @Schema(description = "Whether document assembly is prevented", defaultValue = "false") diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java index a77f67f8a8..bdd9df10a8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java @@ -6,6 +6,7 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; +import java.time.LocalDateTime; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -14,25 +15,31 @@ import org.springframework.stereotype.Service; import lombok.extern.slf4j.Slf4j; +import stirling.software.proprietary.billing.UnitCalcPolicy; + import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; /** * Outbound calls from a self-hosted instance to its linked SaaS backend (combined-billing "Mode * A"). * - *

    Two calls: + *

    Calls: * *

      *
    • {@link #register} — relays the admin's short-lived Supabase JWT to {@code POST * /api/v1/account-link/register}; the SaaS side mints + returns a device credential. *
    • {@link #fetchEntitlement} — authenticates with the stored device credential against {@code * GET /api/v1/instance/entitlement}; what the local gate consults. + *
    • {@link #reportUsage} — daily usage sync ({@code POST /api/v1/instance/sync}); reports + * cumulative units and returns the refreshed entitlement. + *
    • {@link #revokeSelf} — self-revokes the credential on local unlink ({@code POST + * /api/v1/instance/revoke-self}). *
    * - *

    Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern, see - * {@code AiEngineClient}). The base URL + client are injectable so tests can stub the SaaS - * endpoint. + *

    Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern; see + * {@code AiEngineClient}); base URL + client are injectable so tests can stub SaaS. */ @Slf4j @Service @@ -86,11 +93,9 @@ public class AccountLinkClient { } /** - * Authoritative deny (401/403) from the entitlement endpoint — the device credential is revoked - * or invalid. Distinct from a transport/server failure (which returns {@code null} and fails - * open): the cache must BLOCK billable work on this rather than serve a stale entitled - * snapshot. Unchecked so it propagates cleanly through {@link #fetchEntitlement}'s transport - * try/catch. + * Authoritative deny (401/403) — the device credential is revoked or invalid. Unlike a + * transport/server failure (which returns {@code null} and fails open), the cache must BLOCK on + * this. Unchecked so it propagates through {@link #fetchEntitlement}'s transport try/catch. */ public static final class RevokedException extends RuntimeException { private final int status; @@ -142,11 +147,9 @@ public class AccountLinkClient { } /** - * Revokes this instance's own credential on the SaaS side ({@code POST - * /api/v1/instance/revoke-self}), authenticated by the device credential — a credential is - * allowed to revoke its own identity. Best-effort: returns {@code false} if SaaS is unreachable - * or rejects the call, so the caller (local unlink) can still clear locally and log the orphan - * row for follow-up. Idempotent on SaaS (already-revoked → still 204). + * Revokes this instance's own credential on the SaaS side, authenticated by that credential. + * Best-effort: returns {@code false} if SaaS is unreachable or rejects, so the caller (local + * unlink) can still clear locally and log the orphan for follow-up. Idempotent on SaaS. */ public boolean revokeSelf(String deviceId, String deviceSecret) { try { @@ -218,6 +221,63 @@ public class AccountLinkClient { } } + /** + * Reports the period's cumulative per-category units to {@code POST /api/v1/instance/sync} and + * returns the fresh entitlement in the same reply — one round-trip both reports and refreshes. + * SaaS bills the delta against its last-seen cumulative, so resending the same totals is + * idempotent. Same three outcomes as {@link #fetchEntitlement}; on {@code null} the caller must + * not advance its last-synced markers so the usage retries next sync. + */ + public InstanceEntitlement reportUsage( + String deviceId, + String deviceSecret, + long syncSeq, + LocalDateTime periodStart, + long apiUnits, + long aiUnits, + long automationUnits) { + HttpResponse response; + try { + ObjectNode root = mapper.createObjectNode(); + root.put("syncSeq", syncSeq); + // Explicit ISO-8601 string so it round-trips regardless of the mapper's time config. + root.put("periodStart", periodStart.toString()); + ObjectNode units = root.putObject("cumulativeUnits"); + units.put("api", apiUnits); + units.put("ai", aiUnits); + units.put("automation", automationUnits); + String body = mapper.writeValueAsString(root); + HttpRequest request = + HttpRequest.newBuilder() + .uri(uri("/api/v1/instance/sync")) + .header(HEADER_DEVICE_ID, deviceId) + .header(HEADER_DEVICE_SECRET, deviceSecret) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .timeout(timeout()) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + response = send(request); + } catch (Exception e) { + log.debug("Usage sync failed: {}", e.getMessage()); + return null; + } + int status = response.statusCode(); + if (status == 401 || status == 403) { + throw new RevokedException(status); + } + if (status / 100 != 2) { + log.debug("Usage sync returned HTTP {}", status); + return null; + } + try { + return parseEntitlement(response.body()); + } catch (IOException e) { + log.debug("Usage sync parse failed: {}", e.getMessage()); + return null; + } + } + private InstanceEntitlement parseEntitlement(String body) throws IOException { JsonNode root = mapper.readTree(body); boolean subscribed = root.path("subscribed").asBoolean(false); @@ -226,7 +286,45 @@ public class AccountLinkClient { Long periodCap = root.hasNonNull("periodCapUnits") ? root.get("periodCapUnits").asLong() : null; EntitlementState state = mapState(root.path("state").asText(null)); - return new InstanceEntitlement(subscribed, freeRemaining, periodSpend, periodCap, state); + return new InstanceEntitlement( + subscribed, + freeRemaining, + periodSpend, + periodCap, + state, + parseUnitCalcPolicy(root), + parseDateTime(root, "periodStart"), + parseDateTime(root, "periodEnd")); + } + + /** Parses the nested unit-calc policy; null if absent or any knob is invalid (e.g. zero). */ + private static UnitCalcPolicy parseUnitCalcPolicy(JsonNode root) { + if (!root.hasNonNull("unitCalcPolicy")) { + return null; + } + JsonNode node = root.get("unitCalcPolicy"); + try { + return new UnitCalcPolicy( + node.path("docPagesPerUnit").asInt(), + node.path("docBytesPerUnit").asLong(), + node.path("minChargeUnits").asInt(), + node.path("fileUnitCap").asInt()); + } catch (RuntimeException e) { + // Malformed policy → degrade to "none" rather than fail the whole entitlement parse. + return null; + } + } + + /** ISO date-time field → LocalDateTime; null if absent or unparseable. */ + private static LocalDateTime parseDateTime(JsonNode root, String field) { + if (!root.hasNonNull(field)) { + return null; + } + try { + return LocalDateTime.parse(root.get(field).asText(null)); + } catch (RuntimeException e) { + return null; + } } /** Maps the SaaS state string to our coarse enum; unrecognised → UNKNOWN. */ diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java index 1d1a8aa77d..52af366df4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java @@ -2,6 +2,7 @@ package stirling.software.proprietary.accountlink; import java.io.IOException; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; @@ -23,7 +24,9 @@ import lombok.extern.slf4j.Slf4j; *

    The portal (served from this same origin, admin authenticated by the existing self-hosted * security chain) calls these. {@code POST /link} relays the admin's Supabase JWT to the SaaS * backend, which mints + returns a device credential we store locally. {@code GET /status} backs - * the portal's link card. + * the portal's link card; {@code GET /usage} exposes locally-accrued unsynced usage the portal adds + * to SaaS-synced spend; {@code POST /sync-now} forces an immediate usage sync (ops "reconcile now" + * / test aid). * *

    Admin-only, {@code @Profile("!saas")}, gated behind {@code * stirling.billing.account-link.enabled} — off → bean absent → 404. @@ -38,9 +41,17 @@ import lombok.extern.slf4j.Slf4j; public class AccountLinkController { private final AccountLinkService service; + private final LocalUsageService localUsageService; + // Present only when metering is on (its own flag); absent → /sync-now reports 409. + private final ObjectProvider syncServiceProvider; - public AccountLinkController(AccountLinkService service) { + public AccountLinkController( + AccountLinkService service, + LocalUsageService localUsageService, + ObjectProvider syncServiceProvider) { this.service = service; + this.localUsageService = localUsageService; + this.syncServiceProvider = syncServiceProvider; } /** {@code supabaseJwt} is the admin's short-lived token the portal already holds. */ @@ -85,4 +96,29 @@ public class AccountLinkController { service.unlink(); return ResponseEntity.noContent().build(); } + + /** + * Locally accrued usage not yet reported to SaaS — the portal adds it to the SaaS-synced spend + * so "current usage" includes work done since the last daily sync. + */ + @GetMapping("/usage") + public ResponseEntity usage() { + return ResponseEntity.ok(localUsageService.currentPeriodUnsynced()); + } + + /** + * Forces an immediate usage sync to SaaS — the same work the daily scheduler does. An admin + * "reconcile now" action (and a test aid so you don't wait on the scheduler). Idempotent: + * re-reports the current cumulative, so a repeat trigger bills nothing. {@code 204} once run; + * {@code 409} when metering is off (the sync bean is absent). + */ + @PostMapping("/sync-now") + public ResponseEntity syncNow() { + UsageSyncService sync = syncServiceProvider.getIfAvailable(); + if (sync == null) { + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } + sync.syncNow(); + return ResponseEntity.noContent().build(); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java index c12e56f06d..6d1f1fb151 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java @@ -1,5 +1,7 @@ package stirling.software.proprietary.accountlink; +import java.time.Duration; + import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; @@ -36,4 +38,39 @@ public class AccountLinkProperties { /** Connect/read timeout for the outbound SaaS calls. */ private int requestTimeoutSeconds = 10; + + /** Phase 2 usage metering + daily sync. Keyed under {@code …account-link.metering.*}. */ + private final Metering metering = new Metering(); + + /** + * Dedicated billing switch, separate from {@link #enabled} so the link plumbing can be + * enabled (e.g. to test linking) without ever turning on real usage metering, reporting, or cap + * enforcement. Both default off; metering requires the master flag too. This is the production + * safety key — flipping it on is what actually bills linked instances. + */ + @Getter + @Setter + public static class Metering { + + /** Turns on usage metering, the daily sync, and cap enforcement. Default off. */ + private boolean enabled = false; + + /** + * How often the instance syncs usage + refreshes entitlement (matches the licence sync). + */ + private int syncIntervalHours = 24; + + /** + * Block billable work after this many days with no successful sync (fail-open → closed). + */ + private int graceDays = 3; + + /** + * Dedup window for identical input sets. A re-run of the same inputs within this window is + * treated as workflow chaining and not re-charged; the same inputs run again after it are + * billed afresh. Mirrors the cloud's {@code payg.lineage.workflow-window} so the same op + * costs the same on the instance and in the cloud. + */ + private Duration workflowWindow = Duration.ofMinutes(5); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java new file mode 100644 index 0000000000..fbac6a8603 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java @@ -0,0 +1,46 @@ +package stirling.software.proprietary.accountlink; + +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Singleton row holding this instance's daily-sync bookkeeping (combined-billing "Mode A"). + * + *

    {@link #lastSyncSeq} is reserved (incremented + persisted) before each report so it + * is strictly monotonic across restarts and partial failures — SaaS dedups replays by comparing it, + * so a never-decreasing seq is the contract. {@link #lastSuccessAt} is the wall-clock of the last + * sync SaaS accepted and drives the fail-open→closed grace window. + * + *

    Auto-created by Hibernate ({@code ddl-auto=update}); written only by the flag-gated sync. + */ +@Entity +@Table(name = "account_link_sync_state") +@Getter +@Setter +@NoArgsConstructor +public class AccountLinkSyncState { + + /** One instance links to one team → one bookkeeping row. */ + public static final long SINGLETON_ID = 1L; + + @Id private Long id; + + // columnDefinition default keeps the ddl-auto ADD COLUMN safe on a populated external Postgres. + @Column( + name = "last_sync_seq", + nullable = false, + columnDefinition = "bigint not null default 0") + private long lastSyncSeq; + + /** Null until the first sync SaaS accepts. */ + @Column(name = "last_success_at") + private LocalDateTime lastSuccessAt; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java new file mode 100644 index 0000000000..15b5e3842d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java @@ -0,0 +1,6 @@ +package stirling.software.proprietary.accountlink; + +import org.springframework.data.jpa.repository.JpaRepository; + +/** Persistence for the singleton {@link AccountLinkSyncState} (combined-billing "Mode A"). */ +public interface AccountLinkSyncStateRepository extends JpaRepository {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/BillableOperationClassifier.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/BillableOperationClassifier.java index 136e4c3214..476fbd111a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/BillableOperationClassifier.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/BillableOperationClassifier.java @@ -3,14 +3,26 @@ package stirling.software.proprietary.accountlink; import jakarta.servlet.http.HttpServletRequest; import stirling.software.common.service.InternalApiClient; +import stirling.software.proprietary.billing.BillingCategory; +import stirling.software.proprietary.billing.BillingCategoryClassifier; /** - * Classifies a request as billable (AI / automation) or free (a manual tool). + * Buckets a request into a {@link BillingCategory} for the account-link gate + meter, using only + * HTTP-level signals (no dependency on the saas module): * - *

    Mirrors the saas billing categorisation at a coarse level, without depending on the saas - * module: billable = the AI surface ({@code /api/v1/ai/**}) or any request carrying the automation - * marker header ({@link InternalApiClient#AUTOMATION_HEADER}, set on pipeline / workflow / policy - * sub-steps). Everything else — interactive manual PDF tools — is always free. + *

      + *
    • AUTOMATION — the automation marker header ({@link + * InternalApiClient#AUTOMATION_HEADER}, set on pipeline / workflow / policy sub-steps); + *
    • AI — the AI surface ({@code /api/v1/ai/**}); + *
    • API — an API-key authenticated tool call; + *
    • BYPASSED — a manual interactive tool call, never billed. + *
    + * + *

    Same precedence as the SaaS classifier (AUTOMATION → AI → API → BYPASSED) via the shared + * {@link BillingCategoryClassifier}; the AI signal is resolved by path prefix rather than the + * saas-only {@code @RequiresFeature} annotation. The {@code apiKey} signal is supplied by the + * caller (resolved from the security context), so this class stays free of any security-type + * dependency. */ public final class BillableOperationClassifier { @@ -18,16 +30,22 @@ public final class BillableOperationClassifier { private BillableOperationClassifier() {} - public static boolean isBillable(HttpServletRequest request) { - if (request.getHeader(InternalApiClient.AUTOMATION_HEADER) != null) { - return true; - } + /** + * @param apiKey whether the request authenticated via an API key (an {@code + * ApiKeyAuthenticationToken} principal), resolved by the caller from the security context. + */ + public static BillingCategory categorize(HttpServletRequest request, boolean apiKey) { + boolean automation = request.getHeader(InternalApiClient.AUTOMATION_HEADER) != null; + return BillingCategoryClassifier.classify(automation, isAiSurface(request), apiKey); + } + + private static boolean isAiSurface(HttpServletRequest request) { String uri = request.getRequestURI(); if (uri == null) { return false; } // Prefix-match the AI surface (not a loose substring contains), stripping a deployment - // context path so //api/v1/ai/** still classifies as billable. + // context path so //api/v1/ai/** still classifies as AI. String ctx = request.getContextPath(); String path = ctx != null && !ctx.isEmpty() && uri.startsWith(ctx) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementCache.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementCache.java index 63818c1f98..898fb54b3a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementCache.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementCache.java @@ -12,18 +12,13 @@ import org.springframework.stereotype.Service; import lombok.extern.slf4j.Slf4j; /** - * Caches the linked team's entitlement so the request-time gate does not call the SaaS backend on - * every billable request. Single-slot (one instance = one linked team), TTL-based. + * Caches the linked team's entitlement so the request-time gate needn't call SaaS on every billable + * request. Single-slot (one instance = one linked team), TTL-based. * - *

    Fail-open friendly for TRANSPORT failures: {@link #current()} returns the freshest snapshot it - * has, even if a refresh just failed; it returns {@link Optional#empty()} only when nothing has - * ever been fetched and the latest refresh failed (the gate treats empty as "unknown → - * allow"). - * - *

    But an AUTHORITATIVE deny (revoked/invalid credential → {@link - * AccountLinkClient.RevokedException}) is NOT a transport failure: the snapshot is replaced with a - * {@link EntitlementState#REVOKED} blocked entitlement so the gate stops billable work immediately - * rather than serving a stale entitled snapshot. + *

    A transport failure fails open — {@link #current()} keeps serving the freshest snapshot it has + * and returns {@link Optional#empty()} ("unknown → allow") only when nothing was ever fetched. An + * authoritative deny ({@link AccountLinkClient.RevokedException}) does not: the snapshot is + * replaced with a {@link EntitlementState#REVOKED} entitlement so the gate blocks immediately. */ @Slf4j @Service @@ -63,9 +58,8 @@ public class EntitlementCache { * not linked or the SaaS side is unreachable and we have no prior snapshot. */ public Optional current() { - // Single-flight: when stale, exactly one thread refreshes (blocking on the SaaS - // call) while concurrent callers serve the last snapshot — no thundering herd of - // synchronous round-trips on the billable hot path. Safe because the gate fails open. + // Single-flight: when stale, exactly one thread refreshes while concurrent callers serve + // the last snapshot — no thundering herd of round-trips on the billable hot path. if (isStale(snapshot) && refreshing.compareAndSet(false, true)) { try { refresh(); @@ -77,16 +71,15 @@ public class EntitlementCache { } private boolean isStale(Snapshot snap) { - // fetchedAt is the last *attempt* time (stamped on success AND failure), so a failed - // fetch backs off for a full TTL instead of every billable request re-triggering a - // blocking round-trip against a dead/slow SaaS endpoint. + // fetchedAt is the last *attempt* time (stamped on success and failure), so a failed fetch + // backs off a full TTL instead of every request re-triggering a round-trip to a dead SaaS. return Duration.between(snap.fetchedAt(), Instant.now()).compareTo(ttl) >= 0; } /** - * Pulls a fresh snapshot. Keeps the previous entitlement on a TRANSPORT failure (fail-open) but - * still stamps the attempt time so re-fetches throttle to the TTL; on an AUTHORITATIVE deny - * (revoked credential) replaces it with a blocked snapshot so the gate stops billable work. + * Pulls a fresh snapshot. On a transport failure keeps the previous entitlement but stamps the + * attempt time so re-fetches throttle to the TTL; on an authoritative deny replaces it with a + * blocked snapshot. */ void refresh() { Optional cred = credentialStore.get(); @@ -101,15 +94,15 @@ public class EntitlementCache { if (fresh != null) { snapshot = new Snapshot(fresh, Instant.now()); } else { - // Unreachable / server error: keep the last known entitlement (may be null) but - // stamp the attempt so we don't hammer SaaS; the gate fails open in the meantime. + // Unreachable / server error: keep the last known entitlement but stamp the attempt + // so we don't hammer SaaS; the gate fails open meanwhile. log.debug( "Entitlement refresh failed; reusing last known snapshot, backing off a TTL"); snapshot = new Snapshot(snapshot.entitlement(), Instant.now()); } } catch (AccountLinkClient.RevokedException e) { - // Authoritative deny — credential revoked/invalid. Do NOT fail open: block immediately - // rather than serving the stale entitled snapshot until the next unlink. + // Authoritative deny — block immediately rather than serving the stale entitled + // snapshot. log.info( "Entitlement denied (HTTP {}); blocking billable work for the revoked credential", e.status()); @@ -121,4 +114,14 @@ public class EntitlementCache { public void invalidate() { snapshot = new Snapshot(snapshot.entitlement(), Instant.EPOCH); } + + /** + * Seeds the cache with an entitlement obtained out-of-band (the sync reply carries a fresh + * one), saving a redundant fetch. No-op on null. + */ + public void accept(InstanceEntitlement fresh) { + if (fresh != null) { + snapshot = new Snapshot(fresh, Instant.now()); + } + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/GateDecision.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/GateDecision.java index 677183278b..87b4ddcde9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/GateDecision.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/GateDecision.java @@ -16,6 +16,11 @@ public record GateDecision(boolean allowed, Reason reason) { ENTITLED, /** Entitlement source unreachable — fail open, allow. */ FAIL_OPEN, + /** + * Linked + metering, but SaaS has been unreachable past the grace window — block (the + * fail-open backstop expired) so unbounded free/unbilled billable work can't continue. + */ + GRACE_EXPIRED, /** Not linked — block billable work; FE should prompt to link. */ NOT_LINKED, /** Linked but over the limit / no subscription — block billable work. */ diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlement.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlement.java index 6445d886e9..8760239957 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlement.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlement.java @@ -1,19 +1,53 @@ package stirling.software.proprietary.accountlink; +import java.time.LocalDateTime; + +import stirling.software.proprietary.billing.UnitCalcPolicy; + /** - * Cached, proprietary-local view of the SaaS {@code GET /api/v1/instance/entitlement} response — - * just the fields the gate needs. Mirrors the saas {@code EntitlementResponse} shape but carries no - * saas types. + * Cached, proprietary-local view of the SaaS {@code GET /api/v1/instance/entitlement} response. + * Mirrors the saas {@code EntitlementResponse} shape but carries no saas types. + * + *

    The first five fields are what the gate enforces against; the trailing three are the + * metering inputs (Phase 2) the instance uses to cost + bucket its own usage and reset its + * per-period counters. The 5-arg constructor builds a gate-only view (metering fields null) for the + * revoked sentinel and unit tests that don't exercise metering. * * @param subscribed team has an active subscription * @param freeRemainingUnits remaining free-pool units (>0 means free work is available) * @param periodSpendUnits paid units spent this period * @param periodCapUnits paid cap for the period; {@code null} = uncapped * @param state coarse state classification (see {@link EntitlementState}) + * @param unitCalcPolicy doc-unit pricing knobs for local unit computation; {@code null} if not + * supplied (older SaaS / gate-only sentinel) + * @param periodStart inclusive start of the current billing period; {@code null} if not supplied + * @param periodEnd exclusive end of the current billing period; {@code null} if not supplied */ public record InstanceEntitlement( boolean subscribed, long freeRemainingUnits, long periodSpendUnits, Long periodCapUnits, - EntitlementState state) {} + EntitlementState state, + UnitCalcPolicy unitCalcPolicy, + LocalDateTime periodStart, + LocalDateTime periodEnd) { + + /** Gate-only view with no metering config — used by the revoked sentinel and gate tests. */ + public InstanceEntitlement( + boolean subscribed, + long freeRemainingUnits, + long periodSpendUnits, + Long periodCapUnits, + EntitlementState state) { + this( + subscribed, + freeRemainingUnits, + periodSpendUnits, + periodCapUnits, + state, + null, + null, + null); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java index c975bad055..018684b73f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.accountlink; +import java.time.LocalDateTime; import java.util.Optional; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -15,14 +16,17 @@ import org.springframework.stereotype.Service; *

  • Flag off → always allow (feature inert). *
  • Manual tool → always allow (manual tools are free, never metered). *
  • Billable + not linked → block with {@code NOT_LINKED} ("link to activate"). - *
  • Billable + linked + entitlement unknown (unreachable) → fail open, allow. + *
  • Billable + linked + entitlement unknown (unreachable) → fail open, allow — unless + * metering is on and SaaS has been unreachable past the grace window, then block with {@code + * GRACE_EXPIRED} so the fail-open can't grant unbounded free/unbilled work forever. *
  • Billable + linked + entitled → allow. *
  • Billable + linked + credential revoked → block with {@code REVOKED}. *
  • Billable + linked + over limit → block with {@code OVER_LIMIT}. * * - *

    The decision logic is the pure static {@link #decide}; the Spring wrapper just supplies the - * live flag / linked-state / entitlement. This is the unit-tested core. + *

    The decision logic is the pure static {@link #decide}; the Spring wrapper supplies the live + * flag / linked-state / entitlement and computes whether the grace window has expired. This is the + * unit-tested core. */ @Service @Profile("!saas") @@ -32,14 +36,20 @@ public class InstanceEntitlementGate { private final AccountLinkProperties properties; private final DeviceCredentialStore credentialStore; private final EntitlementCache entitlementCache; + private final AccountLinkSyncStateRepository syncStateRepository; + private final LocalUsageService localUsageService; public InstanceEntitlementGate( AccountLinkProperties properties, DeviceCredentialStore credentialStore, - EntitlementCache entitlementCache) { + EntitlementCache entitlementCache, + AccountLinkSyncStateRepository syncStateRepository, + LocalUsageService localUsageService) { this.properties = properties; this.credentialStore = credentialStore; this.entitlementCache = entitlementCache; + this.syncStateRepository = syncStateRepository; + this.localUsageService = localUsageService; } /** Evaluates the gate for a request, resolving live state from the store + cache. */ @@ -53,18 +63,39 @@ public class InstanceEntitlementGate { boolean linked = credentialStore.isLinked(); Optional entitlement = linked ? entitlementCache.current() : Optional.empty(); - return decide(true, true, linked, entitlement); + boolean graceExpired = linked && entitlement.isEmpty() && isGraceExpired(); + // Deplete the applicable ceiling — free grant (unsubscribed) or spend cap (capped + // subscription) — by local usage not yet synced, so the gate stops in real time instead of + // overshooting until the next sync. An uncapped subscription has no ceiling to deplete → 0. + long pendingUnsynced = + entitlement.map(InstanceEntitlementGate::depletesCeiling).orElse(false) + ? localUsageService.currentPeriodUnsynced().totalUnsyncedUnits() + : 0L; + return decide(true, true, linked, entitlement, graceExpired, pendingUnsynced); + } + + /** Whether local unsynced usage pushes against a real ceiling (free grant or a spend cap). */ + private static boolean depletesCeiling(InstanceEntitlement e) { + return !e.subscribed() || e.periodCapUnits() != null; } /** * Pure decision function — no Spring, no I/O. {@code entitlement} empty means "unknown" - * (unreachable): when linked, that fails open. + * (unreachable): when linked, that fails open unless {@code graceExpired} (the metering grace + * window elapsed with no authoritative contact), in which case it blocks. + * + * @param pendingUnsyncedUnits billable units accrued locally since the last sync — depletes the + * free grant (unsubscribed) or the spend cap (capped subscription) in real time so the gate + * stops without waiting for the next sync (0 for uncapped-subscribed / unknown-entitlement + * cases, where it has no effect). */ public static GateDecision decide( boolean flagEnabled, boolean billable, boolean linked, - Optional entitlement) { + Optional entitlement, + boolean graceExpired, + long pendingUnsyncedUnits) { if (!flagEnabled) { return GateDecision.allow(GateDecision.Reason.FLAG_OFF); } @@ -75,30 +106,69 @@ public class InstanceEntitlementGate { return GateDecision.block(GateDecision.Reason.NOT_LINKED); } if (entitlement.isEmpty()) { - // Linked but entitlement source unreachable — never hard-block billable work on our - // inability to reach billing. - return GateDecision.allow(GateDecision.Reason.FAIL_OPEN); + // Linked but entitlement unreachable: fail open, unless the grace window has expired + // (so + // the fail-open can't grant unbounded unbilled work forever). + return graceExpired + ? GateDecision.block(GateDecision.Reason.GRACE_EXPIRED) + : GateDecision.allow(GateDecision.Reason.FAIL_OPEN); } InstanceEntitlement e = entitlement.get(); if (e.state() == EntitlementState.REVOKED) { // Credential revoked/invalid (authoritative deny) — block, distinct from over-limit. return GateDecision.block(GateDecision.Reason.REVOKED); } - return entitled(e) + return entitled(e, pendingUnsyncedUnits) ? GateDecision.allow(GateDecision.Reason.ENTITLED) : GateDecision.block(GateDecision.Reason.OVER_LIMIT); } + /** + * True when metering is on and it's been {@code graceDays} since the last authoritative contact + * (last successful sync, or link time if never synced). {@code graceDays <= 0} or metering off + * disables the backstop. + */ + private boolean isGraceExpired() { + AccountLinkProperties.Metering metering = properties.getMetering(); + if (!metering.isEnabled() || metering.getGraceDays() <= 0) { + return false; + } + LocalDateTime reference = lastAuthoritativeContact(); + if (reference == null) { + return false; // can't determine elapsed time → fail open + } + return reference.plusDays(metering.getGraceDays()).isBefore(LocalDateTime.now()); + } + + private LocalDateTime lastAuthoritativeContact() { + LocalDateTime lastSuccess = + syncStateRepository + .findById(AccountLinkSyncState.SINGLETON_ID) + .map(AccountLinkSyncState::getLastSuccessAt) + .orElse(null); + if (lastSuccess != null) { + return lastSuccess; + } + return credentialStore.get().map(DeviceCredential::getLinkedAt).orElse(null); + } + /** True when the snapshot permits billable work (subscribed, free pool left, or within cap). */ - private static boolean entitled(InstanceEntitlement e) { + private static boolean entitled(InstanceEntitlement e, long pendingUnsyncedUnits) { if (e.state() == EntitlementState.OVER_LIMIT || e.state() == EntitlementState.REVOKED) { return false; } if (e.subscribed()) { - // Subscribed: allowed unless a period cap is set and exceeded. - return e.periodCapUnits() == null || e.periodSpendUnits() < e.periodCapUnits(); + if (e.periodCapUnits() == null) { + return true; // uncapped subscription + } + // Project the cap the way the grant is projected: synced paid spend plus the paid part + // of local usage not yet synced (free grant is consumed first, so only the excess + // bills) — stops at the cap in real time instead of overshooting until the next sync. + long pendingPaid = Math.max(0, pendingUnsyncedUnits - e.freeRemainingUnits()); + return e.periodSpendUnits() + pendingPaid < e.periodCapUnits(); } - // Unsubscribed: only the free pool covers billable work. - return e.freeRemainingUnits() > 0; + // Unsubscribed: free pool must cover SaaS-charged usage (in freeRemainingUnits) plus local + // usage not yet synced — deplete by the pending delta so we stop at the grant in real time. + return e.freeRemainingUnits() - pendingUnsyncedUnits > 0; } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java index 8597813a85..285943ee49 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java @@ -1,27 +1,51 @@ package stirling.software.proprietary.accountlink; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.DigestOutputStream; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.multipart.MultipartHttpServletRequest; import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.util.WebUtils; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; 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.proprietary.billing.BillingCategory; +import stirling.software.proprietary.billing.ContentHasher; +import stirling.software.proprietary.billing.DocumentUnitCalculator; +import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize; +import stirling.software.proprietary.billing.UnitCalcPolicy; +import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; + /** - * Request-time gate for combined-billing "Mode A". Runs before billable (AI / automation) work and - * blocks it when the instance is unlinked or over its limit; manual tools pass straight through. + * Request-time gate + meter for combined-billing "Mode A". {@code preHandle} blocks billable (API / + * AI / automation) work when the instance is unlinked or over its limit; manual tools pass through. + * {@code afterCompletion} meters a successful billable op into the per-period cumulative counter. * - *

    Blocking responds {@code 402 Payment Required} with a small machine-readable body — {@code - * {"error":"ACCOUNT_LINK_REQUIRED","reason":"NOT_LINKED"}} — that the FE maps to a "link to - * activate" prompt (the same DownstreamEntitlementError-style envelope already used for saas limit - * responses). Fail-open and flag-off both let the request continue. - * - *

    Gated + {@code @Profile("!saas")}; when the flag is off the bean is absent and the {@link - * AccountLinkWebMvcConfig} never registers it, so there is no per-request cost. + *

    Blocking responds {@code 402} with a machine-readable body the FE maps to a "link to activate" + * prompt; fail-open and flag-off both let the request continue. Metering is separately gated behind + * {@code …metering.enabled} via {@link ObjectProvider} — switch off means the {@link + * UsageMeterService} bean is absent and nothing accrues, while the gate still works. */ @Slf4j @Component @@ -29,10 +53,23 @@ import lombok.extern.slf4j.Slf4j; @ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") public class InstanceEntitlementInterceptor implements HandlerInterceptor { - private final InstanceEntitlementGate gate; + private static final String ATTR_CATEGORY = + InstanceEntitlementInterceptor.class.getName() + ".category"; - public InstanceEntitlementInterceptor(InstanceEntitlementGate gate) { + private final InstanceEntitlementGate gate; + private final EntitlementCache entitlementCache; + private final ObjectProvider meterProvider; + private final TempFileManager tempFileManager; + + public InstanceEntitlementInterceptor( + InstanceEntitlementGate gate, + EntitlementCache entitlementCache, + ObjectProvider meterProvider, + TempFileManager tempFileManager) { this.gate = gate; + this.entitlementCache = entitlementCache; + this.meterProvider = meterProvider; + this.tempFileManager = tempFileManager; } @Override @@ -41,7 +78,13 @@ public class InstanceEntitlementInterceptor implements HandlerInterceptor { throws Exception { GateDecision decision; try { - decision = gate.evaluate(BillableOperationClassifier.isBillable(request)); + // API-key tool calls are billable (category API); stash the category for the meter. + boolean apiKey = + SecurityContextHolder.getContext().getAuthentication() + instanceof ApiKeyAuthenticationToken; + BillingCategory category = BillableOperationClassifier.categorize(request, apiKey); + request.setAttribute(ATTR_CATEGORY, category); + decision = gate.evaluate(category != BillingCategory.BYPASSED); } catch (RuntimeException e) { // Fail open: an inability to resolve entitlement (e.g. a DB or SaaS blip) must never // turn into a hard block on billable work. @@ -62,4 +105,139 @@ public class InstanceEntitlementInterceptor implements HandlerInterceptor { + "\"}"); return false; } + + @Override + public void afterCompletion( + HttpServletRequest request, + HttpServletResponse response, + Object handler, + Exception ex) { + // Meter successful billable ops only. + if (ex != null || response.getStatus() >= 400) { + return; + } + UsageMeterService meter = meterProvider.getIfAvailable(); + if (meter == null) { + return; // metering switch off + } + if (!(request.getAttribute(ATTR_CATEGORY) instanceof BillingCategory category) + || category == BillingCategory.BYPASSED) { + return; + } + try { + InstanceEntitlement ent = entitlementCache.current().orElse(null); + if (ent == null || ent.unitCalcPolicy() == null || ent.periodStart() == null) { + // Not yet synced (no policy/period) — can't compute units; skip until next sync. + return; + } + meterRequest(request, category, ent, meter); + } catch (RuntimeException e) { + // Metering must never affect the response that already completed. + log.debug("Usage metering failed for {}", request.getRequestURI(), e); + } + } + + /** + * Computes doc-units (page + byte axes) and the input-set signature, then accrues. The instance + * is authoritative for units (SaaS bills the delta and never sees the file), so a page-heavy + * but small PDF must be page-counted or it under-bills. A fileless op has no input identity — + * null signature (no dedup), billed the 1-unit floor each time. + */ + private void meterRequest( + HttpServletRequest request, + BillingCategory category, + InstanceEntitlement ent, + UsageMeterService meter) { + UnitCalcPolicy policy = ent.unitCalcPolicy(); + MultipartHttpServletRequest mreq = + WebUtils.getNativeRequest(request, MultipartHttpServletRequest.class); + if (mreq == null) { + long fileless = DocumentUnitCalculator.unitsForFile(0, 0, policy); + meter.accrue(ent.periodStart(), category, fileless, null); + return; + } + List temps = new ArrayList<>(); + try { + List sizes = new ArrayList<>(); + List hashes = new ArrayList<>(); + int fileCount = 0; + for (List files : mreq.getMultiFileMap().values()) { + for (MultipartFile f : files) { + fileCount++; + try { + TempFile temp = tempFileManager.createManagedTempFile(".bin"); + temps.add(temp); + // Hash in the same pass that writes the temp file — one read of the upload, + // not a second full read just to fingerprint it. + MessageDigest digest = ContentHasher.newSha256(); + try (InputStream in = f.getInputStream(); + DigestOutputStream out = + new DigestOutputStream( + Files.newOutputStream(temp.getPath()), digest)) { + in.transferTo(out); + } + sizes.add(new FileSize(pageCount(temp.getPath(), f), f.getSize())); + hashes.add(ContentHasher.toHex(digest.digest())); + } catch (IOException | RuntimeException perFile) { + // Couldn't materialise/hash this input — bill on bytes only and, by leaving + // it out of `hashes`, drop dedup for the whole op rather than risk a + // mismatch. + log.debug( + "Metering materialise/hash failed for {}; bytes-only", + f.getOriginalFilename()); + sizes.add(new FileSize(0, f.getSize())); + } + } + } + long units = + sizes.isEmpty() + ? DocumentUnitCalculator.unitsForFile(0, 0, policy) + : DocumentUnitCalculator.unitsForGroup(sizes, policy); + // Only dedup when every input hashed; a partial signature could collide with a + // different input set, so fall back to no-dedup (bill it) if any file failed. + String opSignature = + fileCount > 0 && hashes.size() == fileCount ? opSignature(hashes) : null; + meter.accrue(ent.periodStart(), category, units, opSignature); + } finally { + for (TempFile temp : temps) { + try { + temp.close(); + } catch (RuntimeException cleanup) { + log.debug("Temp file cleanup failed: {}", cleanup.getMessage()); + } + } + } + } + + /** Page count via jpdfium (parser-identical to SaaS); 0 for non-PDF / unreadable inputs. */ + private static int pageCount(Path path, MultipartFile file) { + if (!isPdf(file)) { + return 0; + } + try (PdfDocument doc = PdfDocument.open(path)) { + return doc.pageCount(); + } catch (RuntimeException e) { + // Malformed / encrypted → byte axis only, matching the SaaS classifier. + log.debug( + "Page count unavailable for {}; metering on bytes only", + file.getOriginalFilename()); + return 0; + } + } + + /** Order-independent signature of the input set: sorted per-file hashes, hashed together. */ + private static String opSignature(List hashes) { + List sorted = new ArrayList<>(hashes); + Collections.sort(sorted); + return ContentHasher.sha256(String.join("\n", sorted).getBytes(StandardCharsets.UTF_8)); + } + + private static boolean isPdf(MultipartFile file) { + String contentType = file.getContentType(); + if (contentType != null && contentType.toLowerCase().contains("pdf")) { + return true; + } + String name = file.getOriginalFilename(); + return name != null && name.toLowerCase().endsWith(".pdf"); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/LocalUsageService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/LocalUsageService.java new file mode 100644 index 0000000000..58d365fe28 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/LocalUsageService.java @@ -0,0 +1,59 @@ +package stirling.software.proprietary.accountlink; + +import java.time.LocalDateTime; +import java.util.EnumMap; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import stirling.software.proprietary.billing.BillingCategory; + +/** + * Reads this instance's locally accrued but not-yet-synced usage for the current period. The portal + * adds this on top of SaaS-synced spend so "current usage" reflects work done since the last sync. + * + *

    Unsynced per category = {@code cumulativeUnits − lastSyncedUnits} (floored at 0), scoped to + * the current period so prior-period leftovers don't inflate it. Zeros when the period is unknown + * or metering is off. + */ +@Service +@Profile("!saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class LocalUsageService { + + private final UsageCounterRepository counters; + private final EntitlementCache entitlementCache; + + public LocalUsageService(UsageCounterRepository counters, EntitlementCache entitlementCache) { + this.counters = counters; + this.entitlementCache = entitlementCache; + } + + /** Per-category unsynced units for the current period; {@code periodStart} null = unknown. */ + public record LocalUsage( + LocalDateTime periodStart, + long apiUnsyncedUnits, + long aiUnsyncedUnits, + long automationUnsyncedUnits, + long totalUnsyncedUnits) {} + + public LocalUsage currentPeriodUnsynced() { + LocalDateTime period = + entitlementCache.current().map(InstanceEntitlement::periodStart).orElse(null); + if (period == null) { + return new LocalUsage(null, 0, 0, 0, 0); + } + EnumMap unsynced = new EnumMap<>(BillingCategory.class); + for (UsageCounter c : counters.findByPeriodStart(period)) { + BillingCategory cat = c.billingCategory(); + if (cat != null && cat != BillingCategory.BYPASSED) { + unsynced.merge(cat, c.unsyncedUnits(), Long::sum); + } + } + long api = unsynced.getOrDefault(BillingCategory.API, 0L); + long ai = unsynced.getOrDefault(BillingCategory.AI, 0L); + long automation = unsynced.getOrDefault(BillingCategory.AUTOMATION, 0L); + return new LocalUsage(period, api, ai, automation, api + ai + automation); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignature.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignature.java new file mode 100644 index 0000000000..1ed49d6a8b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignature.java @@ -0,0 +1,73 @@ +package stirling.software.proprietary.accountlink; + +import java.time.LocalDateTime; + +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 jakarta.persistence.UniqueConstraint; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * The last time the instance metered a given input set this period — the local equivalent of the + * cloud's lineage join (combined-billing "Mode A"). The meter dedups on a rolling workflow + * window: an identical input set re-submitted within the window (see {@link + * AccountLinkProperties.Metering}) is treated as workflow chaining and not re-charged, while the + * same inputs run again after the window are billed afresh — matching the cloud's 5-minute open-job + * window so the same operation costs the same on the instance and in the cloud. + * + *

    {@code lastMeteredAt} is refreshed on every sighting (the window slides, as recording a cloud + * artifact touches its job). One row per {@code (period, signature)}; the unique constraint also + * makes the first-sighting insert an atomic claim under concurrency. + * + *

    Auto-created by Hibernate ({@code ddl-auto=update}); written only by the flag-gated meter. + */ +@Entity +@Table( + name = "account_link_metered_signature", + uniqueConstraints = + @UniqueConstraint( + name = "uk_account_link_metered_signature", + columnNames = {"period_start", "signature"})) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MeteredInputSignature { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "period_start", nullable = false) + private LocalDateTime periodStart; + + /** SHA-256 hex of the op's input set (64 chars); the dedup key within a period. */ + @Column(name = "signature", nullable = false, length = 64) + private String signature; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + /** + * When this input set was last metered — the anchor the workflow-window dedup compares against. + */ + @Column(name = "last_metered_at") + private LocalDateTime lastMeteredAt; + + public MeteredInputSignature(LocalDateTime periodStart, String signature, LocalDateTime at) { + this.periodStart = periodStart; + this.signature = signature; + this.createdAt = at; + this.lastMeteredAt = at; + } + + /** Slides the window forward — the input set was seen again. */ + public void touch(LocalDateTime at) { + this.lastMeteredAt = at; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java new file mode 100644 index 0000000000..863f503f61 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java @@ -0,0 +1,15 @@ +package stirling.software.proprietary.accountlink; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +/** Persistence for the per-period metered input-set signatures (combined-billing "Mode A"). */ +public interface MeteredInputSignatureRepository + extends JpaRepository { + + /** The existing row for a seen input set, so the meter can apply the workflow-window check. */ + Optional findByPeriodStartAndSignature( + LocalDateTime periodStart, String signature); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java new file mode 100644 index 0000000000..b90af07958 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java @@ -0,0 +1,105 @@ +package stirling.software.proprietary.accountlink; + +import java.time.LocalDateTime; + +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 jakarta.persistence.UniqueConstraint; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import stirling.software.proprietary.billing.BillingCategory; + +/** + * Durable per-(billing period, category) cumulative usage counter for combined-billing "Mode A". + * Each successful billable op increments its row; the daily sync reports the cumulative totals and + * SaaS bills the delta since the last sync. The cumulative model is idempotent (a resend bills + * nothing) and tamper-evident (a counter that drops is a signal). One row per {@code (period_start, + * category)}, auto-created by Hibernate; only the flag-gated {@link UsageMeterService} writes it. + */ +@Entity +@Table( + name = "account_link_usage_counter", + uniqueConstraints = + @UniqueConstraint( + name = "uk_usage_counter_period_category", + columnNames = {"period_start", "category"})) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UsageCounter { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * Inclusive start of the billing period this counter belongs to (from the entitlement sync). + */ + @Column(name = "period_start", nullable = false) + private LocalDateTime periodStart; + + /** {@code BillingCategory} name — API / AI / AUTOMATION (never BYPASSED). */ + @Column(name = "category", nullable = false, length = 32) + private String category; + + /** Running total of metered units in this period+category. */ + @Column(name = "cumulative_units", nullable = false) + private long cumulativeUnits; + + /** + * {@link #cumulativeUnits} as of the last sync SaaS accepted; the difference is the unreported + * usage the portal shows on top of SaaS-synced spend. The {@code columnDefinition} default + * keeps the {@code ddl-auto=update} ADD COLUMN safe against a table an earlier build already + * populated (NOT NULL with no default would fail the ALTER). + */ + @Column( + name = "last_synced_units", + nullable = false, + columnDefinition = "bigint not null default 0") + private long lastSyncedUnits; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** Fresh-accrual row: nothing synced yet. */ + public UsageCounter( + LocalDateTime periodStart, + String category, + long cumulativeUnits, + LocalDateTime updatedAt) { + this(periodStart, category, cumulativeUnits, 0L, updatedAt); + } + + public UsageCounter( + LocalDateTime periodStart, + String category, + long cumulativeUnits, + long lastSyncedUnits, + LocalDateTime updatedAt) { + this.periodStart = periodStart; + this.category = category; + this.cumulativeUnits = cumulativeUnits; + this.lastSyncedUnits = lastSyncedUnits; + this.updatedAt = updatedAt; + } + + /** This row's category as the enum, or {@code null} for an unrecognised stored value. */ + public BillingCategory billingCategory() { + try { + return BillingCategory.valueOf(category); + } catch (IllegalArgumentException unknown) { + return null; + } + } + + /** Units accrued but not yet accepted by SaaS (floored at 0). */ + public long unsyncedUnits() { + return Math.max(0, cumulativeUnits - lastSyncedUnits); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java new file mode 100644 index 0000000000..2140775abc --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java @@ -0,0 +1,57 @@ +package stirling.software.proprietary.accountlink; + +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.transaction.annotation.Transactional; + +/** Persistence for the per-period/per-category usage counters (combined-billing "Mode A"). */ +public interface UsageCounterRepository extends JpaRepository { + + /** + * Atomically adds {@code delta} to an existing counter row. Returns the number of rows updated + * (0 when the row doesn't exist yet — the caller then inserts). Doing the add in SQL avoids a + * read-modify-write race between concurrent billable requests. + */ + @Modifying + @Transactional + @Query( + "UPDATE UsageCounter c SET c.cumulativeUnits = c.cumulativeUnits + :delta," + + " c.updatedAt = :now" + + " WHERE c.periodStart = :periodStart AND c.category = :category") + int increment( + @Param("periodStart") LocalDateTime periodStart, + @Param("category") String category, + @Param("delta") long delta, + @Param("now") LocalDateTime now); + + /** All counters for a period — the daily sync reads these to report cumulative totals. */ + List findByPeriodStart(LocalDateTime periodStart); + + /** + * Periods (oldest first) that still hold usage not yet accepted by SaaS. The sync reports each + * so end-of-period usage isn't stranded when the billing period rolls over between syncs. + */ + @Query( + "SELECT DISTINCT c.periodStart FROM UsageCounter c" + + " WHERE c.cumulativeUnits > c.lastSyncedUnits ORDER BY c.periodStart") + List findPeriodsWithUnsyncedUsage(); + + /** + * Marks a counter synced up to {@code syncedUnits} (the cumulative value just accepted by + * SaaS), not the live cumulative — concurrent accruals during the sync stay correctly unsynced. + */ + @Modifying + @Transactional + @Query( + "UPDATE UsageCounter c SET c.lastSyncedUnits = :syncedUnits" + + " WHERE c.periodStart = :periodStart AND c.category = :category") + int markSynced( + @Param("periodStart") LocalDateTime periodStart, + @Param("category") String category, + @Param("syncedUnits") long syncedUnits); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageMeterService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageMeterService.java new file mode 100644 index 0000000000..6aa93606fb --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageMeterService.java @@ -0,0 +1,115 @@ +package stirling.software.proprietary.accountlink; + +import java.time.Duration; +import java.time.LocalDateTime; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.billing.BillingCategory; + +/** + * Accrues metered usage into the durable per-(period, category) {@link UsageCounter}; the daily + * sync later reports the cumulative totals to SaaS. + * + *

    Workflow-window dedup: an identical input set re-submitted within {@code metering.workflow- + * window} is treated as chaining and not re-charged; the same inputs run again after the window are + * billed afresh — matching the cloud's open-job lineage window so the same op costs the same on the + * instance and in the cloud. Fileless ops pass a null signature and always accrue. {@link #accrue} + * is best-effort: callers need not handle persistence errors. + */ +@Slf4j +@Service +@Profile("!saas") +@ConditionalOnProperty( + name = "stirling.billing.account-link.metering.enabled", + havingValue = "true") +public class UsageMeterService { + + private final UsageCounterRepository repo; + private final MeteredInputSignatureRepository signatureRepo; + private final Duration workflowWindow; + + public UsageMeterService( + UsageCounterRepository repo, + MeteredInputSignatureRepository signatureRepo, + AccountLinkProperties properties) { + this.repo = repo; + this.signatureRepo = signatureRepo; + this.workflowWindow = properties.getMetering().getWorkflowWindow(); + } + + /** + * Adds {@code units} to the {@code (periodStart, category)} counter (creating the row on first + * use), unless {@code opSignature} was already metered this period. No-ops for non-billable + * categories, non-positive units, or a missing period. + */ + public void accrue( + LocalDateTime periodStart, BillingCategory category, long units, String opSignature) { + if (periodStart == null + || category == null + || category == BillingCategory.BYPASSED + || units <= 0) { + return; + } + if (opSignature != null && !shouldCharge(periodStart, opSignature)) { + return; // identical inputs seen within the workflow window — chaining, already billed + } + incrementOrInsert(periodStart, category.name(), units); + } + + /** + * True when this input set should be charged: unseen this period, or last seen outside the + * workflow window. Records a first sighting (an atomic insert-as-claim under concurrency) and + * slides the window on a repeat. Fails toward charging so a store hiccup never drops a charge. + */ + private boolean shouldCharge(LocalDateTime periodStart, String opSignature) { + LocalDateTime now = LocalDateTime.now(); + MeteredInputSignature seen = + signatureRepo.findByPeriodStartAndSignature(periodStart, opSignature).orElse(null); + if (seen == null) { + try { + signatureRepo.saveAndFlush( + new MeteredInputSignature(periodStart, opSignature, now)); + return true; // first sighting this period + } catch (DataIntegrityViolationException raced) { + return false; // a concurrent op just claimed it — within window → chaining + } catch (RuntimeException e) { + log.debug("Signature claim failed for {}: {}", periodStart, e.getMessage()); + return true; + } + } + LocalDateTime last = seen.getLastMeteredAt() != null ? seen.getLastMeteredAt() : now; + boolean withinWindow = last.isAfter(now.minus(workflowWindow)); + try { + seen.touch(now); + signatureRepo.save(seen); + } catch (RuntimeException e) { + log.debug("Signature touch failed for {}: {}", periodStart, e.getMessage()); + } + return !withinWindow; + } + + private void incrementOrInsert(LocalDateTime periodStart, String category, long units) { + LocalDateTime now = LocalDateTime.now(); + try { + if (repo.increment(periodStart, category, units, now) > 0) { + return; + } + try { + repo.saveAndFlush(new UsageCounter(periodStart, category, units, now)); + } catch (DataIntegrityViolationException raceLostInsert) { + // A concurrent request inserted the row first — increment the now-existing row. + repo.increment(periodStart, category, units, now); + } + } catch (RuntimeException e) { + // Metering must never break the request it rode in on; a lost accrual self-heals on the + // next increment and the daily sync reports the cumulative total either way. + log.debug("Usage accrual failed for {}/{}: {}", periodStart, category, e.getMessage()); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java new file mode 100644 index 0000000000..4c4ce2377c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java @@ -0,0 +1,189 @@ +package stirling.software.proprietary.accountlink; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.EnumMap; +import java.util.List; +import java.util.Optional; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.SchedulingConfigurer; +import org.springframework.scheduling.config.FixedDelayTask; +import org.springframework.scheduling.config.ScheduledTaskRegistrar; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.billing.BillingCategory; + +/** + * Daily usage sender for combined-billing "Mode A". Reports each period's cumulative per-category + * usage to SaaS, which bills the delta against its own last-seen totals. + * + *

    Resilience: the sync seq is persisted before the report so it never regresses across + * restarts/failures; a transport failure leaves the {@code lastSyncedUnits} markers untouched so + * usage rolls into the next sync; and reporting the same cumulative twice bills nothing. All + * periods with unsynced usage are reported so nothing is stranded when the period rolls over + * between syncs. + */ +@Slf4j +@Service +@Profile("!saas") +@ConditionalOnProperty( + name = "stirling.billing.account-link.metering.enabled", + havingValue = "true") +public class UsageSyncService implements SchedulingConfigurer { + + // First run waits out startup churn; then every interval. + private static final Duration INITIAL_DELAY = Duration.ofMinutes(5); + + private final UsageCounterRepository counters; + private final AccountLinkSyncStateRepository syncState; + private final DeviceCredentialStore credentialStore; + private final AccountLinkClient client; + private final EntitlementCache entitlementCache; + private final AccountLinkProperties properties; + + public UsageSyncService( + UsageCounterRepository counters, + AccountLinkSyncStateRepository syncState, + DeviceCredentialStore credentialStore, + AccountLinkClient client, + EntitlementCache entitlementCache, + AccountLinkProperties properties) { + this.counters = counters; + this.syncState = syncState; + this.credentialStore = credentialStore; + this.client = client; + this.entitlementCache = entitlementCache; + this.properties = properties; + } + + /** + * Registers the daily sync, binding the interval from {@code metering.sync-interval-hours} in + * code rather than a {@code @Scheduled} SpEL string so a bad interval fails at boot/test rather + * than only on a flags-on run. + */ + @Override + public void configureTasks(ScheduledTaskRegistrar registrar) { + Duration interval = Duration.ofHours(properties.getMetering().getSyncIntervalHours()); + registrar.addFixedDelayTask( + new FixedDelayTask(this::scheduledSync, interval, INITIAL_DELAY)); + } + + public void scheduledSync() { + try { + syncNow(); + } catch (RuntimeException e) { + log.debug("Scheduled usage sync failed", e); + } + } + + /** + * Reports every period with unsynced usage and refreshes the cached entitlement from the reply. + * Single daily caller (non-reentrant {@code fixedDelay}), so no internal locking. No-op when + * unlinked or when nothing is pending. + */ + public void syncNow() { + Optional cred = credentialStore.get(); + if (cred.isEmpty()) { + return; // not linked + } + List periods = counters.findPeriodsWithUnsyncedUsage(); + if (periods.isEmpty()) { + // Nothing to report, but a sync is also our cue to pick up an out-of-band entitlement + // change (e.g. the admin just subscribed) that otherwise wouldn't surface until the + // cache TTL lapses. Force an immediate refresh so the gate reflects the new plan now. + entitlementCache.invalidate(); + entitlementCache.current(); + return; + } + InstanceEntitlement latest = null; + try { + for (LocalDateTime period : periods) { + InstanceEntitlement fresh = syncPeriod(cred.get(), period); + if (fresh != null) { + latest = fresh; + } + } + } catch (AccountLinkClient.RevokedException e) { + // Authoritative deny — stop reporting; the entitlement cache blocks billable work on + // its + // own next refresh, so we don't synthesise the blocked state here. + log.info( + "Usage sync denied (HTTP {}); credential revoked/invalid — gate blocks on next" + + " refresh", + e.status()); + return; + } + // Adopt the freshest entitlement the sync returned, saving the cache a redundant fetch. + entitlementCache.accept(latest); + } + + /** Reports one period; returns the fresh entitlement, or null on a transport/server failure. */ + private InstanceEntitlement syncPeriod(DeviceCredential cred, LocalDateTime period) { + EnumMap cumulative = new EnumMap<>(BillingCategory.class); + for (UsageCounter c : counters.findByPeriodStart(period)) { + BillingCategory cat = c.billingCategory(); + if (cat != null && cat != BillingCategory.BYPASSED) { + cumulative.merge(cat, c.getCumulativeUnits(), Long::sum); + } + } + AccountLinkSyncState state = loadState(); + long seq = reserveNextSeq(state); + InstanceEntitlement fresh = + client.reportUsage( + cred.getDeviceId(), + cred.getDeviceSecret(), + seq, + period, + cumulative.getOrDefault(BillingCategory.API, 0L), + cumulative.getOrDefault(BillingCategory.AI, 0L), + cumulative.getOrDefault(BillingCategory.AUTOMATION, 0L)); + if (fresh == null) { + // Transport/server failure: leave the synced markers untouched. The burned seq is + // harmless (seqs need only be monotonic) and the delta bills on the next successful + // sync. + return null; + } + recordSuccess(period, cumulative, state); + return fresh; + } + + /** Reserves and persists the next strictly-increasing sequence before the report goes out. */ + private long reserveNextSeq(AccountLinkSyncState state) { + long next = state.getLastSyncSeq() + 1; + state.setLastSyncSeq(next); + syncState.save(state); + return next; + } + + /** + * Advances the per-category synced markers to the reported totals + stamps the success time. + */ + private void recordSuccess( + LocalDateTime period, + EnumMap cumulative, + AccountLinkSyncState state) { + cumulative.forEach( + (category, units) -> { + if (units > 0) { + counters.markSynced(period, category.name(), units); + } + }); + state.setLastSuccessAt(LocalDateTime.now()); + syncState.save(state); + } + + private AccountLinkSyncState loadState() { + return syncState + .findById(AccountLinkSyncState.SINGLETON_ID) + .orElseGet( + () -> { + AccountLinkSyncState s = new AccountLinkSyncState(); + s.setId(AccountLinkSyncState.SINGLETON_ID); + return s; + }); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java index 0d777d9481..c59c6f3721 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java @@ -130,6 +130,7 @@ public class ControllerAuditAspect { String previousPrincipal = MDC.get("auditPrincipal"); String previousOrigin = MDC.get("auditOrigin"); + String previousSource = MDC.get("auditSource"); String previousIp = MDC.get("auditIp"); // EARLY CAPTURE: Capture from SecurityContext on request thread, store in MDC for async @@ -161,6 +162,14 @@ public class ControllerAuditAspect { return joinPoint.proceed(); } + // Stamp the free-UI source only for non-@Audited controller traffic — an actual + // tool / UI action. @Audited events (login, settings) return above without a source, + // so they never count as an "active editor" or a free UI run. The finally block + // restores auditSource, so a pooled thread can't leak a stale "WEB" into them. + if (previousSource == null) { + MDC.put("auditSource", auditService.captureCurrentSource()); + } + long start = System.currentTimeMillis(); // Use auditService to create the base audit data @@ -247,6 +256,7 @@ public class ControllerAuditAspect { } finally { restoreMdcValue("auditPrincipal", previousPrincipal); restoreMdcValue("auditOrigin", previousOrigin); + restoreMdcValue("auditSource", previousSource); restoreMdcValue("auditIp", previousIp); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/DefaultPortalAuditScopeResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/DefaultPortalAuditScopeResolver.java new file mode 100644 index 0000000000..3eb476c4df --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/DefaultPortalAuditScopeResolver.java @@ -0,0 +1,15 @@ +package stirling.software.proprietary.audit; + +import org.springframework.stereotype.Component; + +/** Self-hosted default: admins see the whole-server audit log, everyone else is denied. */ +@Component +public class DefaultPortalAuditScopeResolver implements PortalAuditScopeResolver { + + @Override + public PortalAuditScope resolve() { + return PortalAuditScopeResolver.hasAdminAuthority() + ? PortalAuditScope.server() + : PortalAuditScope.denied(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/PortalAuditEventRow.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/PortalAuditEventRow.java new file mode 100644 index 0000000000..622d17c354 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/PortalAuditEventRow.java @@ -0,0 +1,7 @@ +package stirling.software.proprietary.audit; + +import java.time.Instant; + +/** Immutable, cacheable projection of an {@code audit_events} row, shared across portal views. */ +public record PortalAuditEventRow( + long id, String principal, String type, String data, Instant timestamp) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/PortalAuditScope.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/PortalAuditScope.java new file mode 100644 index 0000000000..078dc2a332 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/PortalAuditScope.java @@ -0,0 +1,21 @@ +package stirling.software.proprietary.audit; + +import java.util.List; + +/** Resolved audit visibility: fullServer (admin), principals-scoped (team lead), or !allowed. */ +public record PortalAuditScope( + boolean allowed, boolean fullServer, List principals, String cacheKey) { + + public static PortalAuditScope denied() { + return new PortalAuditScope(false, false, List.of(), "denied"); + } + + // Named server()/team() to avoid colliding with the record's fullServer() accessor. + public static PortalAuditScope server() { + return new PortalAuditScope(true, true, List.of(), "server"); + } + + public static PortalAuditScope team(String cacheKey, List principals) { + return new PortalAuditScope(true, false, List.copyOf(principals), cacheKey); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/PortalAuditScopeResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/PortalAuditScopeResolver.java new file mode 100644 index 0000000000..59b68d720e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/PortalAuditScopeResolver.java @@ -0,0 +1,18 @@ +package stirling.software.proprietary.audit; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +/** Resolves which slice of the audit log the caller may see. */ +public interface PortalAuditScopeResolver { + + PortalAuditScope resolve(); + + /** True when the current authentication carries {@code ROLE_ADMIN}. */ + static boolean hasAdminAuthority() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + return auth != null + && auth.getAuthorities().stream() + .anyMatch(a -> "ROLE_ADMIN".equals(a.getAuthority())); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/billing/BillingCategory.java b/app/proprietary/src/main/java/stirling/software/proprietary/billing/BillingCategory.java new file mode 100644 index 0000000000..51ca47ad64 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/billing/BillingCategory.java @@ -0,0 +1,22 @@ +package stirling.software.proprietary.billing; + +/** + * The billing / analytics axis for a metered operation. PAYG runs on a single flat-priced meter, so + * category is metadata only and never affects price. + * + *

    Classification precedence is {@code AUTOMATION → AI → API → BYPASSED} (see {@link + * BillingCategoryClassifier}); {@link #BYPASSED} is a manual interactive tool call that is never + * billed. + * + *

    Mirrors the value set of the SaaS {@code payg.model.BillingCategory}. A linked self-hosted + * instance reports usage per category to SaaS as the lower-case names ({@code api} / {@code ai} / + * {@code automation}) in the daily sync, and SaaS maps them back — so the two enums must keep the + * same names. (We deliberately do not share one enum across the modules: that would drag the SaaS + * billing enum through ~20 hot-path files for what is JSON-string metadata on the wire.) + */ +public enum BillingCategory { + BYPASSED, + API, + AI, + AUTOMATION +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/billing/BillingCategoryClassifier.java b/app/proprietary/src/main/java/stirling/software/proprietary/billing/BillingCategoryClassifier.java new file mode 100644 index 0000000000..95b3abb99c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/billing/BillingCategoryClassifier.java @@ -0,0 +1,29 @@ +package stirling.software.proprietary.billing; + +/** + * Pure precedence for bucketing a request into a {@link BillingCategory}, so the SaaS engine and a + * linked self-hosted instance classify identically. Each backend resolves the three signals from + * its own types — the automation marker header; an AI-surface signal (a {@code @RequiresFeature} + * annotation / route on SaaS, a path prefix on the instance); API-key authentication — and this + * applies the order {@code AUTOMATION → AI → API → BYPASSED}. + * + *

    An AI tool dispatched inside a pipeline / workflow therefore bills as {@code AUTOMATION} (the + * automation header dominates), while a direct call to it bills as {@code AI}. + */ +public final class BillingCategoryClassifier { + + private BillingCategoryClassifier() {} + + public static BillingCategory classify(boolean automation, boolean ai, boolean apiKey) { + if (automation) { + return BillingCategory.AUTOMATION; + } + if (ai) { + return BillingCategory.AI; + } + if (apiKey) { + return BillingCategory.API; + } + return BillingCategory.BYPASSED; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/billing/ContentHasher.java b/app/proprietary/src/main/java/stirling/software/proprietary/billing/ContentHasher.java new file mode 100644 index 0000000000..232dd499dc --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/billing/ContentHasher.java @@ -0,0 +1,68 @@ +package stirling.software.proprietary.billing; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +/** + * SHA-256 content fingerprint shared by the SaaS charge path and the linked self-hosted instance's + * meter (combined-billing "Mode A"), so both derive an identical signature for the same + * bytes — the basis for lineage dedup. Pure, no Spring: fixed 64 KiB buffer (allocation independent + * of file size), hardware-accelerated by the JVM where available. + * + *

    Lives in {@code :proprietary} (not {@code :common}) so it stays out of the community core + * build yet is reachable from {@code :saas} (which depends on {@code :proprietary}). + */ +public final class ContentHasher { + + private static final String ALGORITHM = "SHA-256"; + private static final int BUFFER_SIZE = 64 * 1024; + + private ContentHasher() {} + + /** Lower-case hex SHA-256 of the file's bytes. */ + public static String sha256(Path file) throws IOException { + MessageDigest digest = newDigest(); + try (InputStream raw = Files.newInputStream(file); + DigestInputStream in = new DigestInputStream(raw, digest)) { + byte[] buf = new byte[BUFFER_SIZE]; + while (in.read(buf) != -1) { + // drain through the digest; we only want the side effect + } + } + return HexFormat.of().formatHex(digest.digest()); + } + + /** Lower-case hex SHA-256 of the given bytes (e.g. to combine per-file hashes into one key). */ + public static String sha256(byte[] bytes) { + return HexFormat.of().formatHex(newDigest().digest(bytes)); + } + + /** + * A fresh SHA-256 digest, for callers that stream bytes through a {@link + * java.security.DigestOutputStream} to hash in the same pass that writes the file — avoiding a + * second full read just to fingerprint it. Pair with {@link #toHex(byte[])}. + */ + public static MessageDigest newSha256() { + return newDigest(); + } + + /** Lower-case hex of a completed digest — the same format {@link #sha256(Path)} produces. */ + public static String toHex(byte[] digest) { + return HexFormat.of().formatHex(digest); + } + + private static MessageDigest newDigest() { + try { + return MessageDigest.getInstance(ALGORITHM); + } catch (NoSuchAlgorithmException e) { + // SHA-256 is mandated by every JDK; unreachable in practice. + throw new IllegalStateException(ALGORITHM + " unavailable — JDK is misconfigured", e); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/billing/DocumentUnitCalculator.java b/app/proprietary/src/main/java/stirling/software/proprietary/billing/DocumentUnitCalculator.java new file mode 100644 index 0000000000..2cc22daf41 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/billing/DocumentUnitCalculator.java @@ -0,0 +1,76 @@ +package stirling.software.proprietary.billing; + +import java.util.List; + +/** + * Pure doc-unit math shared by the SaaS billing engine and a linked self-hosted instance, so both + * cost an operation identically. No Spring, no IO: callers supply page/byte facts (read however + * their backend reads them — e.g. jpdfium for PDFs) plus a {@link UnitCalcPolicy}. + * + *

    Raw units for one file = the larger of {@code ceil(pages / docPagesPerUnit)} and {@code + * ceil(bytes / docBytesPerUnit)} (non-PDF inputs pass {@code pages = 0}, so only the bytes axis + * contributes). A single file is clamped to {@code [1, fileUnitCap]}; a multi-file group is the + * raw per-file sum clamped to {@code [1, fileUnitCap * file_count]} (summing raw, not + * per-file-clamped, units so the group cap can actually bind). + * + *

    {@link UnitCalcPolicy#minChargeUnits()} is applied by the charge layer, not here; this + * enforces only an absolute floor of {@link #MIN_UNITS_PER_NONEMPTY_FILE} so callers can rely on + * "non-empty input → at least 1 unit". Extracted verbatim from the SaaS {@code + * DefaultDocumentClassifier} to preserve behaviour. + */ +public final class DocumentUnitCalculator { + + /** Floor for non-empty input. Distinct from {@link UnitCalcPolicy#minChargeUnits()}. */ + public static final int MIN_UNITS_PER_NONEMPTY_FILE = 1; + + private DocumentUnitCalculator() {} + + /** One file's page count (0 for non-PDF / unreadable) and byte size. */ + public record FileSize(int pages, long bytes) {} + + /** Raw (unclamped) units for one file. */ + public static long rawUnits(int pages, long bytes, UnitCalcPolicy policy) { + long pageUnits = pages > 0 ? ceilDiv(pages, policy.docPagesPerUnit()) : 0L; + long byteUnits = ceilDiv(bytes, policy.docBytesPerUnit()); + return Math.max(pageUnits, byteUnits); + } + + /** Units for a single file, clamped to {@code [1, fileUnitCap]}. */ + public static int unitsForFile(int pages, long bytes, UnitCalcPolicy policy) { + long raw = rawUnits(pages, bytes, policy); + // toIntExact: fail loud on overflow rather than silently wrapping a billing number. + return Math.toIntExact( + Math.max(MIN_UNITS_PER_NONEMPTY_FILE, Math.min(policy.fileUnitCap(), raw))); + } + + /** + * Units for a multi-file group: raw per-file sum clamped to {@code [1, fileUnitCap * count]}. + */ + public static int unitsForGroup(List files, UnitCalcPolicy policy) { + if (files.isEmpty()) { + throw new IllegalArgumentException("files must not be empty"); + } + long rawSum = 0; + for (FileSize f : files) { + rawSum = saturatedAdd(rawSum, rawUnits(f.pages(), f.bytes(), policy)); + } + long groupCap = (long) policy.fileUnitCap() * files.size(); + return Math.toIntExact( + Math.max((long) MIN_UNITS_PER_NONEMPTY_FILE, Math.min(groupCap, rawSum))); + } + + private static long ceilDiv(long numerator, long divisor) { + if (numerator <= 0) { + return 0; + } + return (numerator + divisor - 1) / divisor; + } + + private static long saturatedAdd(long a, long b) { + try { + return Math.addExact(a, b); + } catch (ArithmeticException e) { + return Long.MAX_VALUE; + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/billing/UnitCalcPolicy.java b/app/proprietary/src/main/java/stirling/software/proprietary/billing/UnitCalcPolicy.java new file mode 100644 index 0000000000..b7d773d465 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/billing/UnitCalcPolicy.java @@ -0,0 +1,30 @@ +package stirling.software.proprietary.billing; + +/** + * The four billing knobs the doc-unit math needs, split out of the SaaS {@code PricingPolicy} JPA + * entity so the calculation ({@link DocumentUnitCalculator}) can live in {@code :proprietary} and + * be shared by the SaaS billing engine and a linked self-hosted instance — both then cost an + * operation identically. + * + *

    The SaaS engine builds one from its persisted {@code PricingPolicy}; a linked instance + * receives these values in the daily entitlement sync. {@code minChargeUnits} is carried here for + * the charge layer; {@link DocumentUnitCalculator} itself does not apply it (see its docs). + */ +public record UnitCalcPolicy( + int docPagesPerUnit, long docBytesPerUnit, int minChargeUnits, int fileUnitCap) { + + public UnitCalcPolicy { + 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"); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java index bcb98aa04b..f83bf0afdc 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/config/CustomAuditEventRepository.java @@ -60,6 +60,8 @@ public class CustomAuditEventRepository implements AuditEventRepository { clean.put("requestId", rid); } + String source = MDC.get("auditSource"); + String auditEventData = mapper.writeValueAsString(clean); log.debug("AuditEvent data (JSON): {}", auditEventData); @@ -67,6 +69,7 @@ public class CustomAuditEventRepository implements AuditEventRepository { PersistentAuditEvent.builder() .principal(safePrincipal(ev.getPrincipal())) .type(ev.getType()) + .source(source) .data(auditEventData) .timestamp(ev.getTimestamp()) .build(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/FleetUsageController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/FleetUsageController.java new file mode 100644 index 0000000000..57b74362ec --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/FleetUsageController.java @@ -0,0 +1,71 @@ +package stirling.software.proprietary.controller.api; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.audit.AuditLevel; +import stirling.software.proprietary.config.AuditConfigurationProperties; +import stirling.software.proprietary.model.api.usage.FleetUsageStats; +import stirling.software.proprietary.repository.PersistentAuditEventRepository; +import stirling.software.proprietary.security.config.EnterpriseEndpoint; +import stirling.software.proprietary.security.database.repository.UserRepository; + +/** + * Admin endpoint exposing free-editor fleet usage for the portal Usage card. Audit-derived figures + * (active editors, PDFs processed) are null (rendered as "N/A") rather than a misleading 0 whenever + * the data can't exist: the events they count (PDF_PROCESS, FILE_OPERATION, HTTP_REQUEST) are all + * STANDARD level, so a gate on {@code isEnabled()} alone would still return 0 at level=OFF/BASIC — + * we gate on {@code isLevelEnabled(STANDARD)} instead. + * + *

    Known limitation: on a login-disabled self-hosted instance every request is anonymous, so its + * audit origin is SYSTEM (not WEB) and it is excluded from these WEB-only counts — active/PDFs then + * read 0 despite real usage. Historical audit rows written before the {@code source} column existed + * carry {@code source=null}, so the cumulative "PDFs edited" figure effectively starts at deploy. + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/usage") +@PreAuthorize("hasRole('ADMIN')") +@RequiredArgsConstructor +@EnterpriseEndpoint +public class FleetUsageController { + + private final PersistentAuditEventRepository auditRepository; + private final UserRepository userRepository; + private final AuditConfigurationProperties auditConfig; + + @GetMapping("/fleet-stats") + public FleetUsageStats fleetStats() { + // Exclude the reserved INTERNAL_API_USER row that InitialSecuritySetup creates on every + // install, so a fresh single-admin instance reads 1 editor, not 2. + Long deployed = userRepository.countByUsernameNot(Role.INTERNAL_API_USER.getRoleId()); + // STANDARD is the level at which the counted events are recorded; below it the data + // can't exist, so report N/A instead of a 0 that would misrepresent an empty table. + boolean auditOn = auditConfig.isLevelEnabled(AuditLevel.STANDARD); + Instant since = Instant.now().minus(30, ChronoUnit.DAYS); + Long active = + auditOn + ? auditRepository.countDistinctPrincipalsBySourceExcludingTypeAfter( + "WEB", "UI_DATA", since) + : null; + Long pdfs = + auditOn + ? auditRepository.countByTypeInAndSourceAndTimestampAfter( + List.of("PDF_PROCESS", "FILE_OPERATION"), "WEB", Instant.EPOCH) + : null; + if (active != null && deployed != null && active > deployed) { + active = deployed; // active editors are a subset of those deployed + } + return new FleetUsageStats(deployed, active, pdfs); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalDocumentsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalDocumentsController.java new file mode 100644 index 0000000000..14146eacb4 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalDocumentsController.java @@ -0,0 +1,46 @@ +package stirling.software.proprietary.controller.api; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import io.swagger.v3.oas.annotations.Operation; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.annotations.api.ProprietaryUiDataApi; +import stirling.software.proprietary.audit.PortalAuditScope; +import stirling.software.proprietary.audit.PortalAuditScopeResolver; +import stirling.software.proprietary.model.api.documents.PortalDocumentsResponseDto; +import stirling.software.proprietary.security.config.EnterpriseEndpoint; +import stirling.software.proprietary.service.PortalDocumentsService; + +/** Serves the portal Documents review queue, derived from real audit data and scoped per caller. */ +@ProprietaryUiDataApi +@RequiredArgsConstructor +@EnterpriseEndpoint +public class PortalDocumentsController { + + private final PortalDocumentsService portalDocumentsService; + private final PortalAuditScopeResolver auditScopeResolver; + + // tier accepted for mock-seam symmetry; ignored (queue isn't tier-scoped). + @GetMapping("/documents") + @Operation( + summary = "Documents review queue", + description = "Files processed through the org, derived from the audit trail.") + public ResponseEntity getDocuments( + @RequestParam(value = "tier", required = false) String tier) { + PortalAuditScope scope = auditScopeResolver.resolve(); + if (!scope.allowed()) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + PortalDocumentsResponseDto body = + scope.fullServer() + ? portalDocumentsService.serverDocuments() + : portalDocumentsService.scopedDocuments( + scope.cacheKey(), scope.principals()); + return ResponseEntity.ok(body); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalInfraAuditController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalInfraAuditController.java new file mode 100644 index 0000000000..866c295b8d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalInfraAuditController.java @@ -0,0 +1,47 @@ +package stirling.software.proprietary.controller.api; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import io.swagger.v3.oas.annotations.Operation; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.annotations.api.ProprietaryUiDataApi; +import stirling.software.proprietary.audit.PortalAuditScope; +import stirling.software.proprietary.audit.PortalAuditScopeResolver; +import stirling.software.proprietary.model.api.audit.InfraAuditLogResponse; +import stirling.software.proprietary.security.config.EnterpriseEndpoint; +import stirling.software.proprietary.service.PortalInfraAuditService; + +/** Serves the Infrastructure → Audit tab from real audit data, scoped and cached per caller. */ +@ProprietaryUiDataApi +@RequiredArgsConstructor +@EnterpriseEndpoint +public class PortalInfraAuditController { + + private final PortalInfraAuditService portalInfraAuditService; + private final PortalAuditScopeResolver auditScopeResolver; + + // tier accepted for endpoint symmetry; ignored (audit log isn't tier-scoped). + @GetMapping("/infrastructure/audit-log") + @Operation( + summary = "Infrastructure audit log", + description = "Recent audit events shaped for the portal Infrastructure → Audit tab.") + public ResponseEntity getInfrastructureAuditLog( + @RequestParam(value = "tier", required = false) String tier) { + PortalAuditScope scope = auditScopeResolver.resolve(); + if (!scope.allowed()) { + // Return 403 (not throw) so the tab shows its access message, not a generic 500. + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + InfraAuditLogResponse body = + scope.fullServer() + ? portalInfraAuditService.serverAuditLog() + : portalInfraAuditService.scopedAuditLog( + scope.cacheKey(), scope.principals()); + return ResponseEntity.ok(body); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowOutcome.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowOutcome.java index 2bed56f0f0..a7239e8f90 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowOutcome.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowOutcome.java @@ -21,8 +21,7 @@ public enum AiWorkflowOutcome { COMPLETED("completed"), UNSUPPORTED_CAPABILITY("unsupported_capability"), CANNOT_CONTINUE("cannot_continue"), - GENERATE_FILE("generate_file"), - CONVERT_MARKDOWN("convert_markdown"); + GENERATE_FILE("generate_file"); private final String value; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/InfraAuditEventDto.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/InfraAuditEventDto.java new file mode 100644 index 0000000000..86c27e2c6e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/InfraAuditEventDto.java @@ -0,0 +1,44 @@ +package stirling.software.proprietary.model.api.audit; + +import io.swagger.v3.oas.annotations.media.Schema; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * A single infrastructure audit-log row, shaped for the portal Infrastructure → Audit tab. Derived + * from a {@code audit_events} row: the real {@link + * stirling.software.proprietary.audit.AuditEventType} is mapped to a display category/action. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class InfraAuditEventDto { + + @Schema(description = "Audit event id", example = "8841") + private String id; + + @Schema(description = "Display timestamp (UTC)", example = "2026-07-07 18:59:31") + private String timestamp; + + @Schema(description = "Category: auth | config | elevation | processing | security") + private String category; + + @Schema(description = "Human-readable action", example = "Compress PDF") + private String action; + + @Schema(description = "Actor principal", example = "alice.chen@acme.com") + private String actor; + + @Schema(description = "Affected target (file, endpoint, or session)") + private String target; + + @Schema(description = "Status: success | warning | danger | info") + private String status; + + @Schema(description = "Operation latency in milliseconds", example = "412") + private long latencyMs; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/InfraAuditLogResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/InfraAuditLogResponse.java new file mode 100644 index 0000000000..9b1b9d31c8 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/InfraAuditLogResponse.java @@ -0,0 +1,30 @@ +package stirling.software.proprietary.model.api.audit; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** Response for the portal Infrastructure → Audit tab: summary strip + recent event rows. */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class InfraAuditLogResponse { + + @Schema(description = "Headline counts") + private InfraAuditSummary summary; + + @Schema(description = "Most-recent audit events, newest first") + private List events; + + @Schema( + description = + "True when this is the whole-server (admin) view. Team-scoped views are false; " + + "drives whether the admin-only, whole-server CSV export is offered.") + private boolean fullServer; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/InfraAuditSummary.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/InfraAuditSummary.java new file mode 100644 index 0000000000..c924218e3a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/InfraAuditSummary.java @@ -0,0 +1,28 @@ +package stirling.software.proprietary.model.api.audit; + +import io.swagger.v3.oas.annotations.media.Schema; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** Headline counts for the infrastructure audit-log tab, derived from the returned events. */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class InfraAuditSummary { + + @Schema(description = "Total events in the returned window", example = "40") + private int totalEvents; + + @Schema(description = "Processing-category events", example = "24") + private int processing; + + @Schema(description = "Elevation-category events", example = "0") + private int elevation; + + @Schema(description = "Config-category events", example = "6") + private int config; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/documents/PortalDocAuditEventDto.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/documents/PortalDocAuditEventDto.java new file mode 100644 index 0000000000..c3e4ac3d7e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/documents/PortalDocAuditEventDto.java @@ -0,0 +1,24 @@ +package stirling.software.proprietary.model.api.documents; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** One event in a document's lifecycle timeline. */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PortalDocAuditEventDto { + private String id; + + /** ingested | extracted | flagged | reviewed | approved | archived | elevation */ + private String kind; + + /** Relative-time string, e.g. "2m ago". */ + private String time; + + private String actor; + private String detail; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/documents/PortalDocumentsResponseDto.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/documents/PortalDocumentsResponseDto.java new file mode 100644 index 0000000000..f9b1f28009 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/documents/PortalDocumentsResponseDto.java @@ -0,0 +1,18 @@ +package stirling.software.proprietary.model.api.documents; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** Response for the portal Documents review queue. */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PortalDocumentsResponseDto { + private PortalDocumentsSummaryDto summary; + private List documents; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/documents/PortalDocumentsSummaryDto.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/documents/PortalDocumentsSummaryDto.java new file mode 100644 index 0000000000..1222afdc03 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/documents/PortalDocumentsSummaryDto.java @@ -0,0 +1,18 @@ +package stirling.software.proprietary.model.api.documents; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** KPI strip for the documents queue. */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PortalDocumentsSummaryDto { + private int totalInQueue; + private int processed; + private int errors; + private int processedToday; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/documents/PortalExtractionDto.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/documents/PortalExtractionDto.java new file mode 100644 index 0000000000..e332530dbc --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/documents/PortalExtractionDto.java @@ -0,0 +1,17 @@ +package stirling.software.proprietary.model.api.documents; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** A single extracted field. Empty for audit-derived documents (no extraction data yet). */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PortalExtractionDto { + private String field; + private String value; + private double confidence; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/documents/PortalReviewDocumentDto.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/documents/PortalReviewDocumentDto.java new file mode 100644 index 0000000000..658b1c991d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/documents/PortalReviewDocumentDto.java @@ -0,0 +1,48 @@ +package stirling.software.proprietary.model.api.documents; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * A document in the review queue, derived from the audit trail of a processed file. Extraction + * fields ({@code confidence}, {@code extractions}) are absent/empty - that data doesn't exist yet. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PortalReviewDocumentDto { + private String id; + private String name; + private String type; + + /** Where it was processed: "API" or "Editor". */ + private String product; + + /** The operation / pipeline, e.g. "Compress PDF" (or "Editor"). */ + private String action; + + /** The user who ran it. */ + private String user; + + /** processed | error */ + private String status; + + private String source; + + /** Overall confidence 0..1, or null when there's no extraction data. */ + private Double confidence; + + private int fieldsExtracted; + + /** Relative-time string, e.g. "4m ago". */ + private String time; + + private boolean sensitive; + private List extractions; + private List audit; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/usage/FleetUsageStats.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/usage/FleetUsageStats.java new file mode 100644 index 0000000000..0e554fb884 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/usage/FleetUsageStats.java @@ -0,0 +1,6 @@ +package stirling.software.proprietary.model.api.usage; + +/** + * Free-editor fleet usage for the portal Usage card. Null fields render as "N/A" (uncomputable). + */ +public record FleetUsageStats(Long editorsDeployed, Long activeThisMonth, Long pdfsProcessed) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/security/PersistentAuditEvent.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/security/PersistentAuditEvent.java index 5d90926076..ccaf337c0b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/security/PersistentAuditEvent.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/security/PersistentAuditEvent.java @@ -18,7 +18,15 @@ import lombok.*; columnList = "principal,type"), @jakarta.persistence.Index( name = "idx_audit_type_timestamp", - columnList = "type,timestamp") + columnList = "type,timestamp"), + @jakarta.persistence.Index( + name = "idx_audit_type_source_timestamp", + columnList = "type,source,timestamp"), + // Leads with source (equality) for the active-editors query, which filters on + // source then a timestamp range and counts distinct principal. + @jakarta.persistence.Index( + name = "idx_audit_source_timestamp_principal", + columnList = "source,timestamp,principal") }) @Data @Builder @@ -32,6 +40,7 @@ public class PersistentAuditEvent { private String principal; private String type; + private String source; @Column(columnDefinition = "text") private String data; // JSON blob diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java index 27bca098b3..7c0081dd7c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java @@ -259,4 +259,21 @@ public interface PersistentAuditEventRepository extends JpaRepository :startDate") List findAllExceptTypeAndTimestampAfterForExport( @Param("excludeType") String excludeType, @Param("startDate") Instant startDate); + + // Free-editor fleet usage: count genuine free-UI operations (source = "WEB") by type. + @Query( + "SELECT COUNT(e) FROM PersistentAuditEvent e " + + "WHERE e.type IN :types AND e.source = :source AND e.timestamp > :since") + long countByTypeInAndSourceAndTimestampAfter( + @Param("types") List types, + @Param("source") String source, + @Param("since") Instant since); + + @Query( + "SELECT COUNT(DISTINCT e.principal) FROM PersistentAuditEvent e " + + "WHERE e.source = :source AND e.type <> :excludeType AND e.timestamp > :since") + long countDistinctPrincipalsBySourceExcludingTypeAfter( + @Param("source") String source, + @Param("excludeType") String excludeType, + @Param("since") Instant since); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/CacheConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/CacheConfig.java index 501826a084..eef27703f2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/CacheConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/CacheConfig.java @@ -24,6 +24,9 @@ public class CacheConfig { this.applicationProperties = applicationProperties; } + /** Short-TTL cache of recent audit rows, shared by every audit-derived portal view. */ + private static final String PORTAL_AUDIT_EVENTS_CACHE = "portalAuditEvents"; + @Bean public CacheManager cacheManager() { int keyRetentionDays = applicationProperties.getSecurity().getJwt().getKeyRetentionDays(); @@ -33,6 +36,14 @@ public class CacheConfig { .maximumSize(1000) // Make configurable? .expireAfterWrite(Duration.ofDays(keyRetentionDays)) .recordStats()); + // 30s TTL keeps audit views near-live without re-scanning the DB; one entry per scope. + cacheManager.registerCustomCache( + PORTAL_AUDIT_EVENTS_CACHE, + Caffeine.newBuilder() + .maximumSize(256) + .expireAfterWrite(Duration.ofSeconds(30)) + .recordStats() + .build()); return cacheManager; } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java index 4e592e427a..acae6044de 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java @@ -49,6 +49,9 @@ public interface UserRepository extends JpaRepository { long countByTeam(Team team); + /** Count real users, excluding a reserved username such as the internal API user. */ + long countByUsernameNot(String username); + List findAllByTeam(Team team); // OAuth grandfathering queries diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java index 4e6c515318..d3b2d21a00 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java @@ -68,7 +68,6 @@ import tools.jackson.databind.ObjectMapper; public class AiWorkflowService { private static final String DOCUMENTS_ENDPOINT = "/api/v1/documents"; - private static final String PDF_TO_MARKDOWN_ENDPOINT = "/api/v1/convert/pdf/markdown"; private final CustomPDFDocumentFactory pdfDocumentFactory; private final AiEngineClient aiEngineClient; @@ -196,7 +195,6 @@ public class AiWorkflowService { return switch (response.getOutcome()) { case NEED_CONTENT -> onNeedContent(response, filesById, request, listener); case NEED_INGEST -> onNeedIngest(response, filesById, request, listener); - case CONVERT_MARKDOWN -> onConvertMarkdown(response, filesById, listener); case TOOL_CALL -> onToolCall(response, filesById, listener); case PLAN -> onPlan(response, filesById, request, listener); case ANSWER -> onAnswer(response, filesById, request, listener); @@ -333,72 +331,6 @@ public class AiWorkflowService { return new WorkflowState.Pending(nextRequest); } - /** - * Deterministically convert each requested PDF to Markdown via the {@code - * /convert/pdf/markdown} endpoint (backed by {@code PdfMarkdownConverter}) and return the - * {@code .md} file(s) as a completed result. No AI resume — the conversion output is the final - * answer. - */ - private WorkflowState onConvertMarkdown( - AiWorkflowResponse response, - Map filesById, - ProgressListener listener) { - List filesToConvert = response.getFilesToIngest(); - if (filesToConvert == null || filesToConvert.isEmpty()) { - return new WorkflowState.Terminal( - cannotContinue( - "AI engine requested markdown conversion without listing any files.")); - } - - try { - List resultFiles = new ArrayList<>(); - List inputNames = new ArrayList<>(); - for (int i = 0; i < filesToConvert.size(); i++) { - AiFile file = filesToConvert.get(i); - MultipartFile multipartFile = filesById.get(file.getId()); - if (multipartFile == null) { - return new WorkflowState.Terminal( - cannotContinue( - "AI engine requested markdown conversion for unknown file: " - + file.getName())); - } - listener.onProgress( - AiWorkflowProgressEvent.executingTool( - PDF_TO_MARKDOWN_ENDPOINT, i + 1, filesToConvert.size())); - Resource input = toResource(multipartFile); - PipelineDefinition definition = - new PipelineDefinition( - "convert-markdown", - List.of(new PipelineStep(PDF_TO_MARKDOWN_ENDPOINT, Map.of())), - null); - PolicyExecutionResult result = - policyExecutor.execute( - definition, - PolicyInputs.of(List.of(input)), - PolicyProgressListener.NOOP); - resultFiles.addAll(result.files()); - inputNames.add(multipartFile.getOriginalFilename()); - } - return new WorkflowState.Terminal( - buildCompletedResponse(null, resultFiles, inputNames, null)); - } catch (InternalApiTimeoutException e) { - log.error("PDF to Markdown conversion timed out: {}", e.getMessage()); - return new WorkflowState.Terminal( - cannotContinue(toolTimeoutMessage(PDF_TO_MARKDOWN_ENDPOINT, e))); - } catch (Exception e) { - AiWorkflowResponse limit = paygLimitResponseOrNull(e); - if (limit != null) { - log.info( - "AI markdown conversion blocked by downstream entitlement gate ({})", - limit.getErrorCode()); - return new WorkflowState.Terminal(limit); - } - log.error("Failed to convert PDF to Markdown: {}", e.getMessage(), e); - return new WorkflowState.Terminal( - cannotContinue(toolFailureMessage(PDF_TO_MARKDOWN_ENDPOINT, e))); - } - } - private Resource toResource(MultipartFile file) throws IOException { TempFile tempFile = tempFileManager.createManagedTempFile("ai-workflow"); file.transferTo(tempFile.getPath()); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java index d86ae644e1..a56ece5285 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java @@ -39,6 +39,7 @@ import stirling.software.common.model.api.PDFFile; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.RegexPatternUtils; import stirling.software.common.util.RequestUriUtils; +import stirling.software.proprietary.accountlink.BillableOperationClassifier; import stirling.software.proprietary.audit.AuditEventType; import stirling.software.proprietary.audit.AuditLevel; import stirling.software.proprietary.audit.Audited; @@ -847,6 +848,39 @@ public class AuditService { return origin; } + /** + * Refines {@link #determineOrigin()} into an audit {@code source} that isolates genuine + * free-editor UI runs from automation/AI traffic that also arrives over the web channel. + * + *

    API and SYSTEM origins pass through unchanged. A WEB origin is demoted to "AUTOMATION" or + * "AI" when the request carries the automation marker or targets an AI surface; only a manual + * interactive tool call ({@code BYPASSED}) stays "WEB". A "WEB" source therefore counts as a + * free/BYPASSED UI run. The automation/AI resolution is delegated to {@link + * BillableOperationClassifier} so it can't drift from the billing gate's own signal. + * + *

    IMPORTANT: like {@link #captureCurrentOrigin()} this must be called on the request thread + * before async execution, because it reads the current {@link HttpServletRequest}. + * + * @return "API", "SYSTEM", "AUTOMATION", "AI", or "WEB" + */ + public String captureCurrentSource() { + String origin = determineOrigin(); + if (!"WEB".equals(origin)) { + return origin; + } + + HttpServletRequest req = getCurrentRequest(); + if (req == null) { + return "WEB"; + } + // apiKey=false: a WEB origin already means the request is not API-key authenticated. + return switch (BillableOperationClassifier.categorize(req, false)) { + case AUTOMATION -> "AUTOMATION"; + case AI -> "AI"; + default -> "WEB"; + }; + } + /** * Determines the origin of the request: API (X-API-KEY), WEB (JWT), or SYSTEM (no auth). * IMPORTANT: This must be called in the request thread before async execution. diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalAuditReadService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalAuditReadService.java new file mode 100644 index 0000000000..1678cd095f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalAuditReadService.java @@ -0,0 +1,60 @@ +package stirling.software.proprietary.service; + +import java.util.List; + +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.audit.PortalAuditEventRow; +import stirling.software.proprietary.model.security.PersistentAuditEvent; +import stirling.software.proprietary.repository.PersistentAuditEventRepository; + +/** One cached read of recent {@code audit_events} per scope, shared by all audit-derived views. */ +@Service +@RequiredArgsConstructor +public class PortalAuditReadService { + + /** Cache name - registered with a short TTL in CacheConfig. */ + public static final String CACHE_NAME = "portalAuditEvents"; + + /** Newest rows to scan; each surface filters this down to what it shows. */ + private static final int SCAN_LIMIT = 400; + + private final PersistentAuditEventRepository auditRepository; + + /** Recent whole-server events (admins). */ + @Cacheable(value = CACHE_NAME, key = "'server'") + public List serverEvents() { + return toRows(auditRepository.findAll(recentPage()).getContent()); + } + + /** Recent events by the given principals (team scope). Empty principals yield an empty list. */ + @Cacheable(value = CACHE_NAME, key = "#cacheKey") + public List scopedEvents(String cacheKey, List principals) { + if (principals.isEmpty()) { + return List.of(); + } + return toRows(auditRepository.findByPrincipalIn(principals, recentPage()).getContent()); + } + + private static PageRequest recentPage() { + return PageRequest.of(0, SCAN_LIMIT, Sort.by(Sort.Direction.DESC, "timestamp")); + } + + private static List toRows(List events) { + return events.stream() + .map( + e -> + new PortalAuditEventRow( + e.getId() == null ? 0L : e.getId(), + e.getPrincipal(), + e.getType(), + e.getData(), + e.getTimestamp())) + .toList(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalDocumentsService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalDocumentsService.java new file mode 100644 index 0000000000..8784d5c5b6 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalDocumentsService.java @@ -0,0 +1,268 @@ +package stirling.software.proprietary.service; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.audit.PortalAuditEventRow; +import stirling.software.proprietary.model.api.documents.PortalDocAuditEventDto; +import stirling.software.proprietary.model.api.documents.PortalDocumentsResponseDto; +import stirling.software.proprietary.model.api.documents.PortalDocumentsSummaryDto; +import stirling.software.proprietary.model.api.documents.PortalReviewDocumentDto; + +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +/** Builds the Documents feed from audit_events: one row per file; extraction fields stay null. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PortalDocumentsService { + + private static final int RETURN_LIMIT = 40; + + private final PortalAuditReadService auditReadService; + private final ObjectMapper objectMapper; + + /** Whole-server view (admins). */ + public PortalDocumentsResponseDto serverDocuments() { + return build(auditReadService.serverEvents()); + } + + /** Team-scoped view: only files touched by {@code principals}. */ + public PortalDocumentsResponseDto scopedDocuments(String cacheKey, List principals) { + return build(auditReadService.scopedEvents(cacheKey, principals)); + } + + private PortalDocumentsResponseDto build(List events) { + // Events arrive newest-first; each file in a processing event is one activity row. + Instant dayAgo = Instant.now().minus(Duration.ofDays(1)); + List documents = new ArrayList<>(); + int processedToday = 0; + for (PortalAuditEventRow event : events) { + if (documents.size() >= RETURN_LIMIT) { + break; + } + if (!isFileBearing(event.type())) { + continue; + } + Map data = parseData(event); + Object files = data.get("files"); + if (!(files instanceof List fileList)) { + continue; + } + String path = asString(data.get("path")); + String source = sourceLabel(asString(data.get("__origin"))); + String product = "API integration".equals(source) ? "API" : "Editor"; + String action = prettyTool(path); + boolean failed = isFailure(data); + Instant ts = event.timestamp(); + long eventId = event.id(); + int idx = 0; + for (Object f : fileList) { + if (documents.size() >= RETURN_LIMIT) { + break; + } + if (!(f instanceof Map fileMap)) { + continue; + } + String name = asString(fileMap.get("name")); + if (name == null || name.isBlank()) { + continue; + } + documents.add( + toDocument( + eventId + "-" + idx++, + name, + asString(fileMap.get("type")), + product, + action, + event.principal(), + failed, + source, + ts)); + if (!failed && ts != null && ts.isAfter(dayAgo)) { + processedToday++; + } + } + } + + int processed = + (int) documents.stream().filter(d -> "processed".equals(d.getStatus())).count(); + int errors = documents.size() - processed; + + PortalDocumentsSummaryDto summary = + PortalDocumentsSummaryDto.builder() + .totalInQueue(documents.size()) + .processed(processed) + .errors(errors) + .processedToday(processedToday) + .build(); + + return PortalDocumentsResponseDto.builder().summary(summary).documents(documents).build(); + } + + /** Build one activity row from a single file inside one processing event. */ + private PortalReviewDocumentDto toDocument( + String rowId, + String name, + String contentType, + String product, + String action, + String user, + boolean failed, + String source, + Instant timestamp) { + PortalDocAuditEventDto op = + PortalDocAuditEventDto.builder() + .id(rowId + "-op") + .kind(failed ? "flagged" : "extracted") + .time(relativeTime(timestamp)) + .actor(user) + .detail(failed ? action + " failed" : action + " via " + source) + .build(); + + return PortalReviewDocumentDto.builder() + .id("doc-" + rowId) + .name(name) + .type(docType(contentType, name)) + .product(product) + .action(action) + .user(user) + .status(failed ? "error" : "processed") + .source(source) + .confidence(null) + .fieldsExtracted(0) + .time(relativeTime(timestamp)) + // Audit events don't reveal content sensitivity, so never guess it. + .sensitive(false) + .extractions(List.of()) + .audit(List.of(op)) + .build(); + } + + private static boolean isFileBearing(String type) { + return AuditEventType.PDF_PROCESS.name().equals(type) + || AuditEventType.FILE_OPERATION.name().equals(type); + } + + private static boolean isFailure(Map data) { + Object status = data.get("status"); + if (status instanceof String s && "failure".equalsIgnoreCase(s)) { + return true; + } + Object code = data.get("statusCode"); + return code instanceof Number n && n.intValue() >= 400; + } + + private static String sourceLabel(String origin) { + if ("API".equals(origin)) { + return "API integration"; + } + if ("SYSTEM".equals(origin)) { + return "System"; + } + return "Web upload"; + } + + private static String docType(String contentType, String name) { + String ct = contentType == null ? "" : contentType.toLowerCase(Locale.ROOT); + if (ct.contains("pdf") || name.toLowerCase(Locale.ROOT).endsWith(".pdf")) { + return "PDF"; + } + if (ct.startsWith("image/") || name.matches("(?i).*\\.(png|jpe?g|gif|webp|tiff?)$")) { + return "Image"; + } + if (ct.contains("word") || name.matches("(?i).*\\.docx?$")) { + return "Word"; + } + return "Document"; + } + + private static String prettyTool(String path) { + if (path == null || path.isBlank()) { + return "Processed"; + } + String[] parts = path.split("/"); + // Convert endpoints are /convert/{from}/{to}; label them as a conversion. + for (int i = 0; i + 2 < parts.length; i++) { + if ("convert".equals(parts[i]) && !parts[i + 1].isEmpty() && !parts[i + 2].isEmpty()) { + return "Convert " + prettyWords(parts[i + 1]) + " to " + prettyWords(parts[i + 2]); + } + } + String last = parts.length > 0 ? parts[parts.length - 1] : path; + String pretty = prettyWords(last); + return pretty.isEmpty() ? "Processed" : pretty; + } + + /** Title-case a hyphenated path segment, upper-casing known acronyms. */ + private static String prettyWords(String segment) { + StringBuilder sb = new StringBuilder(); + for (String word : segment.split("-")) { + if (word.isEmpty()) { + continue; + } + if (sb.length() > 0) { + sb.append(' '); + } + String lower = word.toLowerCase(Locale.ROOT); + sb.append( + switch (lower) { + case "pdf" -> "PDF"; + case "pdfs" -> "PDFs"; + case "ocr" -> "OCR"; + case "img" -> "Image"; + case "csv" -> "CSV"; + default -> Character.toUpperCase(word.charAt(0)) + word.substring(1); + }); + } + return sb.toString(); + } + + private static String relativeTime(Instant ts) { + if (ts == null) { + return ""; + } + long seconds = Duration.between(ts, Instant.now()).getSeconds(); + if (seconds < 60) { + return "just now"; + } + long minutes = seconds / 60; + if (minutes < 60) { + return minutes + "m ago"; + } + long hours = minutes / 60; + if (hours < 24) { + return hours + "h ago"; + } + return (hours / 24) + "d ago"; + } + + private Map parseData(PortalAuditEventRow event) { + if (event.data() == null || event.data().isEmpty()) { + return Map.of(); + } + try { + @SuppressWarnings("unchecked") + Map parsed = objectMapper.readValue(event.data(), Map.class); + // A literal "null" payload parses to null; treat it as empty, not an NPE. + return parsed == null ? Map.of() : parsed; + } catch (JacksonException e) { + log.warn("Failed to parse audit event {} data as JSON", event.id()); + return Map.of(); + } + } + + private static String asString(Object o) { + return o == null ? null : String.valueOf(o); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalInfraAuditService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalInfraAuditService.java new file mode 100644 index 0000000000..73d45800a8 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalInfraAuditService.java @@ -0,0 +1,251 @@ +package stirling.software.proprietary.service; + +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.audit.PortalAuditEventRow; +import stirling.software.proprietary.model.api.audit.InfraAuditEventDto; +import stirling.software.proprietary.model.api.audit.InfraAuditLogResponse; +import stirling.software.proprietary.model.api.audit.InfraAuditSummary; + +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +/** Maps cached audit_events to the Infrastructure → Audit tab, dropping read-noise types. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PortalInfraAuditService { + + /** Rows returned to the tab after filtering. */ + private static final int RETURN_LIMIT = 40; + + private static final DateTimeFormatter TS_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneOffset.UTC); + + private final PortalAuditReadService auditReadService; + private final ObjectMapper objectMapper; + + /** Whole-server view (admins). */ + public InfraAuditLogResponse serverAuditLog() { + return buildFromEvents(auditReadService.serverEvents(), true); + } + + /** Team-scoped view: only events by the given principals; empty yields an empty log. */ + public InfraAuditLogResponse scopedAuditLog(String cacheKey, List principals) { + return buildFromEvents(auditReadService.scopedEvents(cacheKey, principals), false); + } + + private InfraAuditLogResponse buildFromEvents( + List recent, boolean fullServer) { + List events = + recent.stream() + .filter(e -> isInfraRelevant(e.type())) + .map(this::toDto) + .limit(RETURN_LIMIT) + .toList(); + + int processing = + (int) events.stream().filter(e -> "processing".equals(e.getCategory())).count(); + int elevation = + (int) events.stream().filter(e -> "elevation".equals(e.getCategory())).count(); + int config = (int) events.stream().filter(e -> "config".equals(e.getCategory())).count(); + + InfraAuditSummary summary = + InfraAuditSummary.builder() + .totalEvents(events.size()) + .processing(processing) + .elevation(elevation) + .config(config) + .build(); + + return InfraAuditLogResponse.builder() + .summary(summary) + .events(events) + .fullServer(fullServer) + .build(); + } + + /** UI_DATA and HTTP_REQUEST are read/polling noise - excluded from the infrastructure view. */ + private static boolean isInfraRelevant(String type) { + return !AuditEventType.UI_DATA.name().equals(type) + && !AuditEventType.HTTP_REQUEST.name().equals(type); + } + + private InfraAuditEventDto toDto(PortalAuditEventRow event) { + Map data = parseData(event); + String path = asString(data.get("path")); + String category = categoryFor(event.type(), path); + + return InfraAuditEventDto.builder() + .id(String.valueOf(event.id())) + .timestamp(event.timestamp() == null ? "" : TS_FORMAT.format(event.timestamp())) + .category(category) + .action(actionFor(event.type(), path)) + .actor(event.principal()) + .target(targetFor(category, path, data)) + .status(statusFor(event.type(), category, data)) + .latencyMs(asLong(data.get("latencyMs"))) + .build(); + } + + private Map parseData(PortalAuditEventRow event) { + if (event.data() == null || event.data().isEmpty()) { + return Map.of(); + } + try { + @SuppressWarnings("unchecked") + Map parsed = objectMapper.readValue(event.data(), Map.class); + // A literal "null" payload parses to null; treat it as empty, not an NPE. + return parsed == null ? Map.of() : parsed; + } catch (JacksonException e) { + log.warn("Failed to parse audit event {} data as JSON", event.id()); + return Map.of(); + } + } + + private static String categoryFor(String type, String path) { + AuditEventType t = AuditEventType.fromString(type); + if (t == null) { + return "processing"; + } + return switch (t) { + case USER_LOGIN, USER_LOGOUT, USER_FAILED_LOGIN -> "auth"; + case SETTINGS_CHANGED, USER_PROFILE_UPDATE -> "config"; + case PDF_PROCESS, FILE_OPERATION -> isSecurityPath(path) ? "security" : "processing"; + // UI_DATA / HTTP_REQUEST are filtered out before mapping. + default -> "processing"; + }; + } + + private static boolean isSecurityPath(String path) { + if (path == null) { + return false; + } + String p = path.toLowerCase(Locale.ROOT); + return p.contains("/security/") + || p.contains("password") + || p.contains("watermark") + || p.contains("sign") + || p.contains("cert") + || p.contains("redact"); + } + + private static String actionFor(String type, String path) { + AuditEventType t = AuditEventType.fromString(type); + if (t == null) { + return prettyTool(path); + } + return switch (t) { + case USER_LOGIN -> "User signed in"; + case USER_LOGOUT -> "User signed out"; + case USER_FAILED_LOGIN -> "Failed sign-in attempt"; + case USER_PROFILE_UPDATE -> "Profile settings updated"; + case SETTINGS_CHANGED -> "Admin settings changed"; + case PDF_PROCESS, FILE_OPERATION -> prettyTool(path); + default -> prettyTool(path); + }; + } + + /** Acronyms/tokens that get special casing when title-casing a tool path. */ + private static final Map WORD_FIXUPS = + Map.of( + "pdf", "PDF", + "pdfs", "PDFs", + "ocr", "OCR", + "img", "Image", + "csv", "CSV", + "html", "HTML", + "url", "URL", + "xml", "XML"); + + /** "/api/v1/misc/compress-pdf" → "Compress PDF"; "merge-pdfs" → "Merge PDFs". */ + private static String prettyTool(String path) { + if (path == null || path.isBlank()) { + return "PDF operation"; + } + String[] parts = path.split("/"); + String last = parts.length > 0 ? parts[parts.length - 1] : path; + StringBuilder sb = new StringBuilder(); + for (String word : last.split("-")) { + if (word.isEmpty()) { + continue; + } + if (sb.length() > 0) { + sb.append(' '); + } + String lower = word.toLowerCase(Locale.ROOT); + sb.append( + WORD_FIXUPS.getOrDefault( + lower, Character.toUpperCase(word.charAt(0)) + word.substring(1))); + } + return sb.isEmpty() ? "PDF operation" : sb.toString(); + } + + private static String targetFor(String category, String path, Map data) { + if ("auth".equals(category)) { + // Auth events don't act on a resource; the session is the closest thing. + return "Web session"; + } + if ("config".equals(category)) { + return path != null && !path.isBlank() ? path : "System settings"; + } + // processing / security: prefer the first affected file name. + String file = firstFileName(data); + if (file != null) { + return file; + } + return path != null && !path.isBlank() ? prettyTool(path) : "Document"; + } + + private static String statusFor(String type, String category, Map data) { + if (AuditEventType.USER_FAILED_LOGIN.name().equals(type)) { + return "danger"; + } + String status = asString(data.get("status")); + Integer code = asInteger(data.get("statusCode")); + if ("failure".equalsIgnoreCase(status) || (code != null && code >= 500)) { + return "danger"; + } + if (code != null && code >= 400) { + return "warning"; + } + if ("config".equals(category)) { + return "info"; + } + return "success"; + } + + @SuppressWarnings("unchecked") + private static String firstFileName(Map data) { + Object files = data.get("files"); + if (files instanceof List list + && !list.isEmpty() + && list.get(0) instanceof Map f) { + Object name = ((Map) f).get("name"); + return name != null ? String.valueOf(name) : null; + } + return null; + } + + private static String asString(Object o) { + return o == null ? null : String.valueOf(o); + } + + private static long asLong(Object o) { + return o instanceof Number n ? n.longValue() : 0L; + } + + private static Integer asInteger(Object o) { + return o instanceof Number n ? n.intValue() : null; + } +} diff --git a/app/proprietary/src/main/resources/templates/AUDIT_USAGE.md b/app/proprietary/src/main/resources/templates/AUDIT_USAGE.md index 57cdce61b5..58702d3524 100644 --- a/app/proprietary/src/main/resources/templates/AUDIT_USAGE.md +++ b/app/proprietary/src/main/resources/templates/AUDIT_USAGE.md @@ -223,7 +223,7 @@ Use consistent event types throughout the application: - `FILE_DOWNLOAD` - When a file is downloaded - `PDF_PROCESS` - When a PDF is processed (split, merged, etc.) - `USER_CREATE` - When a user is created -- `USER_UPDATE` - When a user details are updated +- `USER_UPDATE` - When a user's details are updated - `PASSWORD_CHANGE` - When a password is changed - `PERMISSION_CHANGE` - When permissions are modified - `SETTINGS_CHANGE` - When system settings are changed diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java index 7d954a4398..969111e9f5 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java @@ -12,6 +12,7 @@ import java.net.ConnectException; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.time.LocalDateTime; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -189,4 +190,73 @@ class AccountLinkClientTest { .thenThrow(new ConnectException("refused")); assertEquals(false, client.revokeSelf("dev-1", "sec-1")); } + + @Test + @SuppressWarnings("unchecked") + void reportUsagePostsToSyncWithDeviceHeadersAndParsesFreshEntitlement() throws Exception { + HttpResponse resp = + response( + 200, + "{\"subscribed\":true,\"freeRemainingUnits\":0,\"periodSpendUnits\":42,\"periodCapUnits\":100,\"state\":\"OK\"}"); + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); + when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class))) + .thenReturn(resp); + + InstanceEntitlement e = + client.reportUsage( + "dev-1", "sec-1", 7L, LocalDateTime.of(2026, 6, 1, 0, 0), 12, 4, 8); + + assertNotNull(e); + assertEquals(42, e.periodSpendUnits()); + assertEquals(EntitlementState.OK, e.state()); + + HttpRequest sent = captor.getValue(); + assertEquals("https://saas.example.com/api/v1/instance/sync", sent.uri().toString()); + assertEquals("POST", sent.method()); + assertEquals("dev-1", sent.headers().firstValue("X-Device-Id").orElse(null)); + assertEquals("sec-1", sent.headers().firstValue("X-Device-Secret").orElse(null)); + } + + @Test + @SuppressWarnings("unchecked") + void reportUsageThrowsRevokedOnDeny() throws Exception { + for (int status : new int[] {401, 403}) { + HttpResponse resp = response(status, "{}"); + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp); + AccountLinkClient.RevokedException ex = + assertThrows( + AccountLinkClient.RevokedException.class, + () -> + client.reportUsage( + "dev-1", + "sec-1", + 1L, + LocalDateTime.of(2026, 6, 1, 0, 0), + 1, + 0, + 0)); + assertEquals(status, ex.status()); + } + } + + @Test + @SuppressWarnings("unchecked") + void reportUsageReturnsNullWhenUnreachable() throws Exception { + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))) + .thenThrow(new ConnectException("refused")); + // Null = don't advance synced markers; the usage retries on the next sync. + assertNull( + client.reportUsage( + "dev-1", "sec-1", 1L, LocalDateTime.of(2026, 6, 1, 0, 0), 1, 0, 0)); + } + + @Test + @SuppressWarnings("unchecked") + void reportUsageReturnsNullOnServerError() throws Exception { + HttpResponse resp = response(503, "{}"); + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp); + assertNull( + client.reportUsage( + "dev-1", "sec-1", 1L, LocalDateTime.of(2026, 6, 1, 0, 0), 1, 0, 0)); + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java index 6e4a816bb0..41f544de7e 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java @@ -2,12 +2,15 @@ package stirling.software.proprietary.accountlink; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.IOException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -21,12 +24,18 @@ import stirling.software.proprietary.accountlink.AccountLinkController.LinkReque class AccountLinkControllerTest { private AccountLinkService service; + private UsageSyncService syncService; + private ObjectProvider syncProvider; private AccountLinkController controller; @BeforeEach + @SuppressWarnings("unchecked") void setUp() { service = mock(AccountLinkService.class); - controller = new AccountLinkController(service); + syncService = mock(UsageSyncService.class); + syncProvider = mock(ObjectProvider.class); + controller = + new AccountLinkController(service, mock(LocalUsageService.class), syncProvider); } @Test @@ -65,4 +74,24 @@ class AccountLinkControllerTest { ResponseEntity resp = controller.link(new LinkRequest("jwt", null)); assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY); } + + @Test + void syncNow_triggersSyncWhenMeteringOn() { + when(syncProvider.getIfAvailable()).thenReturn(syncService); + + ResponseEntity resp = controller.syncNow(); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + verify(syncService).syncNow(); + } + + @Test + void syncNow_returns409WhenMeteringOff() { + when(syncProvider.getIfAvailable()).thenReturn(null); // metering disabled → bean absent + + ResponseEntity resp = controller.syncNow(); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + verify(syncService, never()).syncNow(); + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/BillableOperationClassifierTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/BillableOperationClassifierTest.java index 275026794a..0b43069ee9 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/BillableOperationClassifierTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/BillableOperationClassifierTest.java @@ -1,49 +1,78 @@ package stirling.software.proprietary.accountlink; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import stirling.software.common.service.InternalApiClient; +import stirling.software.proprietary.billing.BillingCategory; class BillableOperationClassifierTest { - @Test - void aiPathIsBillable() { - MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/tools/foo"); - assertTrue(BillableOperationClassifier.isBillable(req)); + private static MockHttpServletRequest req(String uri) { + return new MockHttpServletRequest("POST", uri); } @Test - void automationHeaderIsBillable() { - MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge"); + void aiPathIsAi() { + assertEquals( + BillingCategory.AI, + BillableOperationClassifier.categorize(req("/api/v1/ai/tools/foo"), false)); + } + + @Test + void automationHeaderIsAutomation() { + MockHttpServletRequest req = req("/api/v1/general/merge"); req.addHeader(InternalApiClient.AUTOMATION_HEADER, "1"); - assertTrue(BillableOperationClassifier.isBillable(req)); + assertEquals( + BillingCategory.AUTOMATION, BillableOperationClassifier.categorize(req, false)); } @Test - void plainManualToolIsFree() { - MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge"); - assertFalse(BillableOperationClassifier.isBillable(req)); + void apiKeyToolCallIsApi() { + assertEquals( + BillingCategory.API, + BillableOperationClassifier.categorize(req("/api/v1/general/merge"), true)); } @Test - void aiSegmentNotAtPathStartIsFree() { - // Tightened from substring to prefix: the AI segment appearing mid-path (e.g. behind a - // proxy prefix) must NOT classify a manual tool as billable. - MockHttpServletRequest req = - new MockHttpServletRequest("POST", "/proxy/api/v1/ai/tools/foo"); - assertFalse(BillableOperationClassifier.isBillable(req)); + void plainManualToolIsBypassed() { + assertEquals( + BillingCategory.BYPASSED, + BillableOperationClassifier.categorize(req("/api/v1/general/merge"), false)); } @Test - void aiPathUnderContextPathIsBillable() { - // A real context-path deployment still classifies: //api/v1/ai/** is billable. - MockHttpServletRequest req = - new MockHttpServletRequest("POST", "/stirling/api/v1/ai/tools/foo"); + void automationDominatesAiAndApiKey() { + // An AI tool dispatched inside a workflow (automation header) + API-key auth → AUTOMATION. + MockHttpServletRequest req = req("/api/v1/ai/tools/foo"); + req.addHeader(InternalApiClient.AUTOMATION_HEADER, "true"); + assertEquals(BillingCategory.AUTOMATION, BillableOperationClassifier.categorize(req, true)); + } + + @Test + void aiDominatesApiKey() { + // A direct API-key call to an AI tool bills as AI, not API. + assertEquals( + BillingCategory.AI, + BillableOperationClassifier.categorize(req("/api/v1/ai/tools/foo"), true)); + } + + @Test + void aiSegmentNotAtPathStartIsBypassed() { + // Tightened from substring to prefix: the AI segment mid-path (e.g. behind a proxy prefix) + // must NOT classify a manual tool as AI. + assertEquals( + BillingCategory.BYPASSED, + BillableOperationClassifier.categorize(req("/proxy/api/v1/ai/tools/foo"), false)); + } + + @Test + void aiPathUnderContextPathIsAi() { + // A real context-path deployment still classifies: //api/v1/ai/** is AI. + MockHttpServletRequest req = req("/stirling/api/v1/ai/tools/foo"); req.setContextPath("/stirling"); - assertTrue(BillableOperationClassifier.isBillable(req)); + assertEquals(BillingCategory.AI, BillableOperationClassifier.categorize(req, false)); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateTest.java index 0c250fd3e9..1838034a9a 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateTest.java @@ -3,20 +3,32 @@ package stirling.software.proprietary.accountlink; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; +import java.time.LocalDateTime; import java.util.Optional; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import stirling.software.proprietary.accountlink.GateDecision.Reason; /** - * Covers the gate decision matrix: flag-off, manual-free, unlinked, fail-open, linked-free, and - * over-limit. Exercises the pure {@link InstanceEntitlementGate#decide} so no Spring / I/O is - * needed. + * Covers the gate decision matrix: flag-off, manual-free, unlinked, fail-open, grace-expired, + * linked-free, and over-limit. The pure {@link InstanceEntitlementGate#decide} cases need no + * Spring; the grace-window reference computation is exercised through {@link + * InstanceEntitlementGate#evaluate} with mocked collaborators. */ +@ExtendWith(MockitoExtension.class) class InstanceEntitlementGateTest { + @Mock private DeviceCredentialStore credentialStore; + @Mock private EntitlementCache entitlementCache; + @Mock private AccountLinkSyncStateRepository syncStateRepository; + @Mock private LocalUsageService localUsageService; + private static InstanceEntitlement free() { return new InstanceEntitlement(false, 100, 0, null, EntitlementState.OK); } @@ -35,35 +47,67 @@ class InstanceEntitlementGateTest { @Test void flagOff_allowsEverything_evenBillableUnlinked() { - GateDecision d = InstanceEntitlementGate.decide(false, true, false, Optional.empty()); + GateDecision d = + InstanceEntitlementGate.decide(false, true, false, Optional.empty(), false, 0L); assertTrue(d.allowed()); assertEquals(Reason.FLAG_OFF, d.reason()); } @Test void manualTool_alwaysFree_evenUnlinked() { - GateDecision d = InstanceEntitlementGate.decide(true, false, false, Optional.empty()); + GateDecision d = + InstanceEntitlementGate.decide(true, false, false, Optional.empty(), false, 0L); assertTrue(d.allowed()); assertEquals(Reason.MANUAL_FREE, d.reason()); } @Test void billable_notLinked_blocksWithLinkSignal() { - GateDecision d = InstanceEntitlementGate.decide(true, true, false, Optional.empty()); + GateDecision d = + InstanceEntitlementGate.decide(true, true, false, Optional.empty(), false, 0L); assertFalse(d.allowed()); assertEquals(Reason.NOT_LINKED, d.reason()); } @Test - void billable_linked_entitlementUnreachable_failsOpen() { - GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.empty()); + void billable_linked_entitlementUnreachable_withinGrace_failsOpen() { + GateDecision d = + InstanceEntitlementGate.decide(true, true, true, Optional.empty(), false, 0L); assertTrue(d.allowed()); assertEquals(Reason.FAIL_OPEN, d.reason()); } + @Test + void billable_linked_entitlementUnreachable_graceExpired_blocks() { + GateDecision d = + InstanceEntitlementGate.decide(true, true, true, Optional.empty(), true, 0L); + assertFalse(d.allowed()); + assertEquals(Reason.GRACE_EXPIRED, d.reason()); + } + @Test void billable_linked_freePoolAvailable_allows() { - GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(free())); + GateDecision d = + InstanceEntitlementGate.decide(true, true, true, Optional.of(free()), false, 0L); + assertTrue(d.allowed()); + assertEquals(Reason.ENTITLED, d.reason()); + } + + @Test + void billable_linked_unsubscribed_pendingLocalUsageDepletesGrant_blocks() { + // free() has 100 free units left per the last sync; 100 accrued locally since would exhaust + // it once charged, so the gate stops here in real time rather than waiting for the sync. + GateDecision d = + InstanceEntitlementGate.decide(true, true, true, Optional.of(free()), false, 100L); + assertFalse(d.allowed()); + assertEquals(Reason.OVER_LIMIT, d.reason()); + } + + @Test + void billable_linked_unsubscribed_pendingLocalUsageLeavesRoom_allows() { + // 99 pending against 100 remaining → one unit of grant still projected free → allow. + GateDecision d = + InstanceEntitlementGate.decide(true, true, true, Optional.of(free()), false, 99L); assertTrue(d.allowed()); assertEquals(Reason.ENTITLED, d.reason()); } @@ -72,7 +116,7 @@ class InstanceEntitlementGateTest { void billable_linked_unsubscribedAndExhausted_blocksOverLimit() { GateDecision d = InstanceEntitlementGate.decide( - true, true, true, Optional.of(exhaustedUnsubscribed())); + true, true, true, Optional.of(exhaustedUnsubscribed()), false, 0L); assertFalse(d.allowed()); assertEquals(Reason.OVER_LIMIT, d.reason()); } @@ -81,7 +125,7 @@ class InstanceEntitlementGateTest { void billable_linked_subscribedWithinCap_allows() { GateDecision d = InstanceEntitlementGate.decide( - true, true, true, Optional.of(subscribedWithinCap())); + true, true, true, Optional.of(subscribedWithinCap()), false, 0L); assertTrue(d.allowed()); assertEquals(Reason.ENTITLED, d.reason()); } @@ -89,18 +133,67 @@ class InstanceEntitlementGateTest { @Test void billable_linked_subscribedOverCap_blocks() { GateDecision d = - InstanceEntitlementGate.decide(true, true, true, Optional.of(subscribedOverCap())); + InstanceEntitlementGate.decide( + true, true, true, Optional.of(subscribedOverCap()), false, 0L); assertFalse(d.allowed()); assertEquals(Reason.OVER_LIMIT, d.reason()); } + @Test + void billable_linked_subscribedCapped_pendingLocalUsageWouldExceedCap_blocks() { + // Within cap per the last sync (spend 10 / cap 100), but 95 accrued locally since would + // push + // projected spend to 105 → the gate stops now, not after the next sync reconciles. + GateDecision d = + InstanceEntitlementGate.decide( + true, true, true, Optional.of(subscribedWithinCap()), false, 95L); + assertFalse(d.allowed()); + assertEquals(Reason.OVER_LIMIT, d.reason()); + } + + @Test + void billable_linked_subscribedCapped_pendingLeavesCapRoom_allows() { + // 10 synced + 80 pending = 90 < 100 cap → still room. + GateDecision d = + InstanceEntitlementGate.decide( + true, true, true, Optional.of(subscribedWithinCap()), false, 80L); + assertTrue(d.allowed()); + assertEquals(Reason.ENTITLED, d.reason()); + } + + @Test + void billable_linked_subscribedCapped_freeGrantAbsorbsPending_allows() { + // 50 free units remain, so 40 pending is entirely free → 0 projected paid < 100 cap → + // allow. + InstanceEntitlement subscribedWithGrant = + new InstanceEntitlement(true, 50, 0, 100L, EntitlementState.OK); + GateDecision d = + InstanceEntitlementGate.decide( + true, true, true, Optional.of(subscribedWithGrant), false, 40L); + assertTrue(d.allowed()); + assertEquals(Reason.ENTITLED, d.reason()); + } + + @Test + void billable_linked_subscribedUncapped_pendingIgnored_allows() { + // No cap → local pending has no ceiling to hit → always allowed. + InstanceEntitlement uncapped = + new InstanceEntitlement(true, 0, 999, null, EntitlementState.OK); + GateDecision d = + InstanceEntitlementGate.decide( + true, true, true, Optional.of(uncapped), false, 500L); + assertTrue(d.allowed()); + assertEquals(Reason.ENTITLED, d.reason()); + } + @Test void billable_linked_revoked_blocksWithRevokedSignal() { // Authoritative deny (revoked/invalid credential) surfaced by the cache as REVOKED — // blocks distinctly from over-limit, even though the snapshot is "present". InstanceEntitlement revoked = new InstanceEntitlement(false, 0, 0, null, EntitlementState.REVOKED); - GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(revoked)); + GateDecision d = + InstanceEntitlementGate.decide(true, true, true, Optional.of(revoked), false, 0L); assertFalse(d.allowed()); assertEquals(Reason.REVOKED, d.reason()); } @@ -110,7 +203,98 @@ class InstanceEntitlementGateTest { // Defensive: an explicit OVER_LIMIT state blocks even if a stale free count looks positive. InstanceEntitlement conflicting = new InstanceEntitlement(false, 5, 0, null, EntitlementState.OVER_LIMIT); - GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(conflicting)); + GateDecision d = + InstanceEntitlementGate.decide( + true, true, true, Optional.of(conflicting), false, 0L); + assertFalse(d.allowed()); + assertEquals(Reason.OVER_LIMIT, d.reason()); + } + + // --- grace window (evaluate()) --------------------------------------------------------------- + + private InstanceEntitlementGate gate(AccountLinkProperties props) { + return new InstanceEntitlementGate( + props, credentialStore, entitlementCache, syncStateRepository, localUsageService); + } + + private static AccountLinkProperties props(boolean meteringEnabled, int graceDays) { + AccountLinkProperties p = new AccountLinkProperties(); + p.setEnabled(true); + p.getMetering().setEnabled(meteringEnabled); + p.getMetering().setGraceDays(graceDays); + return p; + } + + @Test + void evaluate_meteringOff_unreachable_failsOpen_neverGraceBlocks() { + when(credentialStore.isLinked()).thenReturn(true); + when(entitlementCache.current()).thenReturn(Optional.empty()); + + GateDecision d = gate(props(false, 3)).evaluate(true); + + // Metering off → grace never applies, even if a sync is ancient. + assertTrue(d.allowed()); + assertEquals(Reason.FAIL_OPEN, d.reason()); + } + + @Test + void evaluate_neverSynced_pastGraceSinceLink_blocks() { + when(credentialStore.isLinked()).thenReturn(true); + when(entitlementCache.current()).thenReturn(Optional.empty()); + when(syncStateRepository.findById(AccountLinkSyncState.SINGLETON_ID)) + .thenReturn(Optional.empty()); + DeviceCredential cred = new DeviceCredential(); + cred.setLinkedAt(LocalDateTime.now().minusDays(5)); + when(credentialStore.get()).thenReturn(Optional.of(cred)); + + GateDecision d = gate(props(true, 3)).evaluate(true); + + assertFalse(d.allowed()); + assertEquals(Reason.GRACE_EXPIRED, d.reason()); + } + + @Test + void evaluate_recentSync_withinGrace_failsOpen() { + when(credentialStore.isLinked()).thenReturn(true); + when(entitlementCache.current()).thenReturn(Optional.empty()); + AccountLinkSyncState state = new AccountLinkSyncState(); + state.setLastSuccessAt(LocalDateTime.now().minusDays(1)); + when(syncStateRepository.findById(AccountLinkSyncState.SINGLETON_ID)) + .thenReturn(Optional.of(state)); + + GateDecision d = gate(props(true, 3)).evaluate(true); + + assertTrue(d.allowed()); + assertEquals(Reason.FAIL_OPEN, d.reason()); + } + + @Test + void evaluate_unsubscribed_localUsageWouldExceedGrant_blocksInRealTime() { + // 100 free units remaining per the last sync, but 100 already accrued locally since — the + // gate subtracts the pending delta and blocks now, not after the next sync reconciles. + when(credentialStore.isLinked()).thenReturn(true); + when(entitlementCache.current()).thenReturn(Optional.of(free())); + when(localUsageService.currentPeriodUnsynced()) + .thenReturn(new LocalUsageService.LocalUsage(LocalDateTime.now(), 100, 0, 0, 100)); + + GateDecision d = gate(props(true, 3)).evaluate(true); + + assertFalse(d.allowed()); + assertEquals(Reason.OVER_LIMIT, d.reason()); + } + + @Test + void evaluate_subscribedCapped_localUsageWouldExceedCap_blocksInRealTime() { + // Subscribed within cap per the last sync (spend 10 / cap 100), but 90 accrued locally + // since — evaluate() now depletes the cap by pending usage for capped subscriptions too, so + // the gate stops now instead of overshooting the cap until the next sync. + when(credentialStore.isLinked()).thenReturn(true); + when(entitlementCache.current()).thenReturn(Optional.of(subscribedWithinCap())); + when(localUsageService.currentPeriodUnsynced()) + .thenReturn(new LocalUsageService.LocalUsage(LocalDateTime.now(), 0, 90, 0, 90)); + + GateDecision d = gate(props(true, 3)).evaluate(true); + assertFalse(d.allowed()); assertEquals(Reason.OVER_LIMIT, d.reason()); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateWiringTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateWiringTest.java index f764f5e836..06b99e0eb5 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateWiringTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateWiringTest.java @@ -19,6 +19,7 @@ class InstanceEntitlementGateWiringTest { private AccountLinkProperties properties; private DeviceCredentialStore store; private EntitlementCache cache; + private LocalUsageService localUsage; private InstanceEntitlementGate gate; @BeforeEach @@ -27,7 +28,14 @@ class InstanceEntitlementGateWiringTest { properties.setEnabled(true); store = mock(DeviceCredentialStore.class); cache = mock(EntitlementCache.class); - gate = new InstanceEntitlementGate(properties, store, cache); + localUsage = mock(LocalUsageService.class); + gate = + new InstanceEntitlementGate( + properties, + store, + cache, + mock(AccountLinkSyncStateRepository.class), + localUsage); } @Test @@ -55,6 +63,10 @@ class InstanceEntitlementGateWiringTest { .thenReturn( Optional.of( new InstanceEntitlement(false, 5, 0, null, EntitlementState.OK))); + // Unsubscribed → the gate reads local unsynced usage to deplete the grant in real time; + // nothing pending here, so the 5 free units still allow the request. + when(localUsage.currentPeriodUnsynced()) + .thenReturn(new LocalUsageService.LocalUsage(null, 0, 0, 0, 0)); GateDecision d = gate.evaluate(true); assertTrue(d.allowed()); assertEquals(GateDecision.Reason.ENTITLED, d.reason()); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java index 709e3c0866..13a2606105 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java @@ -3,24 +3,55 @@ package stirling.software.proprietary.accountlink; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.notNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import java.io.ByteArrayOutputStream; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.Optional; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.http.HttpStatus; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.mock.web.MockMultipartHttpServletRequest; + +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; +import stirling.software.proprietary.billing.BillingCategory; +import stirling.software.proprietary.billing.UnitCalcPolicy; @ExtendWith(MockitoExtension.class) class InstanceEntitlementInterceptorTest { @Mock private InstanceEntitlementGate gate; + @Mock private EntitlementCache entitlementCache; + @Mock private ObjectProvider meterProvider; + @Mock private TempFileManager tempFileManager; + + private InstanceEntitlementInterceptor interceptor() { + return new InstanceEntitlementInterceptor( + gate, entitlementCache, meterProvider, tempFileManager); + } private boolean preHandle(MockHttpServletResponse response) throws Exception { - return new InstanceEntitlementInterceptor(gate) + return interceptor() .preHandle( new MockHttpServletRequest("GET", "/api/v1/ai/x"), response, new Object()); } @@ -58,4 +89,85 @@ class InstanceEntitlementInterceptorTest { assertTrue(preHandle(response)); assertEquals(200, response.getStatus()); } + + @Test + void metersSuccessfulBillableOp() throws Exception { + when(gate.evaluate(anyBoolean())) + .thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED)); + UsageMeterService meter = mock(UsageMeterService.class); + when(meterProvider.getIfAvailable()).thenReturn(meter); + UnitCalcPolicy policy = new UnitCalcPolicy(1, 1_048_576L, 1, 1000); + LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); + when(entitlementCache.current()).thenReturn(Optional.of(entitled(policy, period))); + + InstanceEntitlementInterceptor interceptor = interceptor(); + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/x"); + MockHttpServletResponse resp = new MockHttpServletResponse(); + interceptor.preHandle(req, resp, new Object()); // stashes AI category + interceptor.afterCompletion(req, resp, new Object(), null); + + // No uploaded files → bytes axis → the 1-unit floor; no input identity → null signature. + verify(meter).accrue(eq(period), eq(BillingCategory.AI), eq(1L), isNull()); + } + + @Test + void metersPdfByPageCountNotJustBytes(@TempDir Path tmp) throws Exception { + when(gate.evaluate(anyBoolean())) + .thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED)); + UsageMeterService meter = mock(UsageMeterService.class); + when(meterProvider.getIfAvailable()).thenReturn(meter); + // docPagesPerUnit=1, docBytesPerUnit=1MB → a tiny 5-page PDF costs 5 on the page axis but + // only 1 on the byte axis: page-counting (via jpdfium) is what makes this bill correctly. + UnitCalcPolicy policy = new UnitCalcPolicy(1, 1_048_576L, 1, 1000); + LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); + when(entitlementCache.current()).thenReturn(Optional.of(entitled(policy, period))); + // Materialise to a real path under @TempDir; the interceptor writes the upload there and + // jpdfium + the hasher read it back. + TempFile temp = mock(TempFile.class); + when(temp.getPath()).thenReturn(tmp.resolve("input.bin")); + when(tempFileManager.createManagedTempFile(any())).thenReturn(temp); + + InstanceEntitlementInterceptor interceptor = interceptor(); + MockMultipartHttpServletRequest req = new MockMultipartHttpServletRequest(); + req.setRequestURI("/api/v1/ai/x"); + req.addFile(new MockMultipartFile("file", "doc.pdf", "application/pdf", fivePagePdf())); + MockHttpServletResponse resp = new MockHttpServletResponse(); + interceptor.preHandle(req, resp, new Object()); + interceptor.afterCompletion(req, resp, new Object(), null); + + // 5 pages + a non-null input-set signature (file ops carry a dedup key). + verify(meter).accrue(eq(period), eq(BillingCategory.AI), eq(5L), notNull()); + } + + @Test + void doesNotMeterWhenMeteringSwitchOff() throws Exception { + when(gate.evaluate(anyBoolean())) + .thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED)); + when(meterProvider.getIfAvailable()).thenReturn(null); // metering.enabled = false + + InstanceEntitlementInterceptor interceptor = interceptor(); + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/x"); + MockHttpServletResponse resp = new MockHttpServletResponse(); + interceptor.preHandle(req, resp, new Object()); + interceptor.afterCompletion(req, resp, new Object(), null); + + // Meter absent → no entitlement lookup, no accrual. + verifyNoInteractions(entitlementCache); + } + + private static InstanceEntitlement entitled(UnitCalcPolicy policy, LocalDateTime period) { + return new InstanceEntitlement( + true, 0, 0, 100L, EntitlementState.OK, policy, period, period.plusMonths(1)); + } + + private static byte[] fivePagePdf() throws Exception { + try (PDDocument doc = new PDDocument(); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + for (int i = 0; i < 5; i++) { + doc.addPage(new PDPage()); + } + doc.save(out); + return out.toByteArray(); + } + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/LocalUsageServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/LocalUsageServiceTest.java new file mode 100644 index 0000000000..4605348666 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/LocalUsageServiceTest.java @@ -0,0 +1,75 @@ +package stirling.software.proprietary.accountlink; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class LocalUsageServiceTest { + + @Mock private UsageCounterRepository counters; + @Mock private EntitlementCache entitlementCache; + + private LocalUsageService service; + private final LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); + + @BeforeEach + void setUp() { + service = new LocalUsageService(counters, entitlementCache); + } + + private static UsageCounter counter( + LocalDateTime period, String category, long cumulative, long synced) { + return new UsageCounter(period, category, cumulative, synced, LocalDateTime.now()); + } + + private static InstanceEntitlement entitledFor(LocalDateTime periodStart) { + return new InstanceEntitlement( + true, + 0, + 0, + null, + EntitlementState.OK, + null, + periodStart, + periodStart.plusMonths(1)); + } + + @Test + void unknownPeriodReturnsZeros() { + when(entitlementCache.current()).thenReturn(Optional.empty()); + + LocalUsageService.LocalUsage usage = service.currentPeriodUnsynced(); + + assertThat(usage.periodStart()).isNull(); + assertThat(usage.totalUnsyncedUnits()).isZero(); + } + + @Test + void sumsPerCategoryUnsyncedDeltaForCurrentPeriod() { + when(entitlementCache.current()).thenReturn(Optional.of(entitledFor(period))); + when(counters.findByPeriodStart(period)) + .thenReturn( + List.of( + counter(period, "API", 30L, 10L), // 20 unsynced + counter(period, "AI", 4L, 4L), // 0 unsynced (all reported) + counter(period, "AUTOMATION", 7L, 2L))); // 5 unsynced + + LocalUsageService.LocalUsage usage = service.currentPeriodUnsynced(); + + assertThat(usage.periodStart()).isEqualTo(period); + assertThat(usage.apiUnsyncedUnits()).isEqualTo(20L); + assertThat(usage.aiUnsyncedUnits()).isEqualTo(0L); + assertThat(usage.automationUnsyncedUnits()).isEqualTo(5L); + assertThat(usage.totalUnsyncedUnits()).isEqualTo(25L); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageMeterServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageMeterServiceTest.java new file mode 100644 index 0000000000..0c9ad2c62e --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageMeterServiceTest.java @@ -0,0 +1,133 @@ +package stirling.software.proprietary.accountlink; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +import stirling.software.proprietary.billing.BillingCategory; + +@ExtendWith(MockitoExtension.class) +class UsageMeterServiceTest { + + @Mock private UsageCounterRepository repo; + @Mock private MeteredInputSignatureRepository signatureRepo; + + private UsageMeterService service; + private final LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); + + @BeforeEach + void setUp() { + service = new UsageMeterService(repo, signatureRepo, new AccountLinkProperties()); + } + + @Test + void incrementsExistingCounter() { + when(repo.increment(eq(period), eq("AI"), eq(5L), any())).thenReturn(1); + + service.accrue(period, BillingCategory.AI, 5, null); + + verify(repo).increment(eq(period), eq("AI"), eq(5L), any()); + verify(repo, never()).saveAndFlush(any()); + } + + @Test + void insertsWhenNoRowExists() { + when(repo.increment(eq(period), eq("API"), eq(3L), any())).thenReturn(0); + + service.accrue(period, BillingCategory.API, 3, null); + + verify(repo).saveAndFlush(any(UsageCounter.class)); + } + + @Test + void retriesIncrementWhenInsertLosesRace() { + // First increment misses (no row); insert loses the race to a concurrent thread; the + // second increment then succeeds against the row that thread created. + when(repo.increment(eq(period), eq("AUTOMATION"), eq(2L), any())).thenReturn(0, 1); + when(repo.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("dup")); + + service.accrue(period, BillingCategory.AUTOMATION, 2, null); + + verify(repo, times(2)).increment(eq(period), eq("AUTOMATION"), eq(2L), any()); + } + + @Test + void skipsBypassedNonPositiveAndNullPeriod() { + service.accrue(period, BillingCategory.BYPASSED, 5, null); + service.accrue(period, BillingCategory.AI, 0, null); + service.accrue(null, BillingCategory.AI, 5, null); + + verifyNoInteractions(repo, signatureRepo); + } + + @Test + void chargesNewSignatureThenAccrues() { + when(signatureRepo.findByPeriodStartAndSignature(period, "op-sig-new")) + .thenReturn(Optional.empty()); + when(repo.increment(eq(period), eq("AI"), eq(5L), any())).thenReturn(1); + + service.accrue(period, BillingCategory.AI, 5, "op-sig-new"); + + verify(signatureRepo).saveAndFlush(any(MeteredInputSignature.class)); + verify(repo).increment(eq(period), eq("AI"), eq(5L), any()); + } + + @Test + void skipsConcurrentDuplicateClaim() { + // Unseen this period, but a concurrent op wins the insert first → treated as within-window + // chaining, not re-charged. + when(signatureRepo.findByPeriodStartAndSignature(period, "op-sig-race")) + .thenReturn(Optional.empty()); + when(signatureRepo.saveAndFlush(any())) + .thenThrow(new DataIntegrityViolationException("dup")); + + service.accrue(period, BillingCategory.AI, 5, "op-sig-race"); + + verify(repo, never()).increment(any(), any(), anyLong(), any()); + verify(repo, never()).saveAndFlush(any()); + } + + @Test + void skipsRepeatWithinWorkflowWindow() { + // Same input set seen moments ago → chaining → not re-charged; the window slides. + MeteredInputSignature recent = + new MeteredInputSignature(period, "op-sig", LocalDateTime.now()); + when(signatureRepo.findByPeriodStartAndSignature(period, "op-sig")) + .thenReturn(Optional.of(recent)); + + service.accrue(period, BillingCategory.AI, 5, "op-sig"); + + verify(repo, never()).increment(any(), any(), anyLong(), any()); + verify(signatureRepo).save(recent); // window touched + } + + @Test + void chargesRepeatOutsideWorkflowWindow() { + // Same input set last seen well past the 5-minute window → an independent re-run → charged. + MeteredInputSignature stale = + new MeteredInputSignature(period, "op-sig", LocalDateTime.now().minusMinutes(10)); + when(signatureRepo.findByPeriodStartAndSignature(period, "op-sig")) + .thenReturn(Optional.of(stale)); + when(repo.increment(eq(period), eq("AI"), eq(5L), any())).thenReturn(1); + + service.accrue(period, BillingCategory.AI, 5, "op-sig"); + + verify(repo).increment(eq(period), eq("AI"), eq(5L), any()); + verify(signatureRepo).save(stale); // window touched + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageSyncServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageSyncServiceTest.java new file mode 100644 index 0000000000..1b1ade7e80 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageSyncServiceTest.java @@ -0,0 +1,171 @@ +package stirling.software.proprietary.accountlink; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.scheduling.config.ScheduledTaskRegistrar; + +@ExtendWith(MockitoExtension.class) +class UsageSyncServiceTest { + + @Mock private UsageCounterRepository counters; + @Mock private AccountLinkSyncStateRepository syncState; + @Mock private DeviceCredentialStore credentialStore; + @Mock private AccountLinkClient client; + @Mock private EntitlementCache entitlementCache; + + private UsageSyncService service; + private final LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); + + @BeforeEach + void setUp() { + service = + new UsageSyncService( + counters, + syncState, + credentialStore, + client, + entitlementCache, + new AccountLinkProperties()); + } + + @Test + void registersFixedDelayTaskWithConfiguredInterval() { + AccountLinkProperties props = new AccountLinkProperties(); + props.getMetering().setSyncIntervalHours(6); + UsageSyncService svc = + new UsageSyncService( + counters, syncState, credentialStore, client, entitlementCache, props); + + ScheduledTaskRegistrar registrar = new ScheduledTaskRegistrar(); + svc.configureTasks(registrar); + + // Pins the interval binding in CI — the old @Scheduled SpEL only resolved at flags-on boot. + assertThat(registrar.getFixedDelayTaskList()).hasSize(1); + assertThat(registrar.getFixedDelayTaskList().get(0).getIntervalDuration()) + .isEqualTo(Duration.ofHours(6)); + } + + private static DeviceCredential credential() { + DeviceCredential c = new DeviceCredential(); + c.setDeviceId("dev-1"); + c.setDeviceSecret("sec-1"); + return c; + } + + private static UsageCounter counter(LocalDateTime period, String category, long cumulative) { + return new UsageCounter(period, category, cumulative, LocalDateTime.now()); + } + + private static InstanceEntitlement entitled() { + return new InstanceEntitlement(true, 0, 0, null, EntitlementState.OK); + } + + @Test + void notLinkedSkipsEntirely() { + when(credentialStore.get()).thenReturn(Optional.empty()); + + service.syncNow(); + + verifyNoInteractions(client, entitlementCache); + verify(counters, never()).findPeriodsWithUnsyncedUsage(); + } + + @Test + void nothingPendingStillForcesEntitlementRefresh() { + when(credentialStore.get()).thenReturn(Optional.of(credential())); + when(counters.findPeriodsWithUnsyncedUsage()).thenReturn(List.of()); + + service.syncNow(); + + // No usage to report, so nothing is sent and no markers advance — but the sync still forces + // an entitlement refresh so an out-of-band plan change (e.g. a just-completed subscription) + // surfaces on the gate immediately instead of waiting out the entitlement-cache TTL. + verifyNoInteractions(client); + verify(syncState, never()).save(any()); + verify(entitlementCache, never()).accept(any()); + verify(entitlementCache).invalidate(); + verify(entitlementCache).current(); + } + + @Test + void reportsCumulativePerCategoryAndAdvancesSyncedMarkers() { + AccountLinkSyncState state = new AccountLinkSyncState(); + state.setId(AccountLinkSyncState.SINGLETON_ID); + state.setLastSyncSeq(5L); + when(credentialStore.get()).thenReturn(Optional.of(credential())); + when(counters.findPeriodsWithUnsyncedUsage()).thenReturn(List.of(period)); + when(counters.findByPeriodStart(period)) + .thenReturn(List.of(counter(period, "API", 12L), counter(period, "AI", 4L))); + when(syncState.findById(AccountLinkSyncState.SINGLETON_ID)).thenReturn(Optional.of(state)); + InstanceEntitlement fresh = entitled(); + when(client.reportUsage( + eq("dev-1"), eq("sec-1"), eq(6L), eq(period), eq(12L), eq(4L), eq(0L))) + .thenReturn(fresh); + + service.syncNow(); + + // Seq advanced from 5 → 6 and the report carried the per-category cumulative. + verify(client) + .reportUsage(eq("dev-1"), eq("sec-1"), eq(6L), eq(period), eq(12L), eq(4L), eq(0L)); + // Only categories with usage are marked; AUTOMATION (0) is skipped. + verify(counters).markSynced(period, "API", 12L); + verify(counters).markSynced(period, "AI", 4L); + verify(counters, never()).markSynced(eq(period), eq("AUTOMATION"), anyLong()); + // Two saves: the pre-report seq reservation + the post-success timestamp. + verify(syncState, times(2)).save(state); + verify(entitlementCache).accept(fresh); + } + + @Test + void transportFailureReservesSeqButLeavesMarkersUntouched() { + AccountLinkSyncState state = new AccountLinkSyncState(); + state.setId(AccountLinkSyncState.SINGLETON_ID); + when(credentialStore.get()).thenReturn(Optional.of(credential())); + when(counters.findPeriodsWithUnsyncedUsage()).thenReturn(List.of(period)); + when(counters.findByPeriodStart(period)).thenReturn(List.of(counter(period, "API", 12L))); + when(syncState.findById(AccountLinkSyncState.SINGLETON_ID)).thenReturn(Optional.of(state)); + when(client.reportUsage(any(), any(), anyLong(), any(), anyLong(), anyLong(), anyLong())) + .thenReturn(null); + + service.syncNow(); + + verify(counters, never()).markSynced(any(), any(), anyLong()); + verify(syncState, times(1)).save(state); // seq reserved, success not recorded + verify(entitlementCache).accept(null); // nothing fresh adopted + } + + @Test + void revokedAbortsWithoutMarkingOrAdoptingEntitlement() { + AccountLinkSyncState state = new AccountLinkSyncState(); + state.setId(AccountLinkSyncState.SINGLETON_ID); + when(credentialStore.get()).thenReturn(Optional.of(credential())); + when(counters.findPeriodsWithUnsyncedUsage()).thenReturn(List.of(period)); + when(counters.findByPeriodStart(period)).thenReturn(List.of(counter(period, "API", 12L))); + when(syncState.findById(AccountLinkSyncState.SINGLETON_ID)).thenReturn(Optional.of(state)); + when(client.reportUsage(any(), any(), anyLong(), any(), anyLong(), anyLong(), anyLong())) + .thenThrow(new AccountLinkClient.RevokedException(403)); + + service.syncNow(); + + verify(counters, never()).markSynced(any(), any(), anyLong()); + verify(entitlementCache, never()).accept(any()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/billing/BillingCategoryClassifierTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/billing/BillingCategoryClassifierTest.java new file mode 100644 index 0000000000..1eb6c3360a --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/billing/BillingCategoryClassifierTest.java @@ -0,0 +1,30 @@ +package stirling.software.proprietary.billing; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class BillingCategoryClassifierTest { + + @Test + void automationWinsOverEverything() { + assertEquals( + BillingCategory.AUTOMATION, BillingCategoryClassifier.classify(true, true, true)); + } + + @Test + void aiWinsOverApiKey() { + assertEquals(BillingCategory.AI, BillingCategoryClassifier.classify(false, true, true)); + } + + @Test + void apiKeyWhenNotAutomationOrAi() { + assertEquals(BillingCategory.API, BillingCategoryClassifier.classify(false, false, true)); + } + + @Test + void bypassedWhenNoSignal() { + assertEquals( + BillingCategory.BYPASSED, BillingCategoryClassifier.classify(false, false, false)); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/config/CustomAuditEventRepositoryTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/config/CustomAuditEventRepositoryTest.java index 3bdde8ecab..6ba15b24aa 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/config/CustomAuditEventRepositoryTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/config/CustomAuditEventRepositoryTest.java @@ -3,12 +3,61 @@ package stirling.software.proprietary.config; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import java.time.Instant; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.slf4j.MDC; +import org.springframework.boot.actuate.audit.AuditEvent; + +import stirling.software.proprietary.model.security.PersistentAuditEvent; +import stirling.software.proprietary.repository.PersistentAuditEventRepository; + +import tools.jackson.databind.json.JsonMapper; class CustomAuditEventRepositoryTest { + @AfterEach + void clearMdc() { + MDC.clear(); + } + + @Test + void sourceIsPopulatedFromMdcAuditSource() { + PersistentAuditEventRepository repo = mock(PersistentAuditEventRepository.class); + CustomAuditEventRepository writer = + new CustomAuditEventRepository(repo, JsonMapper.builder().build()); + + MDC.put("auditSource", "WEB"); + writer.add(new AuditEvent(Instant.now(), "admin", "PDF_PROCESS", Map.of("k", "v"))); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(PersistentAuditEvent.class); + verify(repo).save(captor.capture()); + assertEquals("WEB", captor.getValue().getSource()); + } + + @Test + void sourceIsNullWhenMdcAbsent() { + PersistentAuditEventRepository repo = mock(PersistentAuditEventRepository.class); + CustomAuditEventRepository writer = + new CustomAuditEventRepository(repo, JsonMapper.builder().build()); + + writer.add(new AuditEvent(Instant.now(), "admin", "PDF_PROCESS", Map.of("k", "v"))); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(PersistentAuditEvent.class); + verify(repo).save(captor.capture()); + assertNull(captor.getValue().getSource()); + } + @Test void shortPrincipalPassesThroughUnchanged() { assertEquals( diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/FleetUsageControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/FleetUsageControllerTest.java new file mode 100644 index 0000000000..11e168b8d1 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/FleetUsageControllerTest.java @@ -0,0 +1,108 @@ +package stirling.software.proprietary.controller.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.audit.AuditLevel; +import stirling.software.proprietary.config.AuditConfigurationProperties; +import stirling.software.proprietary.model.api.usage.FleetUsageStats; +import stirling.software.proprietary.repository.PersistentAuditEventRepository; +import stirling.software.proprietary.security.database.repository.UserRepository; + +@ExtendWith(MockitoExtension.class) +class FleetUsageControllerTest { + + @Mock private PersistentAuditEventRepository auditRepository; + @Mock private UserRepository userRepository; + @Mock private AuditConfigurationProperties auditConfig; + + private FleetUsageController controller; + + @BeforeEach + void setUp() { + controller = new FleetUsageController(auditRepository, userRepository, auditConfig); + } + + @Test + @DisplayName("deployed reflects the user count, excluding the internal API user") + void deployedFromUserCount() { + when(userRepository.countByUsernameNot(anyString())).thenReturn(7L); + when(auditConfig.isLevelEnabled(AuditLevel.STANDARD)).thenReturn(false); + + FleetUsageStats stats = controller.fleetStats(); + + assertThat(stats.editorsDeployed()).isEqualTo(7L); + verify(userRepository).countByUsernameNot(Role.INTERNAL_API_USER.getRoleId()); + } + + @Test + @DisplayName("audit-derived figures are null when auditing is below STANDARD") + void auditOffYieldsNulls() { + when(userRepository.countByUsernameNot(anyString())).thenReturn(3L); + // Covers both disabled and the enabled-but-level=OFF/BASIC misconfig: isLevelEnabled + // is false, so no events can exist and we must report N/A, not a 0 from an empty table. + when(auditConfig.isLevelEnabled(AuditLevel.STANDARD)).thenReturn(false); + + FleetUsageStats stats = controller.fleetStats(); + + assertThat(stats.activeThisMonth()).isNull(); + assertThat(stats.pdfsProcessed()).isNull(); + verify(auditRepository, never()) + .countDistinctPrincipalsBySourceExcludingTypeAfter( + any(), any(), any(Instant.class)); + verify(auditRepository, never()) + .countByTypeInAndSourceAndTimestampAfter(anyList(), any(), any(Instant.class)); + } + + @Test + @DisplayName("audit-derived figures come from the repository when auditing is enabled") + void auditOnReadsRepository() { + when(userRepository.countByUsernameNot(anyString())).thenReturn(10L); + when(auditConfig.isLevelEnabled(AuditLevel.STANDARD)).thenReturn(true); + when(auditRepository.countDistinctPrincipalsBySourceExcludingTypeAfter( + eq("WEB"), eq("UI_DATA"), any(Instant.class))) + .thenReturn(4L); + when(auditRepository.countByTypeInAndSourceAndTimestampAfter( + anyList(), eq("WEB"), any(Instant.class))) + .thenReturn(1234L); + + FleetUsageStats stats = controller.fleetStats(); + + assertThat(stats.editorsDeployed()).isEqualTo(10L); + assertThat(stats.activeThisMonth()).isEqualTo(4L); + assertThat(stats.pdfsProcessed()).isEqualTo(1234L); + } + + @Test + @DisplayName("active editors are clamped to deployed (active is a subset)") + void activeClampedToDeployed() { + when(userRepository.countByUsernameNot(anyString())).thenReturn(2L); + when(auditConfig.isLevelEnabled(AuditLevel.STANDARD)).thenReturn(true); + when(auditRepository.countDistinctPrincipalsBySourceExcludingTypeAfter( + eq("WEB"), eq("UI_DATA"), any(Instant.class))) + .thenReturn(9L); + when(auditRepository.countByTypeInAndSourceAndTimestampAfter( + anyList(), eq("WEB"), any(Instant.class))) + .thenReturn(50L); + + FleetUsageStats stats = controller.fleetStats(); + + assertThat(stats.activeThisMonth()).isEqualTo(2L); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceMoreTest.java index cc8388b8c2..7c44a40a5d 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceMoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceMoreTest.java @@ -221,34 +221,6 @@ class AiWorkflowServiceMoreTest { } } - @Nested - @DisplayName("convert_markdown guards") - class ConvertMarkdownGuards { - - @Test - @DisplayName("no files listed yields CANNOT_CONTINUE") - void noFiles() throws IOException { - stubOrchestrator("{\"outcome\":\"convert_markdown\",\"filesToIngest\":[]}"); - AiWorkflowResponse result = service.orchestrate(requestFor(pdf("a.pdf", "x"), "to md")); - assertThat(result.getOutcome()).isEqualTo(AiWorkflowOutcome.CANNOT_CONTINUE); - } - - @Test - @DisplayName("unknown file id yields CANNOT_CONTINUE") - void unknownFile() throws IOException { - when(fileIdStrategy.idFor(any())).thenReturn("real-id"); - stubOrchestrator( - """ - {"outcome":"convert_markdown", - "filesToIngest":[{"id":"other-id","name":"other.pdf"}]} - """); - AiWorkflowResponse result = - service.orchestrate(requestFor(pdf("real.pdf", "x"), "to md")); - assertThat(result.getOutcome()).isEqualTo(AiWorkflowOutcome.CANNOT_CONTINUE); - assertThat(result.getReason()).contains("other.pdf"); - } - } - @Nested @DisplayName("plan guards and errors") class PlanGuardsAndErrors { diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceTest.java index 9e611cf32e..7dc174c041 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceTest.java @@ -78,6 +78,7 @@ class AiWorkflowServiceTest { private static final String SPLIT_ENDPOINT = "/api/v1/general/split-pages"; private static final String MERGE_ENDPOINT = "/api/v1/general/merge-pdfs"; private static final String COMPRESS_ENDPOINT = "/api/v1/misc/compress-pdf"; + private static final String MARKDOWN_ENDPOINT = "/api/v1/convert/pdf/markdown"; @Mock private CustomPDFDocumentFactory pdfDocumentFactory; @Mock private AiEngineClient aiEngineClient; @@ -440,23 +441,23 @@ class AiWorkflowServiceTest { } @Test - void convertMarkdownRunsDeterministicConversionAndReturnsMdFile() throws IOException { + void planWithMarkdownStepReturnsMdFile() throws IOException { + // PDF→Markdown is a normal tool the edit agent emits as a plan step (no bespoke + // outcome); the plan executor runs the converter and returns the .md file. MockMultipartFile input = pdf("multi-column-test_lorem.pdf", "pdf-bytes"); - when(fileIdStrategy.idFor(any())).thenReturn("doc-1"); stubOrchestrator( """ { - "outcome":"convert_markdown", - "reason":"PDF to Markdown requested.", - "filesToIngest":[{"id":"doc-1","name":"multi-column-test_lorem.pdf"}] + "outcome":"plan", + "summary":"Convert to Markdown", + "steps":[{"tool":"%s","parameters":{}}] } - """); - when(toolMetadataService.shouldUnpackZipResponse("/api/v1/convert/pdf/markdown")) - .thenReturn(false); - stubEndpoint( - "/api/v1/convert/pdf/markdown", - pdfResource("# Title", "multi-column-test_lorem.md")); - AtomicInteger ids = stubFileStorage(); + """ + .formatted(MARKDOWN_ENDPOINT)); + when(toolMetadataService.isMultiInput(anyString())).thenReturn(false); + when(toolMetadataService.shouldUnpackZipResponse(anyString())).thenReturn(false); + stubEndpoint(MARKDOWN_ENDPOINT, pdfResource("# Title", "multi-column-test_lorem.md")); + stubFileStorage(); AiWorkflowResponse result = service.orchestrate(requestFor(input, "convert to markdown")); @@ -464,8 +465,7 @@ class AiWorkflowServiceTest { assertEquals(1, result.getResultFiles().size()); // Extension changes (pdf -> md), so the converter's response filename wins. assertEquals("multi-column-test_lorem.md", result.getResultFiles().get(0).getFileName()); - assertEquals(1, ids.get()); - verify(internalApiClient, times(1)).post(eq("/api/v1/convert/pdf/markdown"), any()); + verify(internalApiClient, times(1)).post(eq(MARKDOWN_ENDPOINT), any()); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditServiceTest.java index 321d0975d4..a90fde6b85 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditServiceTest.java @@ -35,6 +35,7 @@ import jakarta.servlet.http.HttpServletResponse; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.InternalApiClient; import stirling.software.proprietary.audit.AuditEventType; import stirling.software.proprietary.audit.AuditLevel; import stirling.software.proprietary.audit.Audited; @@ -83,6 +84,61 @@ class AuditServiceTest { RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); } + @Nested + @DisplayName("captureCurrentSource()") + class CaptureSource { + + @Test + @DisplayName("a plain authenticated web tool call is WEB") + void plainWebIsWeb() { + authenticateAs("alice"); + bindRequest(new MockHttpServletRequest("POST", "/api/v1/general/merge-pdfs")); + + assertThat(service.captureCurrentSource()).isEqualTo("WEB"); + } + + @Test + @DisplayName("the automation marker header demotes WEB to AUTOMATION") + void automationHeaderIsAutomation() { + authenticateAs("alice"); + MockHttpServletRequest req = + new MockHttpServletRequest("POST", "/api/v1/general/merge"); + req.addHeader(InternalApiClient.AUTOMATION_HEADER, "true"); + bindRequest(req); + + assertThat(service.captureCurrentSource()).isEqualTo("AUTOMATION"); + } + + @Test + @DisplayName("the AI surface demotes WEB to AI") + void aiSurfaceIsAi() { + authenticateAs("alice"); + bindRequest(new MockHttpServletRequest("POST", "/api/v1/ai/tools/ask")); + + assertThat(service.captureCurrentSource()).isEqualTo("AI"); + } + + @Test + @DisplayName("the AI surface is matched after stripping the deployment context path") + void aiSurfaceWithContextPathIsAi() { + authenticateAs("alice"); + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/tools/ask"); + req.setContextPath("/stirling"); + req.setRequestURI("/stirling/api/v1/ai/tools/ask"); + bindRequest(req); + + assertThat(service.captureCurrentSource()).isEqualTo("AI"); + } + + @Test + @DisplayName("an unauthenticated request is SYSTEM, not WEB") + void anonymousIsSystem() { + bindRequest(new MockHttpServletRequest("POST", "/api/v1/general/merge-pdfs")); + + assertThat(service.captureCurrentSource()).isEqualTo("SYSTEM"); + } + } + @Nested @DisplayName("audit() gating") class AuditGating { diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java b/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java index 7392eb928a..7c98d5e59b 100644 --- a/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java @@ -1,5 +1,8 @@ package stirling.software.saas.accountlink; +import java.time.LocalDateTime; +import java.util.Map; + import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; @@ -9,6 +12,7 @@ import org.springframework.security.core.Authentication; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -16,11 +20,16 @@ import io.swagger.v3.oas.annotations.Hidden; import lombok.extern.slf4j.Slf4j; +import stirling.software.proprietary.billing.UnitCalcPolicy; import stirling.software.saas.payg.billing.TeamBillingContext; import stirling.software.saas.payg.billing.TeamBillingService; import stirling.software.saas.payg.entitlement.EntitlementService; import stirling.software.saas.payg.entitlement.EntitlementSnapshot; +import stirling.software.saas.payg.instance.InstanceUsageIngestService; +import stirling.software.saas.payg.model.BillingCategory; import stirling.software.saas.payg.model.EntitlementState; +import stirling.software.saas.payg.policy.PricingPolicy; +import stirling.software.saas.payg.policy.PricingPolicyService; /** * Instance-facing surface (combined-billing "Mode A"), authenticated by the device @@ -46,14 +55,23 @@ public class InstanceController { private final EntitlementService entitlementService; private final TeamBillingService billingService; private final AccountLinkService accountLinkService; + private final PricingPolicyService pricingPolicyService; + private final InstanceUsageIngestService usageIngestService; + private final LinkedInstanceRepository linkedInstanceRepository; public InstanceController( EntitlementService entitlementService, TeamBillingService billingService, - AccountLinkService accountLinkService) { + AccountLinkService accountLinkService, + PricingPolicyService pricingPolicyService, + InstanceUsageIngestService usageIngestService, + LinkedInstanceRepository linkedInstanceRepository) { this.entitlementService = entitlementService; this.billingService = billingService; this.accountLinkService = accountLinkService; + this.pricingPolicyService = pricingPolicyService; + this.usageIngestService = usageIngestService; + this.linkedInstanceRepository = linkedInstanceRepository; } public record WhoAmIResponse(Long instanceId, Long teamId) {} @@ -68,7 +86,13 @@ public class InstanceController { long freeRemainingUnits, long periodSpendUnits, Long periodCapUnits, - String state) {} + String state, + // Metering inputs the instance needs to cost + bucket its own usage (Phase 2). The + // instance computes units locally with this policy and resets its per-period cumulative + // counters on the [periodStart, periodEnd) boundary. + UnitCalcPolicy unitCalcPolicy, + LocalDateTime periodStart, + LocalDateTime periodEnd) {} @GetMapping("/whoami") @PreAuthorize("hasRole('LINKED_INSTANCE')") @@ -103,20 +127,96 @@ public class InstanceController { if (!(auth instanceof LinkedInstanceAuthenticationToken token)) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } - Long teamId = token.getTeamId(); + // Drop the cached snapshot first: this low-frequency read gates real-time billable work, so + // it must reflect a just-changed subscription/cap at once (the flip is a DB-function write + // with no Java event to invalidate on). + entitlementService.invalidate(token.getTeamId()); + return ResponseEntity.ok(buildEntitlement(token.getTeamId())); + } + /** Body for {@code POST /sync}: the instance's cumulative units per category this period. */ + public record UsageSyncRequest( + long syncSeq, LocalDateTime periodStart, CategoryUnits cumulativeUnits) { + public record CategoryUnits(long api, long ai, long automation) {} + } + + /** + * Daily usage sync: the instance reports its cumulative per-category unit totals for the + * period; SaaS bills the delta since the last sync (reusing the standard charge path) and + * returns the fresh entitlement — so one round-trip both reports usage and refreshes the gate + * state. + */ + @PostMapping("/sync") + @PreAuthorize("hasRole('LINKED_INSTANCE')") + @Transactional + public ResponseEntity sync( + Authentication auth, @RequestBody UsageSyncRequest req) { + if (!(auth instanceof LinkedInstanceAuthenticationToken token)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + if (req == null || req.periodStart() == null || req.cumulativeUnits() == null) { + return ResponseEntity.badRequest().build(); + } + Long teamId = token.getTeamId(); + // periodStart is the dedup/regression partition key, so bound a fabricated value to the + // snapshot window (current or immediately-prior period, never future). + EntitlementSnapshot snap = entitlementService.getSnapshot(teamId); + LocalDateTime reported = req.periodStart(); + if (!reported.isBefore(snap.periodEnd()) + || reported.isBefore(snap.periodStart().minusMonths(1))) { + log.warn( + "Instance sync for team {} reported implausible periodStart {} (authoritative" + + " {}..{}); rejecting.", + teamId, + reported, + snap.periodStart(), + snap.periodEnd()); + return ResponseEntity.badRequest().build(); + } + // Attribute the charge to the admin who linked the instance (the device credential carries + // no user). Null is tolerated by the ingest service (it skips + retries next sync). + Long actorUserId = + linkedInstanceRepository + .findById(token.getInstanceId()) + .map(LinkedInstance::getCreatedByUserId) + .orElse(null); + UsageSyncRequest.CategoryUnits c = req.cumulativeUnits(); + usageIngestService.ingest( + teamId, + actorUserId, + req.syncSeq(), + req.periodStart(), + Map.of( + BillingCategory.API, c.api(), + BillingCategory.AI, c.ai(), + BillingCategory.AUTOMATION, c.automation())); + // Drop the cache so the buildEntitlement below (and the portal's next read) reflect the + // just-charged delta + moved free-grant balance now, not after the TTL. + entitlementService.invalidate(teamId); + return ResponseEntity.ok(buildEntitlement(teamId)); + } + + /** The entitlement view shared by {@code GET /entitlement} and the {@code /sync} response. */ + private EntitlementResponse buildEntitlement(Long teamId) { // Same composition the FE wallet uses: billing facts (subscription, free pool) from - // TeamBillingService, period spend/cap + state from the entitlement snapshot. + // TeamBillingService, period spend/cap + state from the entitlement snapshot, plus the + // unit-calc policy + period the instance needs to meter locally. TeamBillingContext billing = billingService.forTeam(teamId); EntitlementSnapshot snap = entitlementService.getSnapshot(teamId); - - return ResponseEntity.ok( - new EntitlementResponse( - billing.subscribed(), - billing.freeRemainingUnits(), - snap.periodSpendUnits(), - snap.periodCapUnits(), - coarseState(snap.state()))); + PricingPolicy policy = pricingPolicyService.getEffectivePolicy(teamId); + return new EntitlementResponse( + billing.subscribed(), + billing.freeRemainingUnits(), + snap.periodSpendUnits(), + snap.periodCapUnits(), + coarseState(snap.state()), + new UnitCalcPolicy( + policy.getDocPagesPerUnit(), + policy.getDocBytesPerUnit(), + policy.getMinChargeUnits(), + policy.getFileUnitCap()), + snap.periodStart(), + snap.periodEnd()); } /** diff --git a/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java b/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java index 5e90df6bf5..53a5f34c15 100644 --- a/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java @@ -18,13 +18,15 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories; "stirling.software.saas.repository", "stirling.software.saas.billing.repository", "stirling.software.saas.ai.repository", - "stirling.software.saas.payg.repository" + "stirling.software.saas.payg.repository", + "stirling.software.saas.procurement.repository" }) @EntityScan({ "stirling.software.saas.accountlink", "stirling.software.saas.model", "stirling.software.saas.billing.model", "stirling.software.saas.ai.model", - "stirling.software.saas.payg" + "stirling.software.saas.payg", + "stirling.software.saas.procurement.model" }) public class SaasJpaConfig {} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java index 041eaa8f4a..b79d57117e 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java @@ -19,6 +19,7 @@ import org.springframework.security.core.Authentication; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -328,6 +329,32 @@ public class PaygWalletController { /** Request body for {@link #updateCap}. */ public record UpdateCapRequest(@Min(0) int capUsd, boolean noCap) {} + // --------------------------------------------------------------------------------------- + // POST /wallet/refresh — drop the caller's cached snapshot so the next read is fresh + // --------------------------------------------------------------------------------------- + + /** + * Drops the caller's team snapshot + billing cache so the next {@code GET /wallet} reflects a + * billing state that just changed out-of-band. The subscription flip is written by a Postgres + * function ({@code payg_link_subscription}) with no Java event to invalidate on, so a client + * that knows a change just happened — the portal while finalizing a checkout — pokes the cache + * here rather than waiting out the ~30s TTL. Team-scoped to the caller: a client can only + * refresh its own team, and a no-team caller is a cheap no-op. + */ + @PostMapping("/wallet/refresh") + @PreAuthorize("isAuthenticated()") + public ResponseEntity refreshWallet(Authentication auth) { + User user; + try { + user = AuthenticationUtils.getCurrentUser(auth, userRepository); + } catch (SecurityException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + primaryMembership(user.getId()) + .ifPresent(m -> entitlementService.invalidate(m.getTeam().getId())); + return ResponseEntity.noContent().build(); + } + // --------------------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------------------- diff --git a/app/saas/src/main/java/stirling/software/saas/payg/docs/DefaultDocumentClassifier.java b/app/saas/src/main/java/stirling/software/saas/payg/docs/DefaultDocumentClassifier.java index 603f490a4f..04b9b182a2 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/docs/DefaultDocumentClassifier.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/docs/DefaultDocumentClassifier.java @@ -5,6 +5,7 @@ import java.io.InputStream; import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -18,6 +19,9 @@ 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.proprietary.billing.DocumentUnitCalculator; +import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize; +import stirling.software.proprietary.billing.UnitCalcPolicy; import stirling.software.saas.payg.policy.PricingPolicy; /** @@ -42,9 +46,6 @@ 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 @@ -59,13 +60,7 @@ public class DefaultDocumentClassifier implements DocumentClassifier { Objects.requireNonNull(policy, "policy"); FileFacts facts = inspect(file, materialisedPath); - 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))); + int units = DocumentUnitCalculator.unitsForFile(facts.pages, facts.bytes, unitCalc(policy)); return new DocumentMetrics(facts.pages, facts.bytes, facts.contentType, units); } @@ -93,17 +88,16 @@ public class DefaultDocumentClassifier implements DocumentClassifier { int totalPages = 0; long totalBytes = 0; - long rawUnitsSum = 0; String firstContentType = null; + List sizes = new ArrayList<>(files.size()); for (int i = 0; i < files.size(); i++) { MultipartFile file = files.get(i); Path path = materialisedPaths == null ? null : materialisedPaths.get(i); FileFacts facts = inspect(file, path); - // 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)); + // Collect raw page/byte facts; the group cap is applied over the raw sum in the + // calculator (per-file clamping here would make the group cap a no-op). + sizes.add(new FileSize(facts.pages, facts.bytes)); totalPages = saturatedAdd(totalPages, facts.pages); totalBytes = saturatedAdd(totalBytes, facts.bytes); if (firstContentType == null) { @@ -111,13 +105,7 @@ public class DefaultDocumentClassifier implements DocumentClassifier { } } - 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))); + int totalUnits = DocumentUnitCalculator.unitsForGroup(sizes, unitCalc(policy)); return new DocumentMetrics( totalPages, @@ -126,6 +114,14 @@ public class DefaultDocumentClassifier implements DocumentClassifier { totalUnits); } + private static UnitCalcPolicy unitCalc(PricingPolicy policy) { + return new UnitCalcPolicy( + policy.getDocPagesPerUnit(), + policy.getDocBytesPerUnit(), + policy.getMinChargeUnits(), + policy.getFileUnitCap()); + } + private FileFacts inspect(MultipartFile file, Path materialisedPath) { long bytes = file.getSize(); String contentType = @@ -140,19 +136,6 @@ public class DefaultDocumentClassifier implements DocumentClassifier { 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; diff --git a/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java b/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java new file mode 100644 index 0000000000..291a46880c --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java @@ -0,0 +1,130 @@ +package stirling.software.saas.payg.instance; + +import java.time.LocalDateTime; +import java.util.Map; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.saas.payg.charge.ChargeContext; +import stirling.software.saas.payg.charge.JobChargeService; +import stirling.software.saas.payg.model.BillingCategory; +import stirling.software.saas.payg.model.JobSource; +import stirling.software.saas.payg.model.ProcessType; +import stirling.software.saas.payg.repository.PaygInstanceUsageRepository; + +/** + * Ingests a linked instance's daily usage sync (combined-billing "Mode A"). The instance reports a + * monotonic cumulative unit total per {@link BillingCategory}; we bill only the delta since the + * last sync via {@link JobChargeService#chargeStandalone} (reusing the in-cloud free-grant split, + * ledger DEBIT, Stripe meter and idempotency). Idempotent (a resend → delta 0 → no charge) and + * tamper-evident (a backwards total is refused; a monotonic {@code syncSeq} dedups replays). The + * cap is enforced at the instance gate, not here. Gated behind {@code account-link.enabled}. + */ +@Slf4j +@Service +@Profile("saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class InstanceUsageIngestService { + + private final PaygInstanceUsageRepository usageRepository; + private final JobChargeService chargeService; + + public InstanceUsageIngestService( + PaygInstanceUsageRepository usageRepository, JobChargeService chargeService) { + this.usageRepository = usageRepository; + this.chargeService = chargeService; + } + + /** + * Bills the delta for each category and advances the last-seen cumulative + sync sequence. The + * delta-advance and the charge share this transaction, so a crash before commit re-bills + * cleanly on retry (delta unchanged) and a commit means the cumulative moved with the charge. + * + * @param actorUserId the linking admin ({@code linked_instance.created_by_user_id}); required + * to attribute the charge. If {@code null} we skip entirely (don't advance) so a later + * sync, once the actor is resolvable, still bills the usage. + */ + @Transactional + public void ingest( + Long teamId, + Long actorUserId, + long syncSeq, + LocalDateTime periodStart, + Map cumulativeByCategory) { + if (teamId == null || periodStart == null || cumulativeByCategory == null) { + return; + } + if (actorUserId == null) { + log.warn( + "Instance usage sync for team {} has no actor (created_by_user_id null); not" + + " billing — a later sync will pick it up.", + teamId); + return; + } + cumulativeByCategory.forEach( + (category, cumulative) -> { + if (category == null + || category == BillingCategory.BYPASSED + || cumulative == null + || cumulative < 0) { + return; + } + applyCategory(teamId, actorUserId, syncSeq, periodStart, category, cumulative); + }); + } + + private void applyCategory( + Long teamId, + Long actorUserId, + long syncSeq, + LocalDateTime periodStart, + BillingCategory category, + long cumulative) { + // Pessimistic row lock so a duplicate delivery can't have two txns read the same baseline + // and both charge: the second waits, then sees the advanced seq and replay-skips. + PaygInstanceUsage row = + usageRepository + .findByTeamIdAndPeriodStartAndCategoryForUpdate( + teamId, periodStart, category.name()) + .orElse(null); + if (row != null && syncSeq <= row.getLastSyncSeq()) { + return; // replay / out-of-order — already applied this or a later sync + } + long lastCumulative = row == null ? 0L : row.getLastCumulativeUnits(); + long delta = cumulative - lastCumulative; + if (delta < 0) { + // The cumulative counter went backwards — a reset or tampering. Refuse to credit; don't + // advance, so the discrepancy stays visible and a corrected resend can reconcile. + log.warn( + "Instance usage regression team={} category={} reported {} < last {}; ignoring.", + teamId, + category, + cumulative, + lastCumulative); + return; + } + if (delta > 0) { + int units = (int) Math.min(delta, Integer.MAX_VALUE); + chargeService.chargeStandalone( + new ChargeContext( + actorUserId, + teamId, + JobSource.LINKED_INSTANCE, + ProcessType.SINGLE_TOOL, + category), + units); + } + if (row == null) { + row = new PaygInstanceUsage(teamId, periodStart, category.name(), cumulative, syncSeq); + } else { + row.setLastCumulativeUnits(cumulative); + row.setLastSyncSeq(syncSeq); + } + usageRepository.save(row); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/instance/PaygInstanceUsage.java b/app/saas/src/main/java/stirling/software/saas/payg/instance/PaygInstanceUsage.java new file mode 100644 index 0000000000..45a6435746 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/instance/PaygInstanceUsage.java @@ -0,0 +1,74 @@ +package stirling.software.saas.payg.instance; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.UpdateTimestamp; + +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 jakarta.persistence.UniqueConstraint; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Last-seen cumulative usage a linked self-hosted instance has reported for one {@code (team, + * billing period, category)} (combined-billing "Mode A"). The instance reports monotonic cumulative + * unit totals on its daily sync; SaaS bills {@code reportedCumulative - lastCumulativeUnits} via + * the standard charge path and advances this row. {@code lastSyncSeq} dedups replays. + */ +@Entity +@Table( + name = "payg_instance_usage", + uniqueConstraints = + @UniqueConstraint( + name = "uk_payg_instance_usage", + columnNames = {"team_id", "period_start", "category"})) +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PaygInstanceUsage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "team_id", nullable = false) + private Long teamId; + + @Column(name = "period_start", nullable = false) + private LocalDateTime periodStart; + + /** {@code BillingCategory} name — API / AI / AUTOMATION. */ + @Column(name = "category", nullable = false, length = 32) + private String category; + + @Column(name = "last_cumulative_units", nullable = false) + private long lastCumulativeUnits; + + @Column(name = "last_sync_seq", nullable = false) + private long lastSyncSeq; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public PaygInstanceUsage( + Long teamId, + LocalDateTime periodStart, + String category, + long lastCumulativeUnits, + long lastSyncSeq) { + this.teamId = teamId; + this.periodStart = periodStart; + this.category = category; + this.lastCumulativeUnits = lastCumulativeUnits; + this.lastSyncSeq = lastSyncSeq; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/lineage/ByteHashSignatureExtractor.java b/app/saas/src/main/java/stirling/software/saas/payg/lineage/ByteHashSignatureExtractor.java index 86e6e4ab47..4e62afcbd8 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/lineage/ByteHashSignatureExtractor.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/lineage/ByteHashSignatureExtractor.java @@ -1,22 +1,18 @@ package stirling.software.saas.payg.lineage; import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; import java.nio.file.Path; -import java.security.DigestInputStream; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.HexFormat; import java.util.Set; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; +import stirling.software.proprietary.billing.ContentHasher; + /** * SHA-256 of the file's bytes. The simplest universally-applicable signature — works for every - * content type, doesn't parse, doesn't allocate proportional to file size (fixed 64 KiB read - * buffer), hardware-accelerated by the JVM on modern hardware (Intel SHA-NI, ARM SHA extensions). + * content type, doesn't parse. Delegates to the shared {@link ContentHasher} so the cloud charge + * path and a linked self-hosted instance's meter compute byte-identical signatures. * *

    Always returns exactly one {@link LineageSignature} of type {@code "sha256"}. A future {@code * PdfMetadataSignatureExtractor} would be a separate bean and add its own signature type — composed @@ -26,36 +22,15 @@ import org.springframework.stereotype.Component; @Profile("saas") public class ByteHashSignatureExtractor implements LineageSignatureExtractor { - private static final String ALGORITHM = "SHA-256"; private static final String SIGNATURE_TYPE = "sha256"; - private static final int BUFFER_SIZE = 64 * 1024; @Override public Set extract(Path file) throws IOException { - MessageDigest digest = newDigest(); - try (InputStream raw = Files.newInputStream(file); - DigestInputStream in = new DigestInputStream(raw, digest)) { - byte[] buf = new byte[BUFFER_SIZE]; - // Drain through the digest stream; we only care about side effects on the digest. - while (in.read(buf) != -1) { - // no-op - } - } - String hex = HexFormat.of().formatHex(digest.digest()); - return Set.of(new LineageSignature(SIGNATURE_TYPE, hex)); + return Set.of(new LineageSignature(SIGNATURE_TYPE, ContentHasher.sha256(file))); } @Override public String name() { return SIGNATURE_TYPE; } - - private static MessageDigest newDigest() { - try { - return MessageDigest.getInstance(ALGORITHM); - } catch (NoSuchAlgorithmException e) { - // SHA-256 is mandated by every JDK; unreachable in practice. - throw new IllegalStateException(ALGORITHM + " unavailable — JDK is misconfigured", e); - } - } } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/JobSource.java b/app/saas/src/main/java/stirling/software/saas/payg/model/JobSource.java index 8f7d2b3df7..b023f08fb5 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/model/JobSource.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/JobSource.java @@ -15,5 +15,12 @@ public enum JobSource { /** * The Tauri desktop client. Independent of whether it routes to SaaS or a self-hosted backend. */ - DESKTOP_APP + DESKTOP_APP, + /** + * Usage reported by a linked self-hosted instance via the daily sync (combined-billing "Mode + * A"). The per-request surface is lost in the aggregate — the instance reports cumulative units + * per {@code BillingCategory} — so this just marks the charge as instance-synced. No per-source + * step limit is seeded for it; the charge path's fallback applies. + */ + LINKED_INSTANCE } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygInstanceUsageRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygInstanceUsageRepository.java new file mode 100644 index 0000000000..283cc212b1 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygInstanceUsageRepository.java @@ -0,0 +1,35 @@ +package stirling.software.saas.payg.repository; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import jakarta.persistence.LockModeType; + +import stirling.software.saas.payg.instance.PaygInstanceUsage; + +/** Last-seen cumulative usage per (team, period, category) for linked-instance daily syncs. */ +public interface PaygInstanceUsageRepository extends JpaRepository { + + Optional findByTeamIdAndPeriodStartAndCategory( + Long teamId, LocalDateTime periodStart, String category); + + /** + * Pessimistic-write variant the ingest uses so two concurrent deliveries of the same sync (e.g. + * a proxy retry) can't both read the same baseline and double-charge the delta. Must run inside + * a transaction. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query( + "SELECT u FROM PaygInstanceUsage u" + + " WHERE u.teamId = :teamId AND u.periodStart = :periodStart" + + " AND u.category = :category") + Optional findByTeamIdAndPeriodStartAndCategoryForUpdate( + @Param("teamId") Long teamId, + @Param("periodStart") LocalDateTime periodStart, + @Param("category") String category); +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java new file mode 100644 index 0000000000..f92267da33 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java @@ -0,0 +1,378 @@ +package stirling.software.saas.procurement.api; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.swagger.v3.oas.annotations.Hidden; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.saas.model.TeamMembership; +import stirling.software.saas.procurement.config.ProcurementConfigurationProperties; +import stirling.software.saas.procurement.model.ProcurementDeal; +import stirling.software.saas.procurement.model.ProcurementQuote; +import stirling.software.saas.procurement.pricing.QuoteConfig; +import stirling.software.saas.procurement.pricing.QuoteLineItem; +import stirling.software.saas.procurement.service.ProcurementService; +import stirling.software.saas.repository.TeamMembershipRepository; +import stirling.software.saas.util.AuthenticationUtils; + +/** + * The enterprise procurement journey for a linked team: read the deal snapshot, start/extend a + * (mock-licensed) trial, build a server-priced quote, and accept it. Stripe checkout itself is a + * Supabase edge function the portal calls with the accepted quote — this controller never touches + * Stripe. The caller's team is resolved from the authenticated principal; a team id is never + * trusted from the request. Mutations require the team leader. + */ +@Slf4j +@Hidden +@RestController +@RequestMapping("/api/v1/procurement") +@Profile("saas") +public class ProcurementController { + + // Local mapper to parse the stored line-items JSON; the saas context exposes no injectable + // ObjectMapper bean. + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final ProcurementService procurement; + private final TeamMembershipRepository memberRepo; + private final UserRepository userRepository; + private final ProcurementConfigurationProperties config; + + public ProcurementController( + ProcurementService procurement, + TeamMembershipRepository memberRepo, + UserRepository userRepository, + ProcurementConfigurationProperties config) { + this.procurement = Objects.requireNonNull(procurement); + this.memberRepo = Objects.requireNonNull(memberRepo); + this.userRepository = Objects.requireNonNull(userRepository); + this.config = Objects.requireNonNull(config); + } + + // ---- request / response DTOs ------------------------------------------- + + public record QuoteRequest( + long volume, + int users, + int intensity, // policy posture (runs/PDF): 2 / 4 / 7; 0 → default Governed + String deployment, + int termYears, + String serviceLevel, + boolean indemnification, + boolean training, + boolean qbr, + boolean offlineLicense, + String currency, + String businessName) { + QuoteConfig toConfig() { + return new QuoteConfig( + volume, + users, + intensity, + deployment, + termYears, + serviceLevel, + indemnification, + training, + qbr, + offlineLicense, + currency); + } + } + + public record QuoteResponse( + Long quoteId, + String quoteNumber, + String status, + String currency, + long annualNetMinor, + long tcvMinor, + List lineItems, + String validUntil, + String stripeQuoteId, + String invoiceUrl, + QuoteConfigEcho config) {} + + /** + * The inputs the quote was priced from, echoed back so the builder can seed itself when the + * buyer re-edits an existing quote. {@code users} is not persisted (only the resulting volume + * is), so it is always 0 here; the builder treats the seeded volume as manually set. + */ + public record QuoteConfigEcho( + long volume, + int users, + int intensity, + String deployment, + int termYears, + String serviceLevel, + boolean indemnification, + boolean training, + boolean qbr, + boolean offlineLicense, + String currency, + String businessName) {} + + public record SnapshotResponse( + Long dealId, + String stage, + String trialStartedAt, + String trialEndsAt, + int trialExtensionsUsed, + boolean licensed, + String licenseKey, + QuoteResponse latestQuote) {} + + // ---- endpoints ---------------------------------------------------------- + + /** + * The team's deal snapshot. Always 200 with a single shape; an unstarted procurement returns an + * empty snapshot ({@code dealId == null}) so the portal can render the "start" state without + * special-casing an empty body. + */ + @GetMapping + @PreAuthorize("isAuthenticated()") + public ResponseEntity snapshot(Authentication auth) { + Optional membership = primaryMembership(auth); + if (membership.isEmpty()) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + Long teamId = membership.get().getTeam().getId(); + // The licence key is the team's secret entitlement — leader-only. Members still see the + // journey (stage, trial, quote) but the key is withheld; the .lic file is likewise gated. + boolean leader = membership.get().getRole() == TeamRole.LEADER; + return ResponseEntity.ok( + procurement.getDeal(teamId).map(d -> toSnapshot(d, leader)).orElse(EMPTY_SNAPSHOT)); + } + + private static final SnapshotResponse EMPTY_SNAPSHOT = + new SnapshotResponse(null, null, null, null, 0, false, null, null); + + /** + * Download the offline / air-gapped licence file (.lic) for the team, when the paid offline + * add-on was purchased. 404 when there's no licence or the add-on wasn't taken — we don't leak + * that a licence exists to a team without the add-on. + */ + @GetMapping("/license/file") + @PreAuthorize("isAuthenticated()") + public ResponseEntity licenseFile(Authentication auth) { + // Leader-only: the offline .lic is the team's portable entitlement, not a member artefact. + Long teamId = requireLeader(auth); + if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + return procurement + .offlineLicenseFile(teamId) + .>map( + cert -> + ResponseEntity.ok() + .header( + HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"stirling-enterprise.lic\"") + .contentType(MediaType.TEXT_PLAIN) + .body(cert)) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @PostMapping("/trial/start") + @PreAuthorize("isAuthenticated()") + public ResponseEntity startTrial(Authentication auth) { + Long teamId = requireLeader(auth); + if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + return ResponseEntity.ok(toSnapshot(procurement.startTrial(teamId), true)); + } + + @PostMapping("/trial/extend") + @PreAuthorize("isAuthenticated()") + public ResponseEntity extendTrial(Authentication auth) { + Long teamId = requireLeader(auth); + if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + try { + return ResponseEntity.ok(toSnapshot(procurement.extendTrial(teamId), true)); + } catch (IllegalStateException e) { + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } + } + + @PostMapping("/quote") + @PreAuthorize("isAuthenticated()") + public ResponseEntity buildQuote( + @RequestBody QuoteRequest request, Authentication auth) { + Long teamId = requireLeader(auth); + if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + return ResponseEntity.ok( + toQuote( + procurement.buildQuote( + teamId, request.toConfig(), request.businessName()))); + } + + // Issue + accept are Supabase edge functions (they own Stripe): issue-procurement-quote turns a + // draft into a finalized Stripe Quote; accept-procurement-quote accepts it into a subscription. + // Both persist their results via SECURITY DEFINER RPCs; the snapshot above reflects them. + + /** + * Advance an issued quote to the agreement (security) stage, where the buyer reviews + agrees. + */ + @PostMapping("/agreement") + @PreAuthorize("isAuthenticated()") + public ResponseEntity startAgreement(Authentication auth) { + Long teamId = requireLeader(auth); + if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + try { + return ResponseEntity.ok(toSnapshot(procurement.startAgreement(teamId), true)); + } catch (IllegalStateException e) { + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } + } + + /** + * Provision on accept: upgrade the team's licence to the committed annual term, valid + * immediately. Called server-side by the accept edge function (ROLE_ADMIN via X-API-Key) once + * the subscription + invoice exist, so the buyer is licensed the moment they accept — the deal + * stays in the payment step until the invoice settles. Idempotent. + */ + @PostMapping("/provision") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity provision(@RequestParam("teamId") long teamId) { + try { + procurement.provisionLicense(teamId); + return ResponseEntity.ok().build(); + } catch (IllegalStateException e) { + log.warn("[procurement] provision rejected team={}: {}", teamId, e.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } + } + + /** + * Demo/manual stand-in for the {@code invoice.paid} webhook: mark the deal live (issue the + * annual licence, advance to active). The real go-live is webhook-driven once payment settles. + */ + @PostMapping("/go-live") + @PreAuthorize("isAuthenticated()") + public ResponseEntity goLive(Authentication auth) { + if (!config.isDemoControlsEnabled()) return ResponseEntity.notFound().build(); + Long teamId = requireLeader(auth); + if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + try { + return ResponseEntity.ok(toSnapshot(procurement.markLive(teamId), true)); + } catch (IllegalStateException e) { + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } + } + + /** Reset the team's procurement (delete the deal + quotes); returns the empty snapshot. */ + @PostMapping("/reset") + @PreAuthorize("isAuthenticated()") + public ResponseEntity reset(Authentication auth) { + if (!config.isDemoControlsEnabled()) return ResponseEntity.notFound().build(); + Long teamId = requireLeader(auth); + if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + procurement.resetDeal(teamId); + return ResponseEntity.ok(EMPTY_SNAPSHOT); + } + + // ---- helpers ------------------------------------------------------------ + + /** The caller's primary team membership; empty when unauthenticated/teamless. */ + private Optional primaryMembership(Authentication auth) { + User user; + try { + user = AuthenticationUtils.getCurrentUser(auth, userRepository); + } catch (SecurityException e) { + return Optional.empty(); + } + return memberRepo.findPrimaryMembership(user.getId()).stream().findFirst(); + } + + /** Team id only when the caller is the team leader; null otherwise (commercial actions). */ + private Long requireLeader(Authentication auth) { + return primaryMembership(auth) + .filter(m -> m.getRole() == TeamRole.LEADER) + .map(m -> m.getTeam().getId()) + .orElse(null); + } + + /** + * Build the snapshot for a deal. {@code includeLicenseKey} is true only for the team leader; a + * member sees {@code licensed} but not the key itself (see {@link #snapshot}). Mutation + * endpoints are leader-gated, so they always pass true. + */ + private SnapshotResponse toSnapshot(ProcurementDeal deal, boolean includeLicenseKey) { + QuoteResponse latest = + procurement.quotesForDeal(deal.getDealId()).stream() + .findFirst() + .map(this::toQuote) + .orElse(null); + return new SnapshotResponse( + deal.getDealId(), + deal.getStage(), + str(deal.getTrialStartedAt()), + str(deal.getTrialEndsAt()), + deal.getTrialExtensionsUsed(), + deal.getLicenseRef() != null, + includeLicenseKey ? deal.getLicenseRef() : null, + latest); + } + + private QuoteResponse toQuote(ProcurementQuote q) { + return new QuoteResponse( + q.getQuoteId(), + q.getQuoteNumber(), + q.getStatus(), + q.getCurrency(), + q.getAnnualNetMinor(), + q.getTcvMinor(), + parseLineItems(q.getLineItemsJson()), + q.getValidUntil() == null ? null : q.getValidUntil().toString(), + q.getStripeQuoteId(), + q.getStripeInvoiceUrl(), + new QuoteConfigEcho( + q.getVolume(), + 0, + q.getIntensity(), + q.getDeployment(), + q.getTermYears(), + q.getServiceLevel(), + q.isIndemnification(), + q.isTraining(), + q.isQbr(), + q.isOfflineLicense(), + q.getCurrency(), + q.getBusinessName())); + } + + private List parseLineItems(String json) { + if (json == null || json.isBlank()) return List.of(); + try { + return OBJECT_MAPPER.readValue( + json, + OBJECT_MAPPER + .getTypeFactory() + .constructCollectionType(List.class, QuoteLineItem.class)); + } catch (Exception e) { + log.warn("[procurement] failed to parse line items", e); + return List.of(); + } + } + + private static String str(Object o) { + return o == null ? null : o.toString(); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/config/KeygenConfigurationProperties.java b/app/saas/src/main/java/stirling/software/saas/procurement/config/KeygenConfigurationProperties.java new file mode 100644 index 0000000000..40ee2ba2c3 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/config/KeygenConfigurationProperties.java @@ -0,0 +1,55 @@ +package stirling.software.saas.procurement.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import lombok.Getter; +import lombok.Setter; + +/** + * Keygen credentials + policy for issuing enterprise procurement licences directly from Java. + * Prefix {@code stirling.keygen}. All secrets come from the environment (relaxed binding: {@code + * STIRLING_KEYGEN_ACCOUNT_ID}, {@code STIRLING_KEYGEN_API_TOKEN}, …) — never committed. + * + *

    {@code enabled} is the switch between {@code MockEnterpriseLicenseService} (default) and the + * real {@code KeygenEnterpriseLicenseService}; the mock stays in place until the env vars are + * wired. + */ +@Getter +@Setter +@Component +@Profile("saas") +@ConfigurationProperties(prefix = "stirling.keygen") +public class KeygenConfigurationProperties { + + /** Master switch: when true, the real Keygen client replaces the mock licence service. */ + private boolean enabled = false; + + /** Keygen account id (UUID or slug). From {@code STIRLING_KEYGEN_ACCOUNT_ID}. */ + private String accountId; + + /** Keygen admin API token. From {@code STIRLING_KEYGEN_API_TOKEN}. Never log this. */ + private String apiToken; + + /** Policy the committed-enterprise licences are created under. From {@code ..._POLICY_ID}. */ + private String policyId; + + /** API base; overridable for self-hosted Keygen, defaults to the hosted service. */ + private String apiBase = "https://api.keygen.sh/v1"; + + /** + * License-file check-out algorithm. Must stay {@code base64+ed25519} — the self-hosted {@code + * KeygenLicenseVerifier} only verifies that scheme (signed, unencrypted) offline. + */ + private String licenseFileAlgorithm = "base64+ed25519"; + + /** True when the credentials needed to talk to Keygen are all present. */ + public boolean isConfigured() { + return notBlank(accountId) && notBlank(apiToken) && notBlank(policyId); + } + + private static boolean notBlank(String s) { + return s != null && !s.isBlank(); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/config/ProcurementConfigurationProperties.java b/app/saas/src/main/java/stirling/software/saas/procurement/config/ProcurementConfigurationProperties.java new file mode 100644 index 0000000000..98ec0cd573 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/config/ProcurementConfigurationProperties.java @@ -0,0 +1,33 @@ +package stirling.software.saas.procurement.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import lombok.Getter; +import lombok.Setter; + +/** Tunables for the enterprise procurement flow. Prefix {@code stirling.procurement}. */ +@Getter +@Setter +@Component +@Profile("saas") +@ConfigurationProperties(prefix = "stirling.procurement") +public class ProcurementConfigurationProperties { + + /** Free trial length, in days (no card). */ + private int trialDurationDays = 14; + + /** Days added per trial extension. */ + private int trialExtensionDays = 7; + + /** Maximum number of trial extensions a buyer may take. */ + private int maxTrialExtensions = 2; + + /** + * Enables the demo-only endpoints (POST /reset, POST /go-live) that reset a team's procurement + * or mark it live without payment. Off by default; turn on ONLY in demo/dev environments — + * /go-live is a stand-in for the invoice.paid webhook and would let a leader activate unpaid. + */ + private boolean demoControlsEnabled = false; +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/license/EnterpriseLicenseService.java b/app/saas/src/main/java/stirling/software/saas/procurement/license/EnterpriseLicenseService.java new file mode 100644 index 0000000000..0c3d547d4c --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/license/EnterpriseLicenseService.java @@ -0,0 +1,47 @@ +package stirling.software.saas.procurement.license; + +import java.time.LocalDateTime; + +/** + * Issues and modifies the customer-facing entitlement that actually unlocks the product for an + * enterprise deal — a Keygen licence (trial or annual, connected or air-gapped). This is the seam + * the real Keygen management client plugs into; today {@link MockEnterpriseLicenseService} records + * intent without calling Keygen. Distinct from the EE {@code KeygenLicenseVerifier}, which only + * verifies this instance's own licence. + */ +public interface EnterpriseLicenseService { + + /** + * Issue a time-boxed trial licence for the team, owned by {@code ownerEmail} (the team leader); + * returns the licence reference (the Keygen key, stored on the deal). + */ + String issueTrialLicense(Long teamId, String ownerEmail, LocalDateTime expiresAt); + + /** Move a licence's expiry out (trial extension). */ + void extendLicense(String licenseRef, LocalDateTime newExpiry); + + /** + * Issue/upgrade to a committed annual licence carrying the deal's {@link LicenseEntitlements} + * ({@code seats} = 0 means unlimited). When {@code existingRef} is non-null (the team already + * has a trial licence), that licence is upgraded in place so the key the buyer already holds + * keeps working; otherwise a new licence is created. Owned by {@code ownerEmail}; returns the + * licence reference. + */ + String issueAnnualLicense( + Long teamId, + String ownerEmail, + LocalDateTime expiresAt, + String existingRef, + LicenseEntitlements entitlements); + + /** Suspend a licence (e.g. payment failed, deal lost). */ + void suspendLicense(String licenseRef); + + /** + * Check out a signed, offline-verifiable licence file (a {@code -----BEGIN LICENSE FILE-----} + * certificate) for the given licence, for an air-gapped self-hosted instance. The paid offline + * add-on gates whether this is offered; the certificate itself is generated on demand and never + * stored. + */ + String checkOutLicenseFile(String licenseRef); +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/license/KeygenEnterpriseLicenseService.java b/app/saas/src/main/java/stirling/software/saas/procurement/license/KeygenEnterpriseLicenseService.java new file mode 100644 index 0000000000..a11ad05755 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/license/KeygenEnterpriseLicenseService.java @@ -0,0 +1,304 @@ +package stirling.software.saas.procurement.license; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.saas.procurement.config.KeygenConfigurationProperties; + +/** + * Real {@link EnterpriseLicenseService}: manages the team's enterprise licence directly against the + * Keygen API (the "call Keygen from Java" direction), rather than via the Supabase edge functions + * the self-hosted checkout uses. Active only when {@code stirling.keygen.enabled=true}; otherwise + * {@link MockEnterpriseLicenseService} is the bean. + * + *

    Licences are owned by the team leader (a Keygen user, found or created by email) and created + * under the committed-enterprise policy. Metadata carries {@code isEnterprise} + {@code users} so a + * checked-out offline licence file satisfies the self-hosted {@code KeygenLicenseVerifier}. The + * licence key returned is stored on the deal as its {@code license_ref}. + */ +@Slf4j +@Service +@Profile("saas") +@ConditionalOnProperty(name = "stirling.keygen.enabled", havingValue = "true") +public class KeygenEnterpriseLicenseService implements EnterpriseLicenseService { + + private static final String VND_JSON = "application/vnd.api+json"; + + private final ObjectMapper mapper = new ObjectMapper(); + private final HttpClient http = + HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); + private final KeygenConfigurationProperties config; + + public KeygenEnterpriseLicenseService(KeygenConfigurationProperties config) { + this.config = config; + // Fail fast: if the flag is on but creds are missing, a misconfigured prod deploy should be + // caught at startup, not at the first trial/provision. (Flag off → Mock bean, never here.) + if (!config.isConfigured()) { + throw new IllegalStateException( + "stirling.keygen.enabled=true but Keygen is not fully configured — set " + + "STIRLING_KEYGEN_ACCOUNT_ID / _API_TOKEN / _POLICY_ID, or turn the flag off"); + } + } + + @Override + public String issueTrialLicense(Long teamId, String ownerEmail, LocalDateTime expiresAt) { + String ownerId = findOrCreateUser(ownerEmail); + // Trial: enterprise entitlement, unlimited users, expiring at the trial end. No committed + // volume/add-ons yet — those are stamped on the annual licence at provision. + return createLicense(ownerId, expiresAt, trialMetadata(teamId)); + } + + @Override + public void extendLicense(String licenseRef, LocalDateTime newExpiry) { + Map attrs = new LinkedHashMap<>(); + attrs.put("expiry", iso(newExpiry)); + patchLicense(licenseRef, attrs); + } + + @Override + public String issueAnnualLicense( + Long teamId, + String ownerEmail, + LocalDateTime expiresAt, + String existingRef, + LicenseEntitlements ent) { + Map metadata = annualMetadata(teamId, ent); + // Upgrade the trial licence in place so the key the buyer already holds keeps working. + if (existingRef != null && !existingRef.isBlank()) { + Map attrs = new LinkedHashMap<>(); + attrs.put("expiry", iso(expiresAt)); + attrs.put("suspended", false); + attrs.put("metadata", metadata); + patchLicense(existingRef, attrs); + return existingRef; + } + String ownerId = findOrCreateUser(ownerEmail); + return createLicense(ownerId, expiresAt, metadata); + } + + @Override + public void suspendLicense(String licenseRef) { + HttpResponse res = + send( + authed(licenseUrl(licenseRef) + "/actions/suspend") + .POST(HttpRequest.BodyPublishers.noBody()) + .build()); + expect(res, 200, "suspend licence"); + } + + @Override + public String checkOutLicenseFile(String licenseRef) { + // Signed, unencrypted (base64+ed25519) so the self-hosted verifier can validate it offline; + // include entitlements in the snapshot. Never encrypt — the verifier only reads + // base64+ed25519. + String url = + licenseUrl(licenseRef) + + "/actions/check-out?include=entitlements&algorithm=" + + enc(config.getLicenseFileAlgorithm()); + HttpResponse res = + send(authed(url).POST(HttpRequest.BodyPublishers.noBody()).build()); + expect(res, 200, "check-out licence file"); + JsonNode cert = readJson(res).at("/data/attributes/certificate"); + if (cert.isMissingNode() || cert.asText().isBlank()) { + throw new IllegalStateException("Keygen check-out returned no certificate"); + } + return cert.asText(); + } + + // ---- Keygen primitives -------------------------------------------------- + + /** Find the Keygen user by email, creating it if absent; returns the user id. */ + private String findOrCreateUser(String email) { + if (email == null || email.isBlank()) { + throw new IllegalArgumentException("Cannot own a licence without an owner email"); + } + HttpResponse find = + send(authed(accountUrl() + "/users/" + enc(email)).GET().build()); + if (find.statusCode() == 200) { + return readJson(find).at("/data/id").asText(); + } + if (find.statusCode() != 404) { + expect(find, 200, "find Keygen user"); // throws with the real status + } + Map body = + jsonApi("users", Map.of("email", email), null); // no relationships + HttpResponse create = + send( + authed(accountUrl() + "/users") + .POST(HttpRequest.BodyPublishers.ofString(write(body))) + .build()); + expect(create, 201, "create Keygen user"); + return readJson(create).at("/data/id").asText(); + } + + private String createLicense( + String ownerId, LocalDateTime expiresAt, Map metadata) { + Map attributes = new LinkedHashMap<>(); + attributes.put("expiry", iso(expiresAt)); + attributes.put("metadata", metadata); + Map relationships = + Map.of( + "policy", + Map.of("data", Map.of("type", "policies", "id", config.getPolicyId())), + "owner", + Map.of("data", Map.of("type", "users", "id", ownerId))); + Map body = jsonApi("licenses", attributes, relationships); + HttpResponse res = + send( + authed(accountUrl() + "/licenses") + .POST(HttpRequest.BodyPublishers.ofString(write(body))) + .build()); + expect(res, 201, "create licence"); + return readJson(res).at("/data/attributes/key").asText(); + } + + private void patchLicense(String licenseRef, Map attributes) { + Map body = jsonApi("licenses", attributes, null); + HttpResponse res = + send( + authed(licenseUrl(licenseRef)) + .method("PATCH", HttpRequest.BodyPublishers.ofString(write(body))) + .build()); + expect(res, 200, "update licence"); + } + + // ---- helpers ------------------------------------------------------------ + + // The self-hosted verifier only reads isEnterprise + users; everything else is informational + // (dashboard / reconciliation) but kept so the licence is a self-describing record of the deal. + + /** Trial licence: enterprise, unlimited users, no committed volume/add-ons yet. */ + private Map trialMetadata(Long teamId) { + Map m = baseMetadata(teamId, true); + m.put("users", 0); // 0 = unlimited during the trial + m.put("seat_count", 0); + return m; + } + + /** Committed annual licence: the full entitlement snapshot from the accepted quote + deal. */ + private Map annualMetadata(Long teamId, LicenseEntitlements ent) { + Map m = baseMetadata(teamId, false); + int seats = Math.max(0, ent.seats()); + m.put("users", seats); // 0 = unlimited + m.put("seat_count", seats); // parity with the self-hosted edge's metadata + m.put("volume", ent.volume()); // committed PDFs / year + m.put("term_years", ent.termYears()); + if (ent.serviceLevel() != null && !ent.serviceLevel().isBlank()) { + m.put("service_level", ent.serviceLevel()); + } + m.put("indemnification", ent.indemnification()); + m.put("training", ent.training()); + m.put("qbr", ent.qbr()); + m.put("offline_license", ent.offlineLicense()); + if (ent.deployment() != null && !ent.deployment().isBlank()) { + m.put("deployment", ent.deployment()); + } + if (ent.dealId() != null) m.put("deal_id", ent.dealId()); + if (ent.subscriptionId() != null && !ent.subscriptionId().isBlank()) { + m.put("subscription_id", ent.subscriptionId()); + } + return m; + } + + private Map baseMetadata(Long teamId, boolean trial) { + Map m = new LinkedHashMap<>(); + m.put("team_id", teamId); + m.put("plan_type", "enterprise"); + m.put("isEnterprise", true); + m.put("trial", trial); + return m; + } + + private Map jsonApi( + String type, Map attributes, Map relationships) { + Map data = new LinkedHashMap<>(); + data.put("type", type); + data.put("attributes", attributes); + if (relationships != null) data.put("relationships", relationships); + return Map.of("data", data); + } + + private HttpRequest.Builder authed(String url) { + return HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Authorization", "Bearer " + config.getApiToken()) + .header("Content-Type", VND_JSON) + .header("Accept", VND_JSON) + .timeout(Duration.ofSeconds(30)); + } + + private HttpResponse send(HttpRequest request) { + try { + return http.send(request, HttpResponse.BodyHandlers.ofString()); + } catch (java.io.IOException e) { + throw new IllegalStateException("Keygen request failed: " + e.getMessage(), e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Keygen request interrupted", e); + } + } + + private void expect(HttpResponse res, int status, String action) { + if (res.statusCode() != status) { + // Keep the response body out of the thrown message: it never carries the token, but can + // echo owner emails / metadata, and the message reaches warn-level logs. Body at debug. + log.debug( + "[procurement][keygen] {} failed: HTTP {} body={}", + action, + res.statusCode(), + res.body()); + throw new IllegalStateException( + "Keygen " + action + " failed: HTTP " + res.statusCode()); + } + } + + private JsonNode readJson(HttpResponse res) { + try { + return mapper.readTree(res.body()); + } catch (Exception e) { + throw new IllegalStateException("Keygen returned unparseable JSON", e); + } + } + + private String write(Map body) { + try { + return mapper.writeValueAsString(body); + } catch (Exception e) { + throw new IllegalStateException("Failed to serialise Keygen request", e); + } + } + + private String accountUrl() { + return config.getApiBase() + "/accounts/" + config.getAccountId(); + } + + private String licenseUrl(String licenseRef) { + return accountUrl() + "/licenses/" + enc(licenseRef); + } + + private static String enc(String s) { + return URLEncoder.encode(s, StandardCharsets.UTF_8); + } + + private static String iso(LocalDateTime dt) { + return dt.toInstant(ZoneOffset.UTC).toString(); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/license/LicenseEntitlements.java b/app/saas/src/main/java/stirling/software/saas/procurement/license/LicenseEntitlements.java new file mode 100644 index 0000000000..0b7a0be6c9 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/license/LicenseEntitlements.java @@ -0,0 +1,21 @@ +package stirling.software.saas.procurement.license; + +/** + * The committed deal's entitlements, stamped onto the annual Keygen licence's metadata so the + * licence is a self-describing record of what was bought. Only {@code seats} (as {@code users}) and + * the enterprise flag are read by the self-hosted verifier; the rest is informational — kept "for + * good measure" so the Keygen dashboard and any downstream reconciliation can see the full picture. + * Built by {@code ProcurementService} from the accepted quote + deal. + */ +public record LicenseEntitlements( + long volume, // committed PDFs / year + int seats, // 0 = unlimited + String deployment, // cloud | selfhost | airgap + int termYears, + String serviceLevel, // standard | priority | dedicated + boolean indemnification, + boolean training, + boolean qbr, + boolean offlineLicense, + Long dealId, + String subscriptionId) {} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/license/MockEnterpriseLicenseService.java b/app/saas/src/main/java/stirling/software/saas/procurement/license/MockEnterpriseLicenseService.java new file mode 100644 index 0000000000..511d67cd08 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/license/MockEnterpriseLicenseService.java @@ -0,0 +1,83 @@ +package stirling.software.saas.procurement.license; + +import java.time.LocalDateTime; +import java.util.UUID; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +/** + * Mock implementation of {@link EnterpriseLicenseService}: records the intended licence action and + * returns a synthetic reference, without calling Keygen. Lets the whole procurement journey run + * end-to-end while the real Keygen management client is a later drop-in — the seam and the stored + * {@code license_ref} on the deal stay identical. + * + *

    This is the default; it steps aside for {@code KeygenEnterpriseLicenseService} when {@code + * stirling.keygen.enabled=true} (real Keygen secrets are wired). + */ +@Slf4j +@Service +@Profile("saas") +@ConditionalOnProperty( + name = "stirling.keygen.enabled", + havingValue = "false", + matchIfMissing = true) +public class MockEnterpriseLicenseService implements EnterpriseLicenseService { + + @Override + public String issueTrialLicense(Long teamId, String ownerEmail, LocalDateTime expiresAt) { + String ref = "mock-trial-" + UUID.randomUUID(); + // Owner email is deliberately not logged — it's PII and adds nothing to the mock trace. + log.info( + "[procurement][mock-license] issue trial team={} expires={} ref={}", + teamId, + expiresAt, + ref); + return ref; + } + + @Override + public void extendLicense(String licenseRef, LocalDateTime newExpiry) { + log.info("[procurement][mock-license] extend ref={} newExpiry={}", licenseRef, newExpiry); + } + + @Override + public String issueAnnualLicense( + Long teamId, + String ownerEmail, + LocalDateTime expiresAt, + String existingRef, + LicenseEntitlements ent) { + // Upgrade in place when a trial licence already exists, so the key stays stable. + String ref = existingRef != null ? existingRef : "mock-annual-" + UUID.randomUUID(); + // Owner email is deliberately not logged — it's PII and adds nothing to the mock trace. + log.info( + "[procurement][mock-license] issue annual team={} seats={} volume={} deployment={} expires={} ref={} upgrade={}", + teamId, + ent.seats(), + ent.volume(), + ent.deployment(), + expiresAt, + ref, + existingRef != null); + return ref; + } + + @Override + public void suspendLicense(String licenseRef) { + log.info("[procurement][mock-license] suspend ref={}", licenseRef); + } + + @Override + public String checkOutLicenseFile(String licenseRef) { + log.info("[procurement][mock-license] check-out licence file ref={}", licenseRef); + // A syntactically shaped stand-in so the portal download path is exercisable without + // Keygen; not a valid certificate. + return "-----BEGIN LICENSE FILE-----\nmock-offline-license-for-" + + licenseRef + + "\n-----END LICENSE FILE-----\n"; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementDeal.java b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementDeal.java new file mode 100644 index 0000000000..f97d66f365 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementDeal.java @@ -0,0 +1,87 @@ +package stirling.software.saas.procurement.model; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * A linked team's enterprise commercial journey (one per team). Stage mirrors the buyer journey the + * portal renders (trial -> quote -> agreement -> payment -> live). The entitlement that + * actually unlocks the product is the Keygen licence in {@code licenseRef}; the paid subscription, + * once commercial, is mirrored in {@code billing_subscriptions} and referenced by {@code + * subscriptionId}. + */ +@Entity +@Table(name = "procurement_deal") +@NoArgsConstructor +@Getter +@Setter +public class ProcurementDeal implements Serializable { + + private static final long serialVersionUID = 1L; + + public static final String STAGE_TRIAL = "trial"; + public static final String STAGE_QUOTE = "quote"; + public static final String STAGE_AGREEMENT = "security"; + public static final String STAGE_PAYMENT = "procurement"; + public static final String STAGE_LIVE = "active"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "deal_id") + private Long dealId; + + @Column(name = "team_id", nullable = false, unique = true) + private Long teamId; + + @Column(name = "stage", nullable = false, length = 32) + private String stage = STAGE_TRIAL; + + @Column(name = "trial_started_at") + private LocalDateTime trialStartedAt; + + @Column(name = "trial_ends_at") + private LocalDateTime trialEndsAt; + + @Column(name = "trial_extensions_used", nullable = false) + private int trialExtensionsUsed; + + @Column(name = "license_ref", length = 128) + private String licenseRef; + + @Column(name = "subscription_id", length = 255) + private String subscriptionId; + + @Column(name = "accepted_quote_id") + private Long acceptedQuoteId; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @Version + @Column(name = "version", nullable = false) + private Long version; + + public ProcurementDeal(Long teamId) { + this.teamId = teamId; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java new file mode 100644 index 0000000000..dd635a2cf6 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java @@ -0,0 +1,126 @@ +package stirling.software.saas.procurement.model; + +import java.io.Serializable; +import java.time.LocalDate; +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * A priced, itemised offer built against a {@link ProcurementDeal}. The config columns are the + * buyer's choices; {@code annualNetMinor}/{@code tcvMinor} and {@code lineItemsJson} are the + * server-computed result (never trusted from the client). Stripe fields are populated when the + * accepted quote is turned into a checkout. + */ +@Entity +@Table(name = "procurement_quote") +@NoArgsConstructor +@Getter +@Setter +public class ProcurementQuote implements Serializable { + + private static final long serialVersionUID = 1L; + + public static final String STATUS_DRAFT = "draft"; + public static final String STATUS_SENT = "sent"; + public static final String STATUS_ACCEPTED = "accepted"; + public static final String STATUS_EXPIRED = "expired"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "quote_id") + private Long quoteId; + + @Column(name = "deal_id", nullable = false) + private Long dealId; + + @Column(name = "quote_number", nullable = false, length = 64) + private String quoteNumber; + + @Column(name = "status", nullable = false, length = 24) + private String status = STATUS_DRAFT; + + @Column(name = "currency", nullable = false, length = 8) + private String currency = "USD"; + + @Column(name = "volume", nullable = false) + private long volume; + + @Column(name = "seats") + private Integer seats; + + /** Policy posture as runs-per-PDF: Essentials 2, Governed 4, Regulated 7. Defaults Governed. */ + @Column(name = "intensity", nullable = false) + private int intensity = 4; + + @Column(name = "deployment", length = 24) + private String deployment; + + @Column(name = "term_years", nullable = false) + private int termYears; + + @Column(name = "service_level", nullable = false, length = 24) + private String serviceLevel; + + @Column(name = "indemnification", nullable = false) + private boolean indemnification; + + @Column(name = "training", nullable = false) + private boolean training; + + @Column(name = "qbr", nullable = false) + private boolean qbr; + + @Column(name = "offline_license", nullable = false) + private boolean offlineLicense; + + @Column(name = "annual_net_minor", nullable = false) + private long annualNetMinor; + + @Column(name = "tcv_minor", nullable = false) + private long tcvMinor; + + @Column(name = "line_items", columnDefinition = "text") + private String lineItemsJson; + + // The Stripe Quote this was issued as (finalized → has a number + PDF). Set by the edge fn. + @Column(name = "stripe_quote_id", length = 128) + private String stripeQuoteId; + + // Hosted Stripe invoice URL for the subscription's first invoice, set once the quote is + // accepted. + @Column(name = "stripe_invoice_url", columnDefinition = "text") + private String stripeInvoiceUrl; + + // Buyer's company name (shown on the quote/agreement); echoed back so an edit remembers it. + @Column(name = "business_name", length = 255) + private String businessName; + + @Column(name = "valid_until") + private LocalDate validUntil; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @Version + @Column(name = "version", nullable = false) + private Long version; +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java new file mode 100644 index 0000000000..d10f216e8a --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java @@ -0,0 +1,44 @@ +package stirling.software.saas.procurement.pricing; + +/** + * The enterprise rate card (D71): the inputs pricing derives a quote from. In production these are + * read from the Stripe price mirror; {@link #defaults()} is the fallback used when the {@code + * stripe} schema isn't synced (dev / tests) and is the single source of the numbers the marketing + * prototype ({@code quotePricing}) encodes. + * + *

    The meter is denominated in runs (a PDF running N policies is N runs). The per-run rate + * is a fraction of a dollar, so it lives here as a {@code double} in dollars; flat and one-time + * fees are whole-dollar amounts held in minor units (cents). See {@link ProcurementPricingService} + * for how they combine. + */ +public record PricingRates( + double listRatePerRun, // $0.01 list per run + double floorRatePerRun, // $0.005 asymptotic floor (cost + margin) + double discountPerDoubling, // 0.06 = 6% off the per-run rate per doubling past 1M runs/yr + double[] termDiscountByYear, // meter-only discount; index 0 = 1yr … index 4 = 5yr + double indemnificationRate, // fraction of the net meter (legal exposure scales with usage) + long dedicatedSupportMinor, // flat: dedicated SE/CSM (standard + priority are included) + long selfHostDeployMinor, // flat: self-hosted deployment + long airgapDeployMinor, // flat: air-gapped deployment + long qbrAnnualMinor, // flat: quarterly business reviews + long trainingOneTimeMinor) { // one-time: onboarding & training + + public static PricingRates defaults() { + return new PricingRates( + 0.01, // $0.01 / run list + 0.005, // $0.005 / run floor + 0.06, // 6% off per doubling of committed runs past 1M/yr + new double[] {0.0, 0.03, 0.05, 0.06, 0.07}, // 1–5 year term, meter only + 0.05, // IP indemnification = 5% of the net meter + 3_000_000, // dedicated SE/CSM $30,000 / yr + 1_200_000, // self-hosted $12,000 / yr + 3_600_000, // air-gapped $36,000 / yr + 800_000, // QBRs $8,000 / yr + 750_000); // onboarding & training $7,500 one-time + } + + public double termDiscount(int termYears) { + int idx = Math.max(1, Math.min(termYears, 5)) - 1; + return termDiscountByYear[idx]; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java new file mode 100644 index 0000000000..4707c94197 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java @@ -0,0 +1,178 @@ +package stirling.software.saas.procurement.pricing; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import org.springframework.stereotype.Service; + +/** + * The canonical enterprise pricing engine (D71) — the single server-side definition the quote + * builder, the order form, and Stripe checkout all derive from. A faithful port of the marketing + * prototype's {@code quotePricing}: + * + *

    + *   runVol    = volume x intensity            // posture: Essentials x2, Governed x4, Regulated x7
    + *   volDisc   = min(0.5, 0.06 x log2(runVol / 1M))   // 6% off the per-run rate per doubling past 1M
    + *   rate      = max($0.005, $0.01 x (1 - volDisc))   // continuous curve, floors at half a cent
    + *   meterNet  = round(runVol x rate x (1 - termDisc))    // whole dollars; term discounts the meter only
    + *   annualNet = meterNet + dedicated + deployment + indemnification + qbr   // needs priced flat
    + *   tcv       = annualNet x termYears + training
    + * 
    + * + *

    No volume tiers, no service-level multipliers, no ACV floor — those were the retired model. + * Deployment (self-hosted / air-gapped) and dedicated support are flat line items at cost basis; + * SSO / SCIM / RBAC / audit are always included. Money is USD only; amounts are in minor units + * (cents). Rates come from {@link PricingRates} (Stripe-backed in prod). + */ +@Service +public class ProcurementPricingService { + + private static final double LOG2 = Math.log(2.0); + private static final long RUN_CURVE_KNEE = 1_000_000L; // discount starts past 1M committed runs + + /** + * Estimated annual PDF volume from seat count (~2,012.5 PDFs/user/yr = 5 docs/day x 230 working + * days x 1.75), used to prefill the builder's volume step. Matches the prototype's {@code users + * x 5 x 230 x 1.75}. + */ + public long estimateAnnualVolume(int users) { + return Math.round(Math.max(0, users) * 5.0 * 230.0 * 1.75); + } + + public QuoteBreakdown price(QuoteConfig cfg) { + return price(cfg, PricingRates.defaults()); + } + + public QuoteBreakdown price(QuoteConfig cfg, PricingRates rates) { + // Never trust the client's volume/intensity: clamp so a tampered request can't drive a + // negative amount. The curve and flat fees are server-side, so the browser can only pick a + // smaller legitimate config, never a cheaper rate. + long volume = Math.max(0, cfg.volume()); + int intensity = Math.max(1, cfg.intensity()); + long runVol = volume * (long) intensity; + + // Committed-volume curve: continuous, no cliffs. Floors at the per-run cost + margin. + double volDisc = + runVol > RUN_CURVE_KNEE + ? Math.min( + 0.5, + rates.discountPerDoubling() + * (Math.log(runVol / (double) RUN_CURVE_KNEE) / LOG2)) + : 0.0; + double rate = Math.max(rates.floorRatePerRun(), rates.listRatePerRun() * (1.0 - volDisc)); + double termDisc = rates.termDiscount(cfg.termYears()); + + // The meter is a whole-dollar figure (the quote reads in dollars), then minor units. + long annualBaseMinor = Math.round((double) runVol * rate) * 100L; + long meterNetMinor = Math.round((double) runVol * rate * (1.0 - termDisc)) * 100L; + long termDiscountMinor = meterNetMinor - annualBaseMinor; // <= 0 + + long support = + "dedicated".equalsIgnoreCase(cfg.serviceLevel()) + ? rates.dedicatedSupportMinor() + : 0L; + long deploy = deployFeeMinor(cfg.deployment(), rates); + long indemnity = + cfg.indemnification() + ? Math.round(meterNetMinor * rates.indemnificationRate()) + : 0L; + long qbr = cfg.qbr() ? rates.qbrAnnualMinor() : 0L; + long training = cfg.training() ? rates.trainingOneTimeMinor() : 0L; + + long annualNet = meterNetMinor + support + deploy + indemnity + qbr; + long tcv = annualNet * cfg.termYears() + training; + + double effectivePerPdf = rate * intensity; // quotes speak per-PDF-at-posture, never per-run + + List lines = new ArrayList<>(); + lines.add( + new QuoteLineItem( + "usage", + String.format( + Locale.ROOT, + "PDF processing — %,d PDFs/yr at $%.4f/PDF (%s posture)", + volume, + effectivePerPdf, + postureLabel(intensity)), + QuoteLineItem.Kind.RECURRING, + annualBaseMinor)); + lines.add( + new QuoteLineItem( + "seats", + "Unlimited users + SSO / SCIM / RBAC / audit", + QuoteLineItem.Kind.INCLUDED, + 0L)); + if (termDiscountMinor < 0) { + lines.add( + new QuoteLineItem( + "multi-year", + cfg.termYears() + "-year commitment", + QuoteLineItem.Kind.DISCOUNT, + termDiscountMinor)); + } + if (support > 0) { + lines.add( + new QuoteLineItem( + "support", + "Dedicated SE / CSM", + QuoteLineItem.Kind.RECURRING, + support)); + } + if (deploy > 0) { + lines.add( + new QuoteLineItem( + "deployment", + deploymentLabel(cfg.deployment()) + " deployment", + QuoteLineItem.Kind.RECURRING, + deploy)); + } + if (indemnity > 0) { + lines.add( + new QuoteLineItem( + "indemnification", + "IP indemnification", + QuoteLineItem.Kind.RECURRING, + indemnity)); + } + if (qbr > 0) { + lines.add( + new QuoteLineItem( + "qbr", + "Quarterly business reviews", + QuoteLineItem.Kind.RECURRING, + qbr)); + } + if (training > 0) { + lines.add( + new QuoteLineItem( + "training", + "Onboarding & training", + QuoteLineItem.Kind.ONE_TIME, + training)); + } + return new QuoteBreakdown(lines, annualNet, tcv, cfg.currency()); + } + + private static long deployFeeMinor(String deployment, PricingRates rates) { + if ("airgap".equalsIgnoreCase(deployment)) return rates.airgapDeployMinor(); + if ("selfhost".equalsIgnoreCase(deployment)) return rates.selfHostDeployMinor(); + return 0L; // cloud (managed) has no deployment fee + } + + /** Buyer-facing posture name for the intensity (policy count); the demo's POLICY_POSTURES. */ + private static String postureLabel(int intensity) { + return switch (intensity) { + case 2 -> "Essentials"; + case 4 -> "Governed"; + case 7 -> "Regulated"; + default -> intensity + "-policy"; + }; + } + + private static String deploymentLabel(String deployment) { + if ("airgap".equalsIgnoreCase(deployment)) return "Air-gapped"; + if ("selfhost".equalsIgnoreCase(deployment)) return "Self-hosted"; + return "Stirling Cloud"; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteBreakdown.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteBreakdown.java new file mode 100644 index 0000000000..10ffde5271 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteBreakdown.java @@ -0,0 +1,12 @@ +package stirling.software.saas.procurement.pricing; + +import java.util.List; + +/** + * The priced result of a {@link QuoteConfig}: the itemised lines plus the two headline figures the + * order form and Stripe checkout are built from. {@code annualNetMinor} is the recurring annual fee + * after the multi-year discount; {@code tcvMinor} is total contract value across the term including + * one-time fees. Minor units (cents). + */ +public record QuoteBreakdown( + List lineItems, long annualNetMinor, long tcvMinor, String currency) {} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java new file mode 100644 index 0000000000..cc6612c530 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java @@ -0,0 +1,32 @@ +package stirling.software.saas.procurement.pricing; + +/** + * The buyer-configurable inputs to an enterprise quote. Mirrors the quote builder's four steps + * (volume, commitment & service, add-ons) and is the sole input to {@link + * ProcurementPricingService}. Amounts are never carried here — the service derives them from these + * choices and the rate card. + */ +public record QuoteConfig( + long volume, // committed PDFs per year + int users, // seats (drives the volume auto-estimate when the buyer hasn't overridden) + int intensity, // policy posture: runs per PDF — Essentials 2, Governed 4, Regulated 7 + String deployment, // cloud | selfhost | airgap (priced flat; inherited from the trial) + int termYears, // 1..5 + String serviceLevel, // standard | priority (both included) | dedicated (flat SE/CSM fee) + boolean indemnification, + boolean training, + boolean qbr, + boolean offlineLicense, // offline .lic availability (gates download; no longer priced here) + String currency) { // USD only for now + + /** Default posture when none is chosen — Governed (x4), per the pricing alignment decision. */ + public static final int DEFAULT_INTENSITY = 4; + + public QuoteConfig { + if (termYears < 1) termYears = 1; + if (termYears > 5) termYears = 5; + if (intensity < 1) intensity = DEFAULT_INTENSITY; + if (serviceLevel == null || serviceLevel.isBlank()) serviceLevel = "standard"; + if (currency == null || currency.isBlank()) currency = "USD"; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteLineItem.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteLineItem.java new file mode 100644 index 0000000000..a8b0d29352 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteLineItem.java @@ -0,0 +1,16 @@ +package stirling.software.saas.procurement.pricing; + +/** + * One line on the itemised quote. {@code amountMinor} is in the currency's minor unit (cents); + * discounts are negative. {@code kind} drives how the portal groups it (recurring annual vs a + * one-time fee vs a discount line). + */ +public record QuoteLineItem(String key, String label, Kind kind, long amountMinor) { + + public enum Kind { + RECURRING, + ONE_TIME, + DISCOUNT, + INCLUDED + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementDealRepository.java b/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementDealRepository.java new file mode 100644 index 0000000000..14247ba1ba --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementDealRepository.java @@ -0,0 +1,17 @@ +package stirling.software.saas.procurement.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import stirling.software.saas.procurement.model.ProcurementDeal; + +public interface ProcurementDealRepository extends JpaRepository { + + Optional findByTeamId(Long teamId); + + boolean existsByTeamId(Long teamId); + + /** Reset: drop the team's deal (quotes + activity cascade via FK). */ + void deleteByTeamId(Long teamId); +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementQuoteRepository.java b/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementQuoteRepository.java new file mode 100644 index 0000000000..45f7af8225 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementQuoteRepository.java @@ -0,0 +1,12 @@ +package stirling.software.saas.procurement.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import stirling.software.saas.procurement.model.ProcurementQuote; + +public interface ProcurementQuoteRepository extends JpaRepository { + + List findByDealIdOrderByCreatedAtDesc(Long dealId); +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java new file mode 100644 index 0000000000..57f530cc9a --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java @@ -0,0 +1,338 @@ +package stirling.software.saas.procurement.service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.saas.model.TeamMembership; +import stirling.software.saas.procurement.config.ProcurementConfigurationProperties; +import stirling.software.saas.procurement.license.EnterpriseLicenseService; +import stirling.software.saas.procurement.license.LicenseEntitlements; +import stirling.software.saas.procurement.model.ProcurementDeal; +import stirling.software.saas.procurement.model.ProcurementQuote; +import stirling.software.saas.procurement.pricing.ProcurementPricingService; +import stirling.software.saas.procurement.pricing.QuoteBreakdown; +import stirling.software.saas.procurement.pricing.QuoteConfig; +import stirling.software.saas.procurement.repository.ProcurementDealRepository; +import stirling.software.saas.procurement.repository.ProcurementQuoteRepository; +import stirling.software.saas.repository.TeamMembershipRepository; + +/** + * Orchestrates a linked team's procurement journey: start a (mock-licensed) trial, build a + * server-priced quote, and accept it. Stripe checkout itself lives in a Supabase edge function the + * portal calls with the accepted quote; on payment the webhook seeds {@code billing_subscriptions} + * and this service issues the annual licence. All amounts are minor units (cents). + */ +@Slf4j +@Service +@Profile("saas") +public class ProcurementService { + + // Local mapper for the line-items JSON snapshot; the saas context exposes no injectable + // ObjectMapper bean, and this (de)serialisation doesn't need Spring's configured one. + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final ProcurementDealRepository dealRepo; + private final ProcurementQuoteRepository quoteRepo; + private final ProcurementPricingService pricing; + private final EnterpriseLicenseService licenses; + private final ProcurementConfigurationProperties config; + private final TeamMembershipRepository memberRepo; + + public ProcurementService( + ProcurementDealRepository dealRepo, + ProcurementQuoteRepository quoteRepo, + ProcurementPricingService pricing, + EnterpriseLicenseService licenses, + ProcurementConfigurationProperties config, + TeamMembershipRepository memberRepo) { + this.dealRepo = dealRepo; + this.quoteRepo = quoteRepo; + this.pricing = pricing; + this.licenses = licenses; + this.config = config; + this.memberRepo = memberRepo; + } + + /** + * The team leader's email — the natural owner of the team's Keygen licence. Falls back to the + * username when no email is set; null when the team has no leader. + */ + private String leaderEmail(Long teamId) { + return memberRepo.findByTeamIdAndRole(teamId, TeamRole.LEADER).stream() + .findFirst() + .map(TeamMembership::getUser) + .map( + u -> + u.getEmail() != null && !u.getEmail().isBlank() + ? u.getEmail() + : u.getUsername()) + .orElse(null); + } + + @Transactional(readOnly = true) + public Optional getDeal(Long teamId) { + return dealRepo.findByTeamId(teamId); + } + + @Transactional(readOnly = true) + public List quotesForDeal(Long dealId) { + return quoteRepo.findByDealIdOrderByCreatedAtDesc(dealId); + } + + /** + * Start (or restart) the free trial for a team: issue a mock trial licence and stamp the trial + * window on the deal. No Stripe: a no-card trial has no subscription; the entitlement is the + * Keygen licence, and the deal row is the journey state. + */ + @Transactional + public ProcurementDeal startTrial(Long teamId) { + ProcurementDeal deal = + dealRepo.findByTeamId(teamId).orElseGet(() -> new ProcurementDeal(teamId)); + LocalDateTime now = LocalDateTime.now(); + LocalDateTime ends = now.plusDays(config.getTrialDurationDays()); + deal.setStage(ProcurementDeal.STAGE_TRIAL); + deal.setTrialStartedAt(now); + deal.setTrialEndsAt(ends); + deal.setTrialExtensionsUsed(0); + deal.setLicenseRef(licenses.issueTrialLicense(teamId, leaderEmail(teamId), ends)); + deal = dealRepo.save(deal); + log.info( + "[procurement] trial started team={} deal={} ends={}", + teamId, + deal.getDealId(), + ends); + return deal; + } + + /** Extend the current trial by the configured increment, up to the cap. */ + @Transactional + public ProcurementDeal extendTrial(Long teamId) { + ProcurementDeal deal = + dealRepo.findByTeamId(teamId) + .orElseThrow(() -> new IllegalStateException("No deal for team " + teamId)); + // Only extend while still in the trial. Past the trial (e.g. active), licenseRef points at + // the committed annual licence — extending would rewind its expiry. + if (!ProcurementDeal.STAGE_TRIAL.equals(deal.getStage())) { + throw new IllegalStateException("Trial extension only allowed during the trial stage"); + } + if (deal.getTrialExtensionsUsed() >= config.getMaxTrialExtensions()) { + throw new IllegalStateException("Trial extension cap reached"); + } + LocalDateTime base = + deal.getTrialEndsAt() != null ? deal.getTrialEndsAt() : LocalDateTime.now(); + LocalDateTime newEnd = base.plusDays(config.getTrialExtensionDays()); + deal.setTrialEndsAt(newEnd); + deal.setTrialExtensionsUsed(deal.getTrialExtensionsUsed() + 1); + if (deal.getLicenseRef() != null) { + licenses.extendLicense(deal.getLicenseRef(), newEnd); + } + return dealRepo.save(deal); + } + + /** Price a quote config server-side and persist it as a draft against the team's deal. */ + @Transactional + public ProcurementQuote buildQuote(Long teamId, QuoteConfig cfg, String businessName) { + ProcurementDeal deal = + dealRepo.findByTeamId(teamId).orElseGet(() -> new ProcurementDeal(teamId)); + if (ProcurementDeal.STAGE_LIVE.equals(deal.getStage())) { + throw new IllegalStateException("Cannot rebuild a quote on a live deal"); + } + // (Re)building a quote returns the deal to the quote stage and drops any prior acceptance, + // so a rebuild from security/payment can't leave a stale stage or accepted-quote pointer. + deal.setStage(ProcurementDeal.STAGE_QUOTE); + deal.setAcceptedQuoteId(null); + deal = dealRepo.save(deal); + + QuoteBreakdown breakdown = pricing.price(cfg); + + ProcurementQuote quote = new ProcurementQuote(); + quote.setDealId(deal.getDealId()); + quote.setQuoteNumber(nextQuoteNumber(deal.getDealId())); + // Priced but not yet issued: the edge fn creates the Stripe Quote and flips this to SENT. + quote.setStatus(ProcurementQuote.STATUS_DRAFT); + quote.setCurrency(cfg.currency()); + quote.setVolume(cfg.volume()); + quote.setSeats(cfg.users() > 0 ? cfg.users() : null); + quote.setIntensity(cfg.intensity()); + quote.setDeployment(cfg.deployment()); + quote.setTermYears(cfg.termYears()); + quote.setServiceLevel(cfg.serviceLevel()); + quote.setIndemnification(cfg.indemnification()); + quote.setTraining(cfg.training()); + quote.setQbr(cfg.qbr()); + quote.setOfflineLicense(cfg.offlineLicense()); + quote.setBusinessName(businessName); + quote.setAnnualNetMinor(breakdown.annualNetMinor()); + quote.setTcvMinor(breakdown.tcvMinor()); + quote.setLineItemsJson(writeLineItems(breakdown)); + quote.setValidUntil(LocalDate.now().plusDays(30)); + quote = quoteRepo.save(quote); + log.info( + "[procurement] quote built team={} quote={} annualNet={} tcv={}", + teamId, + quote.getQuoteNumber(), + quote.getAnnualNetMinor(), + quote.getTcvMinor()); + return quote; + } + + /** + * Advance the deal to the agreement (security) stage: the buyer has an issued quote and is + * reviewing the enterprise agreement before it's accepted into a subscription. Requires an + * issued quote on the deal. + */ + @Transactional + public ProcurementDeal startAgreement(Long teamId) { + ProcurementDeal deal = + dealRepo.findByTeamId(teamId) + .orElseThrow(() -> new IllegalStateException("No deal for team " + teamId)); + boolean hasIssuedQuote = + quoteRepo.findByDealIdOrderByCreatedAtDesc(deal.getDealId()).stream() + .anyMatch(q -> ProcurementQuote.STATUS_SENT.equals(q.getStatus())); + if (!hasIssuedQuote) { + throw new IllegalStateException("No issued quote for team " + teamId); + } + deal.setStage(ProcurementDeal.STAGE_AGREEMENT); + deal = dealRepo.save(deal); + log.info("[procurement] agreement stage team={} deal={}", teamId, deal.getDealId()); + return deal; + } + + /** + * Provision on accept: upgrade the team's licence to the committed annual term (valid + * immediately), so the buyer can get going the moment they accept — before the invoice is paid. + * Driven by the accept edge function once the subscription + invoice are created. Idempotent + * (upgrades the existing licence in place); deliberately does NOT change the stage — the deal + * stays in the payment step so the outstanding invoice remains visible until it settles. + */ + @Transactional + public ProcurementDeal provisionLicense(Long teamId) { + ProcurementDeal deal = + dealRepo.findByTeamId(teamId) + .orElseThrow(() -> new IllegalStateException("No deal for team " + teamId)); + deal.setLicenseRef(issueOrUpgradeAnnual(deal)); + deal = dealRepo.save(deal); + log.info("[procurement] licence provisioned team={} deal={}", teamId, deal.getDealId()); + return deal; + } + + /** + * Mark the deal fully live (advance to the active stage) once payment settles. In production + * this is the {@code invoice.paid} webhook; here it's the demo/manual stand-in. Re-affirms the + * annual licence in case provisioning didn't run at accept. + */ + @Transactional + public ProcurementDeal markLive(Long teamId) { + ProcurementDeal deal = + dealRepo.findByTeamId(teamId) + .orElseThrow(() -> new IllegalStateException("No deal for team " + teamId)); + deal.setLicenseRef(issueOrUpgradeAnnual(deal)); + deal.setStage(ProcurementDeal.STAGE_LIVE); + deal = dealRepo.save(deal); + log.info("[procurement] deal live team={} deal={}", teamId, deal.getDealId()); + return deal; + } + + /** + * Issue or upgrade the committed annual licence from the deal's accepted (else latest) quote, + * stamping the full entitlement snapshot onto it and upgrading the trial licence in place when + * one exists. + */ + private String issueOrUpgradeAnnual(ProcurementDeal deal) { + ProcurementQuote q = + deal.getAcceptedQuoteId() != null + ? quoteRepo.findById(deal.getAcceptedQuoteId()).orElse(null) + : quoteRepo.findByDealIdOrderByCreatedAtDesc(deal.getDealId()).stream() + .findFirst() + .orElse(null); + int term = q != null ? Math.max(1, q.getTermYears()) : 1; + String deployment = + q != null && q.getDeployment() != null && !q.getDeployment().isBlank() + ? q.getDeployment() + : "cloud"; + int seats = q != null && q.getSeats() != null ? q.getSeats() : 0; // 0 = unlimited + LicenseEntitlements entitlements = + new LicenseEntitlements( + q != null ? q.getVolume() : 0, + seats, + deployment, + term, + q != null ? q.getServiceLevel() : null, + q != null && q.isIndemnification(), + q != null && q.isTraining(), + q != null && q.isQbr(), + q != null && q.isOfflineLicense(), + deal.getDealId(), + deal.getSubscriptionId()); + return licenses.issueAnnualLicense( + deal.getTeamId(), + leaderEmail(deal.getTeamId()), + LocalDateTime.now().plusYears(term), + deal.getLicenseRef(), + entitlements); + } + + /** + * Check out the offline/air-gapped licence file for a team, when the offline add-on was + * purchased. Requires an issued licence on the deal and the accepted quote to carry the offline + * add-on; returns empty otherwise (so the controller can 404 rather than leak that a licence + * exists). The certificate is generated on demand by Keygen and never stored. + */ + @Transactional(readOnly = true) + public Optional offlineLicenseFile(Long teamId) { + ProcurementDeal deal = dealRepo.findByTeamId(teamId).orElse(null); + if (deal == null || deal.getLicenseRef() == null) return Optional.empty(); + if (!hasOfflineAddOn(deal)) return Optional.empty(); + return Optional.of(licenses.checkOutLicenseFile(deal.getLicenseRef())); + } + + /** + * Whether the deal's accepted quote carries the paid offline-licence add-on. Gated on + * the accepted quote (not the latest) so merely toggling the add-on on an unaccepted draft + * can't unlock the offline file — it's only available once the add-on has actually been bought. + */ + private boolean hasOfflineAddOn(ProcurementDeal deal) { + if (deal.getAcceptedQuoteId() == null) return false; + ProcurementQuote quote = quoteRepo.findById(deal.getAcceptedQuoteId()).orElse(null); + return quote != null && quote.isOfflineLicense(); + } + + /** + * Reset a team's procurement: delete the deal (quotes + activity cascade). For + * re-demos/testing. + */ + @Transactional + public void resetDeal(Long teamId) { + dealRepo.deleteByTeamId(teamId); + log.info("[procurement] deal reset team={}", teamId); + } + + private String nextQuoteNumber(Long dealId) { + int seq = quoteRepo.findByDealIdOrderByCreatedAtDesc(dealId).size() + 1; + String token = UUID.randomUUID().toString().substring(0, 4).toUpperCase(Locale.ROOT); + return String.format(Locale.ROOT, "QT-%s-%04d", token, seq); + } + + private String writeLineItems(QuoteBreakdown breakdown) { + try { + return OBJECT_MAPPER.writeValueAsString(breakdown.lineItems()); + } catch (JsonProcessingException e) { + log.warn("[procurement] failed to serialise line items", e); + return "[]"; + } + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/security/SaasPortalAuditScopeResolver.java b/app/saas/src/main/java/stirling/software/saas/security/SaasPortalAuditScopeResolver.java new file mode 100644 index 0000000000..00c7c42757 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/security/SaasPortalAuditScopeResolver.java @@ -0,0 +1,45 @@ +package stirling.software.saas.security; + +import java.util.List; +import java.util.Objects; + +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.audit.PortalAuditScope; +import stirling.software.proprietary.audit.PortalAuditScopeResolver; +import stirling.software.saas.repository.TeamMembershipRepository; + +/** SaaS audit visibility: admins see the server; team LEADERs see their team (by member email). */ +@Component +@Primary +@Profile("saas") +@RequiredArgsConstructor +public class SaasPortalAuditScopeResolver implements PortalAuditScopeResolver { + + private final TeamSecurityExpressions teamSecurity; + private final TeamMembershipRepository membershipRepository; + + @Override + public PortalAuditScope resolve() { + if (PortalAuditScopeResolver.hasAdminAuthority()) { + return PortalAuditScope.server(); + } + if (!teamSecurity.isCurrentUserTeamLeader()) { + return PortalAuditScope.denied(); + } + Long teamId = teamSecurity.currentUserTeamId(); + if (teamId == null) { + return PortalAuditScope.denied(); + } + List memberEmails = + membershipRepository.findByTeamId(teamId).stream() + .map(m -> m.getUser() == null ? null : m.getUser().getEmail()) + .filter(Objects::nonNull) + .toList(); + return PortalAuditScope.team("team:" + teamId, memberEmails); + } +} diff --git a/app/saas/src/main/resources/application-saas.properties b/app/saas/src/main/resources/application-saas.properties index 1984974fef..49798d64cd 100644 --- a/app/saas/src/main/resources/application-saas.properties +++ b/app/saas/src/main/resources/application-saas.properties @@ -35,6 +35,15 @@ app.supabase.clock-skew-seconds=${app.jwt.clock-skew-seconds:120} app.supabase.edge-function-url=https://${app.supabase.project-ref}.supabase.co/functions/v1 app.supabase.edge-function-secret=${SUPABASE_EDGE_FUNCTION_SECRET:} +# ---------- Enterprise procurement licences (Keygen) ---------- +# Off by default → MockEnterpriseLicenseService (no real Keygen calls). Set enabled=true + the +# three creds to switch to the real KeygenEnterpriseLicenseService. Same account/policy as the +# self-hosted products; env names match the edge functions' KEYGEN_* for consistency. +stirling.keygen.enabled=${STIRLING_KEYGEN_ENABLED:false} +stirling.keygen.account-id=${KEYGEN_ACCOUNT_ID:} +stirling.keygen.api-token=${KEYGEN_API_TOKEN:} +stirling.keygen.policy-id=${KEYGEN_POLICY_ID:} + # ---------- PAYG meter reporting ---------- # Posts billable usage to the Supabase `meter-payg-units` edge function in the JobChargeService # close() afterCommit hook. Defaults to empty so unit tests / local dev are no-ops; set diff --git a/app/saas/src/main/resources/db/migration/saas/V25__payg_instance_usage.sql b/app/saas/src/main/resources/db/migration/saas/V25__payg_instance_usage.sql new file mode 100644 index 0000000000..7ab65b8b78 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V25__payg_instance_usage.sql @@ -0,0 +1,35 @@ +-- Twin of supabase/migrations/_payg_instance_usage.sql (Stirling-PDF-SaaS). Keep the table +-- definition byte-identical to the Supabase twin — both repos own this stirling_pdf table (the SaaS +-- profile runs this Flyway migration against the Supabase-backed DB; non-Hibernate consumers — RLS, +-- PostgREST, edge functions — rely on the Supabase migration ledger having the matching entry). +-- +-- Per-(team, billing period, category) last-seen cumulative usage reported by a linked self-hosted +-- instance (combined-billing "Mode A"). The instance reports monotonic cumulative unit totals on +-- its daily sync; SaaS bills the DELTA since the last sync — idempotent (a resend bills nothing) and +-- tamper-evident (a counter that drops is a signal) — by reusing the standard charge path +-- (JobChargeService.chargeStandalone), so no separate billing logic exists for this flow. +-- +-- Inert until release: written only by the InstanceController /sync endpoint, gated behind +-- stirling.billing.account-link.enabled (default off). Additive, idempotent table. + +CREATE TABLE IF NOT EXISTS stirling_pdf.payg_instance_usage ( + id BIGSERIAL PRIMARY KEY, + team_id BIGINT NOT NULL REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE, + period_start TIMESTAMP NOT NULL, + category VARCHAR(32) NOT NULL, + -- Highest cumulative unit total seen for this (team, period, category); the next sync bills + -- (reported cumulative - this). + last_cumulative_units BIGINT NOT NULL DEFAULT 0, + -- Highest sync sequence applied; a sync at or below this is a replay and is ignored. + last_sync_seq BIGINT NOT NULL DEFAULT 0, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_payg_instance_usage UNIQUE (team_id, period_start, category) +); + +CREATE INDEX IF NOT EXISTS idx_payg_instance_usage_team + ON stirling_pdf.payg_instance_usage (team_id); + +COMMENT ON TABLE stirling_pdf.payg_instance_usage IS + 'Last-seen cumulative usage per (team, billing period, category) reported by linked self-hosted ' + 'instances (combined-billing Mode A). SaaS bills the delta vs last_cumulative_units via the ' + 'standard charge path; last_sync_seq dedups replays.'; diff --git a/app/saas/src/main/resources/db/migration/saas/V26__payg_shadow_charge_linked_instance_source.sql b/app/saas/src/main/resources/db/migration/saas/V26__payg_shadow_charge_linked_instance_source.sql new file mode 100644 index 0000000000..f79097e325 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V26__payg_shadow_charge_linked_instance_source.sql @@ -0,0 +1,19 @@ +-- Twin of supabase/migrations/_payg_shadow_charge_linked_instance_source.sql (Stirling-PDF-SaaS). +-- Keep byte-identical to the Supabase twin. +-- +-- Widen the payg_shadow_charge.job_source CHECK to allow LINKED_INSTANCE (combined-billing "Mode +-- A"). A linked instance's daily-sync charge runs through JobChargeService.chargeStandalone, which +-- writes a payg_shadow_charge row with job_source=LINKED_INSTANCE — a JobSource value added after +-- the original constraint, so the insert was failing the check and 500ing POST /api/v1/instance/sync. +-- +-- Idempotent (DROP IF EXISTS + ADD, so it survives being applied by both the Flyway and Supabase +-- migration sets against the same schema) and additive (the new set is a superset of the JobSource +-- enum; the app only ever writes enum values, so no existing row can violate it). + +ALTER TABLE stirling_pdf.payg_shadow_charge + DROP CONSTRAINT IF EXISTS payg_shadow_charge_job_source_check; + +ALTER TABLE stirling_pdf.payg_shadow_charge + ADD CONSTRAINT payg_shadow_charge_job_source_check + CHECK (job_source IS NULL + OR job_source IN ('WEB', 'API', 'PIPELINE', 'DESKTOP_APP', 'LINKED_INSTANCE')); diff --git a/app/saas/src/main/resources/db/migration/saas/V27__procurement.sql b/app/saas/src/main/resources/db/migration/saas/V27__procurement.sql new file mode 100644 index 0000000000..0048f999c1 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V27__procurement.sql @@ -0,0 +1,72 @@ +-- Enterprise procurement: the tables that track a linked team's journey from trial to live. +-- +-- One deal per team (the commercial journey: trial -> quote -> agreement -> payment -> live), the +-- quotes built against it (the itemised, priced offers), and an append-only activity log for the +-- money/licence-touching actions. The resulting subscription is mirrored in billing_subscriptions +-- (seeded on trial start / payment); the entitlement that unlocks the product is a Keygen licence +-- referenced by procurement_deal.license_ref. Prices are computed server-side (ProcurementPricingService). +-- +-- Additive and idempotent (IF NOT EXISTS) — safe on the shared dev branch. + +CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_deal ( + deal_id BIGSERIAL PRIMARY KEY, + team_id BIGINT NOT NULL UNIQUE REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE, + -- one active deal per team; the journey lives on this row. + stage VARCHAR(32) NOT NULL DEFAULT 'trial', + -- trial | quote | security (agreement) | procurement (payment) | active (live) + trial_started_at TIMESTAMP, + trial_ends_at TIMESTAMP, + trial_extensions_used INT NOT NULL DEFAULT 0, + license_ref VARCHAR(128), + -- Keygen licence id issued for this deal (trial or annual). Mocked until Keygen mgmt lands. + subscription_id VARCHAR(255), + -- Stripe subscription id, mirrored into billing_subscriptions once commercial. + accepted_quote_id BIGINT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_quote ( + quote_id BIGSERIAL PRIMARY KEY, + deal_id BIGINT NOT NULL REFERENCES stirling_pdf.procurement_deal(deal_id) ON DELETE CASCADE, + quote_number VARCHAR(64) NOT NULL, + status VARCHAR(24) NOT NULL DEFAULT 'draft', + -- draft | sent | accepted | expired + currency VARCHAR(8) NOT NULL DEFAULT 'USD', + volume BIGINT NOT NULL, + seats INT, + deployment VARCHAR(24), + term_years INT NOT NULL, + service_level VARCHAR(24) NOT NULL, + indemnification BOOLEAN NOT NULL DEFAULT FALSE, + training BOOLEAN NOT NULL DEFAULT FALSE, + qbr BOOLEAN NOT NULL DEFAULT FALSE, + annual_net_minor BIGINT NOT NULL, + -- recurring annual fee after the multi-year discount, in minor units (cents). + tcv_minor BIGINT NOT NULL, + -- total contract value across the term incl. one-time fees, minor units. + line_items TEXT, + -- JSON snapshot of the itemised lines the order form renders. + stripe_price_id VARCHAR(128), + checkout_session_id VARCHAR(255), + checkout_url TEXT, + valid_until DATE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_activity ( + activity_id BIGSERIAL PRIMARY KEY, + deal_id BIGINT NOT NULL REFERENCES stirling_pdf.procurement_deal(deal_id) ON DELETE CASCADE, + actor_user_id BIGINT, + -- the internal/portal user who took the action; informational (no FK). + action VARCHAR(48) NOT NULL, + -- trial_started | trial_extended | quote_built | quote_accepted | checkout_created | went_live ... + detail TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_procurement_quote_deal ON stirling_pdf.procurement_quote (deal_id); +CREATE INDEX IF NOT EXISTS idx_procurement_activity_deal ON stirling_pdf.procurement_activity (deal_id); diff --git a/app/saas/src/main/resources/db/migration/saas/V28__procurement_stripe_quote.sql b/app/saas/src/main/resources/db/migration/saas/V28__procurement_stripe_quote.sql new file mode 100644 index 0000000000..5d83ee35f8 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V28__procurement_stripe_quote.sql @@ -0,0 +1,8 @@ +-- Stripe Quote support: a procurement quote is issued as a real Stripe Quote (finalized → PDF + +-- shareable), and on acceptance Stripe creates the committed subscription + first invoice. The +-- Stripe operations live in Supabase edge functions; these columns hold the references they write +-- back. Twin of Supabase migration 20260703000000_procurement_stripe_quote.sql. + +ALTER TABLE stirling_pdf.procurement_quote + ADD COLUMN IF NOT EXISTS stripe_quote_id VARCHAR(128), + ADD COLUMN IF NOT EXISTS stripe_invoice_url TEXT; diff --git a/app/saas/src/main/resources/db/migration/saas/V29__procurement_business_name.sql b/app/saas/src/main/resources/db/migration/saas/V29__procurement_business_name.sql new file mode 100644 index 0000000000..ba9c74dde8 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V29__procurement_business_name.sql @@ -0,0 +1,5 @@ +-- Persist the buyer's company name on the quote so re-editing remembers it and it can be shown on +-- the quote/agreement. Twin of Supabase migration 20260705000000_procurement_business_name.sql. + +ALTER TABLE stirling_pdf.procurement_quote + ADD COLUMN IF NOT EXISTS business_name VARCHAR(255); diff --git a/app/saas/src/main/resources/db/migration/saas/V30__procurement_offline_license.sql b/app/saas/src/main/resources/db/migration/saas/V30__procurement_offline_license.sql new file mode 100644 index 0000000000..e7ca0233a7 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V30__procurement_offline_license.sql @@ -0,0 +1,6 @@ +-- Offline / air-gapped licence add-on flag on the quote (a paid add-on; priced like QBR). Written +-- and read by the Java backend via JPA. Twin of Supabase migration +-- 20260710000000_procurement_offline_license.sql. + +ALTER TABLE stirling_pdf.procurement_quote + ADD COLUMN IF NOT EXISTS offline_license BOOLEAN NOT NULL DEFAULT false; diff --git a/app/saas/src/main/resources/db/migration/saas/V31__procurement_intensity.sql b/app/saas/src/main/resources/db/migration/saas/V31__procurement_intensity.sql new file mode 100644 index 0000000000..4920d52d26 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V31__procurement_intensity.sql @@ -0,0 +1,6 @@ +-- Policy posture (runs per PDF) on the quote: the D71 meter is denominated in runs, so a quote +-- must remember the posture it was priced at (Essentials 2, Governed 4, Regulated 7). Defaults to +-- Governed (4). Written and read by the Java backend via JPA. A Supabase twin migration mirrors it. + +ALTER TABLE stirling_pdf.procurement_quote + ADD COLUMN IF NOT EXISTS intensity INTEGER NOT NULL DEFAULT 4; diff --git a/app/saas/src/test/java/stirling/software/saas/accountlink/InstanceControllerTest.java b/app/saas/src/test/java/stirling/software/saas/accountlink/InstanceControllerTest.java index d221ac8603..4c9cc3b76a 100644 --- a/app/saas/src/test/java/stirling/software/saas/accountlink/InstanceControllerTest.java +++ b/app/saas/src/test/java/stirling/software/saas/accountlink/InstanceControllerTest.java @@ -1,6 +1,7 @@ package stirling.software.saas.accountlink; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -8,9 +9,12 @@ import static org.mockito.Mockito.when; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; +import java.util.Optional; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; @@ -19,14 +23,19 @@ import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; +import stirling.software.proprietary.billing.UnitCalcPolicy; import stirling.software.saas.accountlink.InstanceController.EntitlementResponse; import stirling.software.saas.payg.billing.TeamBillingContext; import stirling.software.saas.payg.billing.TeamBillingService; import stirling.software.saas.payg.entitlement.EntitlementService; import stirling.software.saas.payg.entitlement.EntitlementSnapshot; +import stirling.software.saas.payg.instance.InstanceUsageIngestService; +import stirling.software.saas.payg.model.BillingCategory; import stirling.software.saas.payg.model.EntitlementState; import stirling.software.saas.payg.model.FeatureGate; import stirling.software.saas.payg.model.FeatureSet; +import stirling.software.saas.payg.policy.PricingPolicy; +import stirling.software.saas.payg.policy.PricingPolicyService; /** * Pure-Mockito unit tests for {@link InstanceController} — the device-credential entitlement read. @@ -39,9 +48,22 @@ class InstanceControllerTest { @Mock private EntitlementService entitlementService; @Mock private TeamBillingService billingService; @Mock private AccountLinkService accountLinkService; + @Mock private PricingPolicyService pricingPolicyService; + @Mock private InstanceUsageIngestService usageIngestService; + @Mock private LinkedInstanceRepository linkedInstanceRepository; private InstanceController controller() { - return new InstanceController(entitlementService, billingService, accountLinkService); + return new InstanceController( + entitlementService, + billingService, + accountLinkService, + pricingPolicyService, + usageIngestService, + linkedInstanceRepository); + } + + private static PricingPolicy policy() { + return new PricingPolicy(1, 1_048_576L, 1, 1000); } @Test @@ -50,6 +72,7 @@ class InstanceControllerTest { when(billingService.forTeam(42L)).thenReturn(subscribedBilling("sub_42", 120L)); when(entitlementService.getSnapshot(42L)) .thenReturn(snapshot(EntitlementState.WARNED, 90L, 1250L)); + when(pricingPolicyService.getEffectivePolicy(42L)).thenReturn(policy()); ResponseEntity resp = controller().entitlement(token); @@ -62,6 +85,13 @@ class InstanceControllerTest { assertThat(body.periodCapUnits()).isEqualTo(1250L); // WARNED is still within budget for the gate's purposes → coarse OK. assertThat(body.state()).isEqualTo("OK"); + // Phase 2: the metering inputs the instance needs ride along. + assertThat(body.unitCalcPolicy()).isEqualTo(new UnitCalcPolicy(1, 1_048_576L, 1, 1000)); + assertThat(body.periodStart()).isNotNull(); + assertThat(body.periodEnd()).isNotNull(); + // The instance-facing read drops the cached snapshot first so a just-subscribed team's + // plan surfaces on the next poll instead of waiting out the cache TTL. + verify(entitlementService).invalidate(42L); } @Test @@ -70,6 +100,7 @@ class InstanceControllerTest { when(billingService.forTeam(7L)).thenReturn(freeBilling(500L)); when(entitlementService.getSnapshot(7L)) .thenReturn(snapshot(EntitlementState.FULL, 0L, null)); + when(pricingPolicyService.getEffectivePolicy(7L)).thenReturn(policy()); ResponseEntity resp = controller().entitlement(token); @@ -89,6 +120,7 @@ class InstanceControllerTest { when(billingService.forTeam(8L)).thenReturn(subscribedBilling("sub_8", 0L)); when(entitlementService.getSnapshot(8L)) .thenReturn(snapshot(EntitlementState.DEGRADED, 1300L, 1250L)); + when(pricingPolicyService.getEffectivePolicy(8L)).thenReturn(policy()); EntitlementResponse body = controller().entitlement(token).getBody(); @@ -110,6 +142,59 @@ class InstanceControllerTest { verifyNoInteractions(entitlementService, billingService); } + @Test + void sync_ingestsCumulativePerCategoryAndReturnsFreshEntitlement() { + Authentication token = new LinkedInstanceAuthenticationToken(4L, 99L); + LinkedInstance li = new LinkedInstance(); + li.setCreatedByUserId(7L); + LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); + when(linkedInstanceRepository.findById(4L)).thenReturn(Optional.of(li)); + when(billingService.forTeam(99L)).thenReturn(freeBilling(10L)); + // The reported periodStart is validated against the authoritative snapshot period. + when(entitlementService.getSnapshot(99L)).thenReturn(snapshotForPeriod(period, null)); + when(pricingPolicyService.getEffectivePolicy(99L)).thenReturn(policy()); + + InstanceController.UsageSyncRequest req = + new InstanceController.UsageSyncRequest( + 3L, + period, + new InstanceController.UsageSyncRequest.CategoryUnits(12, 4, 8)); + + ResponseEntity resp = controller().sync(token, req); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + @SuppressWarnings("unchecked") + ArgumentCaptor> cumulative = ArgumentCaptor.forClass(Map.class); + verify(usageIngestService) + .ingest(eq(99L), eq(7L), eq(3L), eq(period), cumulative.capture()); + assertThat(cumulative.getValue()) + .containsEntry(BillingCategory.API, 12L) + .containsEntry(BillingCategory.AI, 4L) + .containsEntry(BillingCategory.AUTOMATION, 8L); + // The sync drops the team's cached snapshot so the just-charged delta (and the free-grant + // balance it moved) show on the next wallet read instead of lagging out the 30s TTL. + verify(entitlementService).invalidate(99L); + } + + @Test + void sync_rejectsImplausiblePeriodStart() { + Authentication token = new LinkedInstanceAuthenticationToken(4L, 99L); + LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); + when(entitlementService.getSnapshot(99L)).thenReturn(snapshotForPeriod(period, null)); + + // A fabricated far-future periodStart (would reset the dedup partition) → 400, no ingest. + InstanceController.UsageSyncRequest req = + new InstanceController.UsageSyncRequest( + 1L, + period.plusYears(5), + new InstanceController.UsageSyncRequest.CategoryUnits(99, 0, 0)); + + ResponseEntity resp = controller().sync(token, req); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + verifyNoInteractions(usageIngestService); + } + @Test void revokeSelf_callsServiceWithTokenIdentityAndReturns204() { Authentication token = new LinkedInstanceAuthenticationToken(11L, 22L); @@ -187,4 +272,17 @@ class InstanceControllerTest { start.plusMonths(1), false); } + + /** Snapshot with an explicit period — the sync tests need a deterministic period window. */ + private static EntitlementSnapshot snapshotForPeriod(LocalDateTime start, Long cap) { + return new EntitlementSnapshot( + EntitlementState.FULL, + FeatureSet.FULL, + List.of(FeatureGate.OFFSITE_PROCESSING), + 0L, + cap, + start, + start.plusMonths(1), + false); + } } diff --git a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java index 8433f5e5a3..6cc97d916b 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java @@ -432,6 +432,52 @@ class PaygWalletControllerTest { verifyNoInteractions(policyRepo, entitlementService); } + // ----------------------------------------------------------------------------------------- + // POST /wallet/refresh + // ----------------------------------------------------------------------------------------- + + @Test + void refreshWallet_dropsCallerTeamCache() { + User user = userWithId(30L, UUID.randomUUID()); + Team team = teamWithId(70L); + when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user)); + when(memberRepo.findPrimaryMembership(30L)) + .thenReturn(List.of(membership(team, user, TeamRole.MEMBER))); + + ResponseEntity resp = controller.refreshWallet(jwtAuth(user.getSupabaseId())); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + // Portal pokes this after checkout so the next /wallet read reflects the subscription + // immediately rather than after the cache TTL. + verify(entitlementService).invalidate(70L); + } + + @Test + void refreshWallet_noTeam_isNoOpButOk() { + User user = userWithId(31L, UUID.randomUUID()); + when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user)); + when(memberRepo.findPrimaryMembership(31L)).thenReturn(List.of()); + + ResponseEntity resp = controller.refreshWallet(jwtAuth(user.getSupabaseId())); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + verify(entitlementService, never()).invalidate(any()); + } + + @Test + void refreshWallet_anonymousIs401() { + Authentication anon = + new AnonymousAuthenticationToken( + "k", + "anonymousUser", + List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))); + + ResponseEntity resp = controller.refreshWallet(anon); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + verifyNoInteractions(entitlementService); + } + // ----------------------------------------------------------------------------------------- // Fixtures // ----------------------------------------------------------------------------------------- diff --git a/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java b/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java index 8b3ce50b6a..e669e1b5bd 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java @@ -913,6 +913,52 @@ class JobChargeServiceTest { .isInstanceOf(IllegalArgumentException.class); } + @Test + void chargeStandalone_floorsUnitsAtMinChargeUnits() { + // Pins the per-call minChargeUnits floor that the linked-instance sync path inherits: a + // daily delta below the floor bills the floor (max(delta, minChargeUnits)) — applied per + // sync-delta here, not per underlying op (documented divergence from the in-cloud per-op + // floor; can only under-bill vs per-op, never over). + long teamId = 100L; + PricingPolicy policy = stubPolicy(/*minCharge*/ 5, Map.of(JobSource.WEB, 10)); + when(policyService.getEffectivePolicy(teamId)).thenReturn(policy); + + UUID jobId = UUID.randomUUID(); + when(jobService.open(any(JobContext.class), eq(5))).thenReturn(openJob(jobId)); + when(jobService.close(jobId)).thenReturn(openJob(jobId)); + + PaygTeamExtensions ext = new PaygTeamExtensions(); + ext.setTeamId(teamId); + ext.setStripeCustomerId("cus_x"); + ext.setPaygSubscriptionId("sub_x"); + ext.setFreeUnitsRemaining(0L); + when(teamExtRepo.findByIdForUpdate(teamId)).thenReturn(Optional.of(ext)); + when(teamExtRepo.findById(teamId)).thenReturn(Optional.of(ext)); + when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)) + .thenReturn( + Optional.of(chargedShadowRow(jobId, teamId, 5, 0, BillingCategory.API))); + + ChargeContext ctx = + new ChargeContext( + 7L, teamId, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.API); + ArgumentCaptor ledger = ArgumentCaptor.forClass(WalletLedgerEntry.class); + + withTransactionSynchronization(() -> service.chargeStandalone(ctx, 2)); + + // Delta of 2 floored to minChargeUnits=5: the job, ledger debit, and meter all use 5. + verify(jobService).open(any(JobContext.class), eq(5)); + verify(ledgerRepo).save(ledger.capture()); + assertThat(ledger.getValue().getAmountUnits()).isEqualTo(-5); + verify(meterReporter) + .recordUsage( + eq(teamId), + eq("cus_x"), + eq(5), + eq(BillingCategory.API), + eq("process:" + jobId + ":close"), + eq(jobId)); + } + private static void withTransactionSynchronization(Runnable body) { TransactionSynchronizationManager.initSynchronization(); try { diff --git a/app/saas/src/test/java/stirling/software/saas/payg/instance/InstanceUsageIngestServiceTest.java b/app/saas/src/test/java/stirling/software/saas/payg/instance/InstanceUsageIngestServiceTest.java new file mode 100644 index 0000000000..57c7b42c01 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/instance/InstanceUsageIngestServiceTest.java @@ -0,0 +1,135 @@ +package stirling.software.saas.payg.instance; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.saas.payg.charge.ChargeContext; +import stirling.software.saas.payg.charge.JobChargeService; +import stirling.software.saas.payg.model.BillingCategory; +import stirling.software.saas.payg.model.JobSource; +import stirling.software.saas.payg.repository.PaygInstanceUsageRepository; + +@ExtendWith(MockitoExtension.class) +class InstanceUsageIngestServiceTest { + + @Mock private PaygInstanceUsageRepository repo; + @Mock private JobChargeService chargeService; + + private InstanceUsageIngestService service; + private final LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); + + @BeforeEach + void setUp() { + service = new InstanceUsageIngestService(repo, chargeService); + } + + @Test + void firstSyncChargesFullCumulativeAndSavesRow() { + when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "AI")) + .thenReturn(Optional.empty()); + + service.ingest(1L, 7L, 1L, period, Map.of(BillingCategory.AI, 10L)); + + ArgumentCaptor ctx = ArgumentCaptor.forClass(ChargeContext.class); + verify(chargeService).chargeStandalone(ctx.capture(), eq(10)); + assertThat(ctx.getValue().ownerTeamId()).isEqualTo(1L); + assertThat(ctx.getValue().ownerUserId()).isEqualTo(7L); + assertThat(ctx.getValue().billingCategory()).isEqualTo(BillingCategory.AI); + assertThat(ctx.getValue().source()).isEqualTo(JobSource.LINKED_INSTANCE); + + ArgumentCaptor row = ArgumentCaptor.forClass(PaygInstanceUsage.class); + verify(repo).save(row.capture()); + assertThat(row.getValue().getLastCumulativeUnits()).isEqualTo(10L); + assertThat(row.getValue().getLastSyncSeq()).isEqualTo(1L); + } + + @Test + void secondSyncChargesOnlyDelta() { + PaygInstanceUsage existing = new PaygInstanceUsage(1L, period, "API", 10L, 1L); + when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "API")) + .thenReturn(Optional.of(existing)); + + service.ingest(1L, 7L, 2L, period, Map.of(BillingCategory.API, 25L)); + + // One charge for the aggregated delta (15), not per underlying op — pins the per-delta + // model. + verify(chargeService).chargeStandalone(any(ChargeContext.class), eq(15)); + verify(repo).save(existing); + assertThat(existing.getLastCumulativeUnits()).isEqualTo(25L); + assertThat(existing.getLastSyncSeq()).isEqualTo(2L); + } + + @Test + void replayIsIgnored() { + PaygInstanceUsage existing = new PaygInstanceUsage(1L, period, "API", 25L, 2L); + when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "API")) + .thenReturn(Optional.of(existing)); + + service.ingest(1L, 7L, 2L, period, Map.of(BillingCategory.API, 25L)); + + verify(chargeService, never()).chargeStandalone(any(), anyInt()); + verify(repo, never()).save(any()); + } + + @Test + void regressionIsRefusedAndNotAdvanced() { + PaygInstanceUsage existing = new PaygInstanceUsage(1L, period, "API", 25L, 2L); + when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "API")) + .thenReturn(Optional.of(existing)); + + service.ingest(1L, 7L, 3L, period, Map.of(BillingCategory.API, 5L)); + + verify(chargeService, never()).chargeStandalone(any(), anyInt()); + verify(repo, never()).save(any()); + } + + @Test + void zeroDeltaAdvancesSeqWithoutCharging() { + PaygInstanceUsage existing = new PaygInstanceUsage(1L, period, "API", 25L, 2L); + when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "API")) + .thenReturn(Optional.of(existing)); + + service.ingest(1L, 7L, 3L, period, Map.of(BillingCategory.API, 25L)); + + verify(chargeService, never()).chargeStandalone(any(), anyInt()); + verify(repo).save(existing); + assertThat(existing.getLastSyncSeq()).isEqualTo(3L); + } + + @Test + void billsAccruedDeltaWithoutConsultingCap() { + // Intent pin: the ingest has no cap input and always bills the accrued delta — cap + // enforcement is the request-time gate's job (the instance stops accruing at the cap), not + // this aggregate charge path's. A large valid delta is billed in full. + when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "API")) + .thenReturn(Optional.empty()); + + service.ingest(1L, 7L, 1L, period, Map.of(BillingCategory.API, 5_000_000L)); + + verify(chargeService).chargeStandalone(any(ChargeContext.class), eq(5_000_000)); + } + + @Test + void nullActorSkipsEntirely() { + service.ingest(1L, null, 1L, period, Map.of(BillingCategory.AI, 10L)); + + verifyNoInteractions(repo, chargeService); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java b/app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java new file mode 100644 index 0000000000..1676541db2 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java @@ -0,0 +1,153 @@ +package stirling.software.saas.procurement.pricing; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import stirling.software.saas.procurement.pricing.QuoteLineItem.Kind; + +/** + * Locks the D71 pricing engine to the numbers marketing publishes. The two anchor fixtures are the + * ones the demo memo foots against: acme (90M PDFs · Governed · self-hosted · dedicated · 3-yr = + * $1,752,000/yr, $5,256,000 TCV) and Northwind (6M · Governed · cloud · standard · 3-yr = + * $165,278/yr). If either moves, the engine has drifted from marketing. + */ +class ProcurementPricingServiceTest { + + private final ProcurementPricingService pricing = new ProcurementPricingService(); + + private static QuoteConfig cfg( + long volume, int intensity, String deployment, int term, String sla) { + return new QuoteConfig( + volume, 0, intensity, deployment, term, sla, false, false, false, false, "USD"); + } + + @Test + void acmeFixtureFootsExactly() { + // 90M PDFs × Governed(×4) = 360M runs → curve floors at $0.005/run → $0.0200/PDF effective. + // meter $1.8M − 5% (3-yr) = $1,710,000 + self-hosted $12K + dedicated SE/CSM $30K. + QuoteBreakdown q = pricing.price(cfg(90_000_000, 4, "selfhost", 3, "dedicated")); + + assertThat(q.annualNetMinor()).isEqualTo(175_200_000L); // $1,752,000 + assertThat(q.tcvMinor()).isEqualTo(525_600_000L); // $5,256,000 + assertThat(lineAmount(q, "support")).isEqualTo(3_000_000L); // dedicated SE/CSM $30K + assertThat(lineAmount(q, "deployment")).isEqualTo(1_200_000L); // self-hosted $12K + } + + @Test + void northwindFixtureFootsExactly() { + // 6M × Governed(×4) = 24M runs → $0.0290/PDF effective → $165,278/yr at 3-yr. + QuoteBreakdown q = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")); + + assertThat(q.annualNetMinor()).isEqualTo(16_527_800L); // $165,278 + assertThat(q.tcvMinor()).isEqualTo(49_583_400L); // × 3 years + // Cloud + standard: no deployment or support line. + assertThat(q.lineItems()).noneMatch(l -> l.key().equals("deployment")); + assertThat(q.lineItems()).noneMatch(l -> l.key().equals("support")); + } + + @Test + void rateFloorsAtHalfACent() { + // 100M × Regulated(×7) = 700M runs — deep past the knee, so the per-run rate is pinned to + // the $0.005 floor: base meter = 700M × $0.005 = $3,500,000 (1-yr, no term discount). + QuoteBreakdown q = pricing.price(cfg(100_000_000, 7, "cloud", 1, "standard")); + assertThat(lineAmount(q, "usage")).isEqualTo(350_000_000L); // $3,500,000 + assertThat(q.annualNetMinor()).isEqualTo(350_000_000L); + } + + @Test + void postureDrivesThePrice() { + // Same PDFs, three postures — the meter scales with runs, so Regulated > Governed > + // Essentials. (This is what the retired flat-per-PDF model could not express.) + long essentials = pricing.price(cfg(6_000_000, 2, "cloud", 3, "standard")).annualNetMinor(); + long governed = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor(); + long regulated = pricing.price(cfg(6_000_000, 7, "cloud", 3, "standard")).annualNetMinor(); + + assertThat(essentials).isLessThan(governed); + assertThat(governed).isLessThan(regulated); + assertThat(governed).isEqualTo(16_527_800L); // Governed is the Northwind anchor + } + + @Test + void deploymentIsAFlatFeeNotAMultiplier() { + long cloud = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor(); + long selfhost = + pricing.price(cfg(6_000_000, 4, "selfhost", 3, "standard")).annualNetMinor(); + long airgap = pricing.price(cfg(6_000_000, 4, "airgap", 3, "standard")).annualNetMinor(); + + assertThat(selfhost - cloud).isEqualTo(1_200_000L); // +$12,000 flat + assertThat(airgap - cloud).isEqualTo(3_600_000L); // +$36,000 flat + } + + @Test + void standardAndPriorityIncludedDedicatedIsFlat() { + long standard = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor(); + long priority = pricing.price(cfg(6_000_000, 4, "cloud", 3, "priority")).annualNetMinor(); + long dedicated = pricing.price(cfg(6_000_000, 4, "cloud", 3, "dedicated")).annualNetMinor(); + + assertThat(priority).isEqualTo(standard); // both included, no uplift + assertThat(dedicated - standard).isEqualTo(3_000_000L); // dedicated SE/CSM +$30,000 flat + } + + @Test + void termDiscountsTheMeterOnly() { + long oneYear = pricing.price(cfg(6_000_000, 4, "cloud", 1, "standard")).annualNetMinor(); + long twoYear = pricing.price(cfg(6_000_000, 4, "cloud", 2, "standard")).annualNetMinor(); + // 2-yr is 3% off the meter (discounted on the raw meter, then rounded to whole dollars). + assertThat(oneYear).isEqualTo(17_397_700L); // no discount + assertThat(twoYear).isEqualTo(16_875_700L); // −3% on the meter + assertThat(twoYear).isLessThan(oneYear); + } + + @Test + void indemnificationIsFivePercentOfTheMeter() { + long base = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor(); + QuoteConfig c = + new QuoteConfig( + 6_000_000, 0, 4, "cloud", 3, "standard", true, false, false, false, "USD"); + QuoteBreakdown q = pricing.price(c); + assertThat(lineAmount(q, "indemnification")).isEqualTo(Math.round(base * 0.05)); + } + + @Test + void trainingIsOneTimeOutsideTheAnnual() { + QuoteConfig withTraining = + new QuoteConfig( + 6_000_000, 0, 4, "cloud", 3, "standard", false, true, false, false, "USD"); + QuoteBreakdown q = pricing.price(withTraining); + long baseAnnual = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor(); + + assertThat(q.annualNetMinor()).isEqualTo(baseAnnual); // one-time never touches the annual + assertThat(q.tcvMinor()).isEqualTo(baseAnnual * 3 + 750_000L); // + $7,500 once + assertThat(q.lineItems()) + .anyMatch(l -> l.key().equals("training") && l.kind() == Kind.ONE_TIME); + } + + @Test + void unsetPostureDefaultsToGoverned() { + long defaulted = pricing.price(cfg(6_000_000, 0, "cloud", 3, "standard")).annualNetMinor(); + long governed = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor(); + assertThat(defaulted).isEqualTo(governed); + } + + @Test + void ssoIsAlwaysAnIncludedZeroLine() { + QuoteBreakdown q = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")); + assertThat(q.lineItems()) + .anyMatch(l -> l.key().equals("seats") && l.kind() == Kind.INCLUDED); + } + + @Test + void volumeEstimateFromSeats() { + assertThat(pricing.estimateAnnualVolume(100)).isEqualTo(201_250L); + assertThat(pricing.estimateAnnualVolume(0)).isZero(); + } + + private static long lineAmount(QuoteBreakdown q, String key) { + return q.lineItems().stream() + .filter(l -> l.key().equals(key)) + .mapToLong(QuoteLineItem::amountMinor) + .findFirst() + .orElseThrow(); + } +} diff --git a/build.gradle b/build.gradle index 3946b32449..5c89fd7af2 100644 --- a/build.gradle +++ b/build.gradle @@ -91,7 +91,7 @@ springBoot { allprojects { group = 'stirling.software' - version = '2.14.0' + version = '2.14.1' configurations.configureEach { exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat" diff --git a/devGuide/STYLELINT.md b/devGuide/STYLELINT.md index 04ddc407a1..0e279e1915 100644 --- a/devGuide/STYLELINT.md +++ b/devGuide/STYLELINT.md @@ -17,8 +17,8 @@ Apply Stylelint to your project's CSS with the following steps: ```jsonc { "scripts": { - "lint:css:check": "stylelint \"../app/core/src/main/**/*.css\" \"../app/proprietary/src/main/resources/static/css/*.css\" --config ../.stylelintrc.json", - "lint:css:fix": "stylelint \"../app/core//src/main/**/*.css\" \"../app/proprietary/src/main/resources/static/css/*.css\" --config .stylelintrc.json --fix" + "lint:css:check": "stylelint \"../app/core/src/main/**/*.css\" \"../app/proprietary/src/main/resources/static/css/*.css\" --config .stylelintrc.json", + "lint:css:fix": "stylelint \"../app/core/src/main/**/*.css\" \"../app/proprietary/src/main/resources/static/css/*.css\" --config .stylelintrc.json --fix" } } ``` diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile index 7ea8201f59..760b728f34 100644 --- a/docker/embedded/Dockerfile +++ b/docker/embedded/Dockerfile @@ -42,6 +42,9 @@ COPY . . ARG PROTOTYPES_BUILD=false ARG STIRLING_FLAVOR=proprietary ENV STIRLING_FLAVOR=${STIRLING_FLAVOR} +# Embed the admin portal app at /portal. Set true by the deploy workflow when the +# portal or AI layers change; defaults false so normal builds skip the extra app. +ARG BUILD_PORTAL=false # Bundle only the JPDFium native for this image's target arch. ARG TARGETARCH @@ -49,6 +52,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li STIRLING_FLAVOR=${STIRLING_FLAVOR} \ gradle clean build \ -PbuildWithFrontend=true \ + -PbuildWithPortal=${BUILD_PORTAL} \ -PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \ -PprototypesMode=${PROTOTYPES_BUILD} \ -x spotlessApply -x spotlessCheck -x test -x sonarqube \ diff --git a/docker/embedded/Dockerfile.fat b/docker/embedded/Dockerfile.fat index 71f2a12414..32219b6bd3 100644 --- a/docker/embedded/Dockerfile.fat +++ b/docker/embedded/Dockerfile.fat @@ -40,12 +40,15 @@ RUN gradle dependencies --no-daemon || true COPY . . +# Embed the admin portal app at /portal when the deploy workflow flags it. +ARG BUILD_PORTAL=false # Bundle only the JPDFium native for this image's target arch. ARG TARGETARCH RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \ DISABLE_ADDITIONAL_FEATURES=false \ gradle clean build \ -PbuildWithFrontend=true \ + -PbuildWithPortal=${BUILD_PORTAL} \ -PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \ -x spotlessApply -x spotlessCheck -x test -x sonarqube \ --no-daemon diff --git a/docker/embedded/Dockerfile.ultra-lite b/docker/embedded/Dockerfile.ultra-lite index 01048e864d..bdaa788785 100644 --- a/docker/embedded/Dockerfile.ultra-lite +++ b/docker/embedded/Dockerfile.ultra-lite @@ -40,12 +40,15 @@ RUN ./gradlew dependencies --no-daemon || true COPY . . # Build ultra-lite JAR with embedded frontend (minimal features). +# Embed the admin portal app at /portal when the deploy workflow flags it. +ARG BUILD_PORTAL=false # Bundle only the JPDFium native for this image's target arch. ARG TARGETARCH RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \ DISABLE_ADDITIONAL_FEATURES=true \ ./gradlew clean build \ -PbuildWithFrontend=true \ + -PbuildWithPortal=${BUILD_PORTAL} \ -PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \ -x spotlessApply -x spotlessCheck -x test -x sonarqube \ --no-daemon diff --git a/docs/type3_fallback_plan.md b/docs/type3_fallback_plan.md index 3ef7e6ad2a..f49514585a 100644 --- a/docs/type3_fallback_plan.md +++ b/docs/type3_fallback_plan.md @@ -132,7 +132,7 @@ Feel free to expand this plan or add notes as the work progresses. | Stage | Tool / Command | Output | | --- | --- | --- | -| 1. Collect PDFs | `python scripts/download_pdf_collection.py --output scripts/pdf-collection` (or drop your own PDFs anywhere) | Raw PDFs ready for harvesting | +| 1. Collect PDFs | `python scripts/download_pdf_samples.py --output-dir scripts/pdf-collection` (or drop your own PDFs anywhere) | Raw PDFs ready for harvesting | | 2. Harvest signatures | `python scripts/harvest_type3_fonts.py --input scripts/pdf-collection --pretty` | Per-PDF dumps in `docs/type3/signatures/…` + global summary `docs/type3/harvest_report.json` | | 3. Summarize backlog | `python scripts/summarize_type3_signatures.py` | `docs/type3/signature_inventory.md` (human checklist of aliases/signatures) | | 4. Convert fonts | Either copy the upstream TTF/OTF for the font (DejaVu, CM, STIX, etc.) or run `scripts/type3_to_cff.py` against the harvested glyph JSON to synthesize one offline; store the result under `app/core/src/main/resources/type3/library/fonts//`. | Canonical font binaries | diff --git a/engine/scripts/generate_tool_models.py b/engine/scripts/generate_tool_models.py index 9bc1458fea..dfe461fcca 100644 --- a/engine/scripts/generate_tool_models.py +++ b/engine/scripts/generate_tool_models.py @@ -59,6 +59,36 @@ class ToolDiscovery: "/api/v1/convert/", ) + # Endpoints under the allowed prefixes that are NOT edit-agent operations. A listed + # path and everything nested under it is dropped. Several kinds live here: + EXCLUDED_PATHS = ( + # 1. Cert-signing family: needs certificate/key files the agent can't supply, plus + # interactive session and hardware-token management. The whole subtree is dropped. + "/api/v1/security/cert-sign", + # 2. Interactive PDF text-editor endpoints, not one-shot operations. + "/api/v1/convert/pdf/text-editor", + "/api/v1/convert/text-editor/pdf", + # 3. Introspection / query endpoints that return metadata, a listing, or a + # verification verdict rather than a transformed document, so they belong to + # the question path, not the edit agent. (decompress is a dev-only stream op.) + "/api/v1/security/get-info-on-pdf", + "/api/v1/security/verify-pdf", + "/api/v1/security/validate-signature", + "/api/v1/misc/list-attachments", + "/api/v1/misc/show-javascript", + "/api/v1/misc/decompress-pdf", + "/api/v1/general/extract-bookmarks", + # 4. Require a secondary file (image, overlay PDF, attachments) on top of the input + # PDF. The agent only ever supplies the input PDF(s), so these can never run. + # (add-stamp / add-watermark stay: their text mode needs no extra file.) + "/api/v1/misc/add-image", + "/api/v1/misc/add-attachments", + "/api/v1/general/overlay-pdfs", + ) + + def _is_excluded(self, path: str) -> bool: + return any(path == p or path.startswith(p + "/") for p in self.EXCLUDED_PATHS) + def __init__(self, spec: dict[str, Any]): resource = Resource.from_contents(spec, default_specification=DRAFT202012) self.resolver = Registry().with_resource("", resource).resolver() @@ -73,17 +103,15 @@ class ToolDiscovery: for path, path_item in sorted(self.spec.get("paths", {}).items()): if "{" in path or not any(path.startswith(p) for p in self.ALLOWED_PATH_PREFIXES): continue + if self._is_excluded(path): + continue body_schema = self._get_request_body_schema(path_item) or {} query_props = self._get_query_parameters(path_item) body_props = body_schema.get("properties") or {} # Body properties win on name collision — body is the canonical param source # for the existing tools; query params are additive. properties = {**query_props, **body_props} - if not properties: - continue clean_props = self._filter_properties(properties) - if not clean_props: - continue enum_name = _deduplicate(_path_to_enum_name(path), used_enum) class_name = _deduplicate(_path_to_class_name(path), used_class) diff --git a/engine/src/stirling/agents/orchestrator.py b/engine/src/stirling/agents/orchestrator.py index c73d9dab32..e07e771e34 100644 --- a/engine/src/stirling/agents/orchestrator.py +++ b/engine/src/stirling/agents/orchestrator.py @@ -15,7 +15,6 @@ from stirling.agents.pdf_review import PdfReviewAgent from stirling.agents.user_spec import UserSpecAgent from stirling.contracts import ( AgentDraftWorkflowResponse, - ConvertMarkdownResponse, ExtractedTextArtifact, OrchestratorRequest, OrchestratorResponse, @@ -48,7 +47,7 @@ class OrchestratorAgent: ToolOutput( self.delegate_pdf_edit, name="delegate_pdf_edit", - description="Delegate requests for PDF modifications and return the PDF edit result.", + description="Delegate requests to modify or convert PDFs and return the PDF edit result.", ), ToolOutput( self.delegate_pdf_question, @@ -71,13 +70,6 @@ class OrchestratorAgent: " feedback')." ), ), - ToolOutput( - self.delegate_pdf_ingest, - name="delegate_pdf_ingest", - description=( - "Delegate requests to convert a PDF to Markdown or extract its content as readable text." - ), - ), ToolOutput( self.delegate_pdf_create, name="delegate_pdf_create", @@ -98,7 +90,7 @@ class OrchestratorAgent: system_prompt=( "You are the top-level orchestrator. " "Choose exactly one output function that best handles the request. " - "Use delegate_pdf_edit for any requested modification of one or more PDFs. " + "Use delegate_pdf_edit for any request to modify or convert one or more PDFs. " "Use delegate_pdf_question for questions about the contents of the attached PDFs. " "Use delegate_user_spec for requests to create or define an agent spec. " "Use delegate_pdf_review when the user wants the PDF returned with review" @@ -106,8 +98,6 @@ class OrchestratorAgent: " 'leave feedback on the PDF'. " "Use delegate_pdf_create when the user wants to generate a new document from" " scratch with no input file — invoices, reports, letters, contracts, etc. " - "Use delegate_pdf_ingest for any request to convert a PDF to Markdown " - "or extract its content as readable text. " "Use unsupported_capability when the user asks about the assistant itself " "or when none of the other outputs fit; supply a helpful message." ), @@ -177,13 +167,6 @@ class OrchestratorAgent: async def _run_agent_draft(self, request: OrchestratorRequest) -> AgentDraftWorkflowResponse: return await UserSpecAgent(self.runtime).orchestrate(request) - async def delegate_pdf_ingest(self, ctx: RunContext[OrchestratorDeps]) -> ConvertMarkdownResponse: - request = ctx.deps.request - return ConvertMarkdownResponse( - reason="PDF to Markdown requested — Java converts deterministically.", - files_to_ingest=request.files, - ) - async def delegate_pdf_review(self, ctx: RunContext[OrchestratorDeps]) -> PdfReviewOrchestrateResponse: return await self._run_pdf_review(ctx.deps.request) diff --git a/engine/src/stirling/contracts/__init__.py b/engine/src/stirling/contracts/__init__.py index 6c8d99d120..77ce40301e 100644 --- a/engine/src/stirling/contracts/__init__.py +++ b/engine/src/stirling/contracts/__init__.py @@ -13,7 +13,6 @@ from .common import ( AiFile, ArtifactKind, ConversationMessage, - ConvertMarkdownResponse, ExtractedFileText, GenerateFileResponse, MathAuditorToolReportArtifact, @@ -163,7 +162,6 @@ __all__ = [ "NeedContentFileRequest", "NeedContentResponse", "NeedIngestResponse", - "ConvertMarkdownResponse", "NextExecutionAction", "OrchestratorRequest", "OrchestratorResponse", diff --git a/engine/src/stirling/contracts/common.py b/engine/src/stirling/contracts/common.py index 8f35c9ceb4..d9105bad4e 100644 --- a/engine/src/stirling/contracts/common.py +++ b/engine/src/stirling/contracts/common.py @@ -62,7 +62,6 @@ class WorkflowOutcome(StrEnum): CANNOT_CONTINUE = "cannot_continue" UNSUPPORTED_CAPABILITY = "unsupported_capability" GENERATE_FILE = "generate_file" - CONVERT_MARKDOWN = "convert_markdown" class ArtifactKind(StrEnum): @@ -184,19 +183,6 @@ class NeedIngestResponse(ApiModel): content_types: list[PdfContentType] = Field(default_factory=list) -class ConvertMarkdownResponse(ApiModel): - """Terminal signal: convert the listed files to Markdown deterministically. - - This is a deterministic, non-AI conversion. Java runs the PDF→Markdown converter - (``PdfMarkdownConverter``) on each file and returns the resulting ``.md`` file(s) as a - completed result. There is no resume turn — the conversion output is the final answer. - """ - - outcome: Literal[WorkflowOutcome.CONVERT_MARKDOWN] = WorkflowOutcome.CONVERT_MARKDOWN - reason: str - files_to_ingest: list[AiFile] - - class ToolOperationStep(ApiModel): kind: Literal[StepKind.TOOL] = StepKind.TOOL tool: AnyToolId diff --git a/engine/src/stirling/contracts/orchestrator.py b/engine/src/stirling/contracts/orchestrator.py index 8b916ccaff..2d85853c51 100644 --- a/engine/src/stirling/contracts/orchestrator.py +++ b/engine/src/stirling/contracts/orchestrator.py @@ -11,7 +11,6 @@ from .common import ( AiFile, ArtifactKind, ConversationMessage, - ConvertMarkdownResponse, ExtractedFileText, GenerateFileResponse, NeedContentResponse, @@ -61,7 +60,6 @@ type OrchestratorResponse = Annotated[ | GenerateFileResponse | NeedContentResponse | NeedIngestResponse - | ConvertMarkdownResponse | AgentDraftResponse | NextExecutionAction | UnsupportedCapabilityResponse, diff --git a/engine/src/stirling/models/tool_models.py b/engine/src/stirling/models/tool_models.py index 3e179eaf0c..1827730283 100644 --- a/engine/src/stirling/models/tool_models.py +++ b/engine/src/stirling/models/tool_models.py @@ -11,13 +11,6 @@ from pydantic import Field, RootModel, SecretStr from stirling.models.base import ApiModel -class AddAttachmentsParams(ApiModel): - attachments: list[bytes] = Field(..., description="The image file to be overlaid onto the PDF.") - convert_to_pdf_a3b: bool = Field( - False, description="Convert the resulting PDF to PDF/A-3b format after adding attachments" - ) - - class AddCommentsParams(ApiModel): comments: str = Field( ..., @@ -28,12 +21,6 @@ class AddCommentsParams(ApiModel): ) -class AddImageParams(ApiModel): - every_page: bool = Field(False, description="Whether to overlay the image onto every page of the PDF.") - x: float = Field(0, description="The x-coordinate at which to place the top-left corner of the image.") - y: float = Field(0, description="The y-coordinate at which to place the top-left corner of the image.") - - class CustomMargin(StrEnum): """ Custom margin: small/medium/large/x-large @@ -107,7 +94,7 @@ class KeyLength(IntEnum): class AddPasswordParams(ApiModel): - key_length: KeyLength = Field(..., description="The length of the encryption key") + key_length: KeyLength = Field(KeyLength.integer_256, description="The length of the encryption key") owner_password: SecretStr | None = Field( None, description="The owner password to be added to the PDF file (Restricts what can be done with the document once it is opened)", @@ -312,50 +299,6 @@ class CbzToPdfParams(ApiModel): optimize_for_ebook: bool = Field(False, description="Optimize the output PDF for ebook reading using Ghostscript") -class CertType(StrEnum): - """ - The type of the digital certificate. WINDOWS_STORE and PKCS11 are hardware-backed and only available in the desktop app. - """ - - pem = "PEM" - pkcs12 = "PKCS12" - pfx = "PFX" - jks = "JKS" - server = "SERVER" - windows_store = "WINDOWS_STORE" - pkcs11 = "PKCS11" - - -class CertSignParams(ApiModel): - alias: str | None = Field( - None, - description="The alias of the certificate to sign with. Required for WINDOWS_STORE and recommended for PKCS11 tokens holding multiple certificates.", - ) - cert_type: CertType = Field( - ..., - description="The type of the digital certificate. WINDOWS_STORE and PKCS11 are hardware-backed and only available in the desktop app.", - ) - location: str = Field("SPDF", description="The location where the PDF is signed") - name: str = Field("SPDF", description="The name of the signer") - page_number: int = Field( - 1, - description="The page number where the signature should be visible. This is required if showSignature is set to true", - ) - password: SecretStr | None = Field( - None, description="The password for the keystore / private key, or the token PIN for PKCS11" - ) - pkcs11_library_path: str | None = Field( - None, - description="Absolute path to the PKCS#11 driver library (required for PKCS11 type). Must be an allowed driver - a detected one or configured via STIRLING_PKCS11_LIBRARIES.", - ) - pkcs11_slot: int | None = Field( - None, description="Optional PKCS#11 slot index. When omitted the first slot with a token is used." - ) - reason: str = Field("Signed by SPDF", description="The reason for signing the PDF") - show_logo: bool = Field(True, description="Whether to visually show a signature logo along with the signature") - show_signature: bool = Field(False, description="Whether to visually show the signature in the PDF file") - - class LineArtEdgeLevel(IntEnum): """ Edge detection strength to use for line art conversion (1-3). This maps to ImageMagick's -edge radius. @@ -525,6 +468,10 @@ class EmlToPdfParams(ApiModel): ) +class ExtractAttachmentsParams(ApiModel): + pass + + class ExtractImageScansParams(ApiModel): angle_threshold: int = Field(5, description="The angle threshold for the image scan extraction") border_size: int = Field(1, description="The border size for the image scan extraction") @@ -547,6 +494,10 @@ class ExtractImagesParams(ApiModel): format: Format = Field(Format.png, description="The output image format e.g., 'png', 'jpeg', or 'gif'") +class FileToPdfParams(ApiModel): + pass + + class FlattenParams(ApiModel): flatten_only_forms: bool = Field( False, description="True to flatten only the forms, false to flatten full PDF (Convert page to image)" @@ -602,6 +553,10 @@ class ImgToPdfParams(ApiModel): ) +class MarkdownToPdfParams(ApiModel): + pass + + class SortType(StrEnum): """ The type of sorting to be applied on the input files before merging. @@ -750,41 +705,6 @@ class OcrPdfParams(ApiModel): sidecar: bool | None = Field(None, description="Include OCR text in a sidecar text file if set to true") -class OverlayMode(StrEnum): - """ - The mode of overlaying: 'SequentialOverlay' for sequential application, 'InterleavedOverlay' for round-robin application, 'FixedRepeatOverlay' for fixed repetition based on provided counts - """ - - sequential_overlay = "SequentialOverlay" - interleaved_overlay = "InterleavedOverlay" - fixed_repeat_overlay = "FixedRepeatOverlay" - - -class OverlayPosition(Enum): - """ - Overlay position 0 is Foregound, 1 is Background - """ - - number_0 = 0 - number_1 = 1 - - -class OverlayPdfsParams(ApiModel): - counts: list[int] | None = Field( - None, - description="An array of integers specifying the number of times each corresponding overlay file should be applied in the 'FixedRepeatOverlay' mode. This should match the length of the overlayFiles array.", - ) - overlay_files: list[bytes] = Field( - ..., - description="An array of PDF files to be used as overlays on the base PDF. The order in these files is applied based on the selected mode.", - ) - overlay_mode: OverlayMode = Field( - ..., - description="The mode of overlaying: 'SequentialOverlay' for sequential application, 'InterleavedOverlay' for round-robin application, 'FixedRepeatOverlay' for fixed repetition based on provided counts", - ) - overlay_position: OverlayPosition = Field(..., description="Overlay position 0 is Foregound, 1 is Background") - - class PdfToCbrParams(ApiModel): dpi: int = Field(..., description="The DPI (Dots Per Inch) for rendering PDF pages as images", examples=[150]) @@ -841,6 +761,12 @@ class PdfToEpubParams(ApiModel): ) +class PdfToHtmlParams(ApiModel): + """ + Either upload a file or provide a server-side file ID + """ + + class ImageFormat(StrEnum): """ The output image format @@ -877,6 +803,12 @@ class PdfToImgParams(ApiModel): ) +class PdfToMarkdownParams(ApiModel): + """ + Either upload a file or provide a server-side file ID + """ + + class OutputFormat1(StrEnum): """ The output format type (PDF/A or PDF/X) @@ -912,13 +844,11 @@ class PdfToPresentationParams(ApiModel): output_format: OutputFormat2 = Field(..., description="The output Presentation format") -class PdfToTextEditorParams(ApiModel): +class PdfToSinglePageParams(ApiModel): """ Either upload a file or provide a server-side file ID """ - lightweight: bool = False - class OutputFormat3(StrEnum): """ @@ -979,10 +909,10 @@ class PdfToXlsxParams(ApiModel): ) -class Pkcs11CertificatesParams(ApiModel): - library_path: str | None = None - pin: str | None = None - slot: int | None = None +class PdfToXmlParams(ApiModel): + """ + Either upload a file or provide a server-side file ID + """ class CustomMode(StrEnum): @@ -1061,6 +991,18 @@ class RemoveBlanksParams(ApiModel): ) +class RemoveCertSignParams(ApiModel): + """ + Either upload a file or provide a server-side file ID + """ + + +class RemoveImagePdfParams(ApiModel): + """ + Either upload a file or provide a server-side file ID + """ + + class RemovePagesParams(ApiModel): page_numbers: str = Field( "all", @@ -1077,6 +1019,12 @@ class RenameAttachmentParams(ApiModel): new_name: str = Field(..., description="The new name for the attachment") +class RepairParams(ApiModel): + """ + Either upload a file or provide a server-side file ID + """ + + class HighContrastColorCombination(StrEnum): """ If HIGH_CONTRAST_COLOR option selected, then pick the default color option for text and background. @@ -1237,27 +1185,6 @@ class ScannerEffectParams(ApiModel): yellowish: bool | None = Field(None, description="Simulate yellowed paper", examples=[False]) -class WorkflowType(StrEnum): - signing = "SIGNING" - review = "REVIEW" - approval = "APPROVAL" - - -class Request(ApiModel): - document_name: str | None = None - due_date: str | None = None - message: str | None = None - owner_email: str | None = None - participant_emails: list[str] | None = None - participant_user_ids: list[int] | None = None - workflow_metadata: str | None = None - workflow_type: WorkflowType | None = None - - -class SessionsParams(ApiModel): - request: Request | None = None - - class SplitBySizeOrCountParams(ApiModel): split_type: int = Field( 0, description="Determines the type of split: 0 for size, 1 for page count, 2 for document count" @@ -1283,8 +1210,8 @@ class PageSize1(StrEnum): class SplitForPosterPrintParams(ApiModel): page_size: PageSize1 = Field(..., description="Target page size for output chunks (e.g., 'A4', 'Letter', 'A3')") right_to_left: bool = Field(False, description="Split right-to-left instead of left-to-right") - xfactor: int | None = None - yfactor: int | None = None + x_factor: int = Field(2, description="Horizontal decimation factor (how many columns to split into)", ge=1, le=10) + y_factor: int = Field(2, description="Vertical decimation factor (how many rows to split into)", ge=1, le=10) class SplitPagesParams(ApiModel): @@ -1360,6 +1287,12 @@ class TimestampPdfParams(ApiModel): ) +class UnlockPdfFormsParams(ApiModel): + """ + Either upload a file or provide a server-side file ID + """ + + class Trapped(StrEnum): """ The trapped status of the document @@ -1399,11 +1332,6 @@ class UrlToPdfParams(ApiModel): url_input: str = Field(..., description="The input URL to be converted to a PDF file") -class ValidateCertificateParams(ApiModel): - cert_type: str | None = None - password: str | None = None - - class OutputFormat6(StrEnum): """ Target vector format extension @@ -1462,20 +1390,24 @@ class Model( | CbzToPdfParams | EbookToPdfParams | EmlToPdfParams + | FileToPdfParams | HtmlToPdfParams | ImgToPdfParams + | MarkdownToPdfParams | PdfToCbrParams | PdfToCbzParams | PdfToCsvParams | PdfToEpubParams + | PdfToHtmlParams | PdfToImgParams + | PdfToMarkdownParams | PdfToPdfaParams | PdfToPresentationParams | PdfToTextParams - | PdfToTextEditorParams | PdfToVectorParams | PdfToWordParams | PdfToXlsxParams + | PdfToXmlParams | SvgToPdfParams | UrlToPdfParams | VectorToPdfParams @@ -1485,8 +1417,9 @@ class Model( | EditTextParams | MergePdfsParams | MultiPageLayoutParams - | OverlayPdfsParams + | PdfToSinglePageParams | RearrangePagesParams + | RemoveImagePdfParams | RemovePagesParams | RotatePdfParams | ScalePagesParams @@ -1495,33 +1428,31 @@ class Model( | SplitPagesParams | SplitPdfByChaptersParams | SplitPdfBySectionsParams - | AddAttachmentsParams | AddCommentsParams - | AddImageParams | AddPageNumbersParams | AddStampParams | AutoRenameParams | AutoSplitPdfParams | CompressPdfParams | DeleteAttachmentParams + | ExtractAttachmentsParams | ExtractImageScansParams | ExtractImagesParams | FlattenParams | OcrPdfParams | RemoveBlanksParams | RenameAttachmentParams + | RepairParams | ReplaceInvertPdfParams | ScannerEffectParams + | UnlockPdfFormsParams | UpdateMetadataParams | AddPasswordParams | AddWatermarkParams | AutoRedactParams - | CertSignParams - | Pkcs11CertificatesParams - | SessionsParams - | ValidateCertificateParams | RedactParams | RedactExecuteParams + | RemoveCertSignParams | RemovePasswordParams | SanitizePdfParams | TimestampPdfParams @@ -1532,20 +1463,24 @@ class Model( | CbzToPdfParams | EbookToPdfParams | EmlToPdfParams + | FileToPdfParams | HtmlToPdfParams | ImgToPdfParams + | MarkdownToPdfParams | PdfToCbrParams | PdfToCbzParams | PdfToCsvParams | PdfToEpubParams + | PdfToHtmlParams | PdfToImgParams + | PdfToMarkdownParams | PdfToPdfaParams | PdfToPresentationParams | PdfToTextParams - | PdfToTextEditorParams | PdfToVectorParams | PdfToWordParams | PdfToXlsxParams + | PdfToXmlParams | SvgToPdfParams | UrlToPdfParams | VectorToPdfParams @@ -1555,8 +1490,9 @@ class Model( | EditTextParams | MergePdfsParams | MultiPageLayoutParams - | OverlayPdfsParams + | PdfToSinglePageParams | RearrangePagesParams + | RemoveImagePdfParams | RemovePagesParams | RotatePdfParams | ScalePagesParams @@ -1565,33 +1501,31 @@ class Model( | SplitPagesParams | SplitPdfByChaptersParams | SplitPdfBySectionsParams - | AddAttachmentsParams | AddCommentsParams - | AddImageParams | AddPageNumbersParams | AddStampParams | AutoRenameParams | AutoSplitPdfParams | CompressPdfParams | DeleteAttachmentParams + | ExtractAttachmentsParams | ExtractImageScansParams | ExtractImagesParams | FlattenParams | OcrPdfParams | RemoveBlanksParams | RenameAttachmentParams + | RepairParams | ReplaceInvertPdfParams | ScannerEffectParams + | UnlockPdfFormsParams | UpdateMetadataParams | AddPasswordParams | AddWatermarkParams | AutoRedactParams - | CertSignParams - | Pkcs11CertificatesParams - | SessionsParams - | ValidateCertificateParams | RedactParams | RedactExecuteParams + | RemoveCertSignParams | RemovePasswordParams | SanitizePdfParams | TimestampPdfParams @@ -1603,20 +1537,24 @@ type ParamToolModel = ( | CbzToPdfParams | EbookToPdfParams | EmlToPdfParams + | FileToPdfParams | HtmlToPdfParams | ImgToPdfParams + | MarkdownToPdfParams | PdfToCbrParams | PdfToCbzParams | PdfToCsvParams | PdfToEpubParams + | PdfToHtmlParams | PdfToImgParams + | PdfToMarkdownParams | PdfToPdfaParams | PdfToPresentationParams | PdfToTextParams - | PdfToTextEditorParams | PdfToVectorParams | PdfToWordParams | PdfToXlsxParams + | PdfToXmlParams | SvgToPdfParams | UrlToPdfParams | VectorToPdfParams @@ -1626,8 +1564,9 @@ type ParamToolModel = ( | EditTextParams | MergePdfsParams | MultiPageLayoutParams - | OverlayPdfsParams + | PdfToSinglePageParams | RearrangePagesParams + | RemoveImagePdfParams | RemovePagesParams | RotatePdfParams | ScalePagesParams @@ -1636,33 +1575,31 @@ type ParamToolModel = ( | SplitPagesParams | SplitPdfByChaptersParams | SplitPdfBySectionsParams - | AddAttachmentsParams | AddCommentsParams - | AddImageParams | AddPageNumbersParams | AddStampParams | AutoRenameParams | AutoSplitPdfParams | CompressPdfParams | DeleteAttachmentParams + | ExtractAttachmentsParams | ExtractImageScansParams | ExtractImagesParams | FlattenParams | OcrPdfParams | RemoveBlanksParams | RenameAttachmentParams + | RepairParams | ReplaceInvertPdfParams | ScannerEffectParams + | UnlockPdfFormsParams | UpdateMetadataParams | AddPasswordParams | AddWatermarkParams | AutoRedactParams - | CertSignParams - | Pkcs11CertificatesParams - | SessionsParams - | ValidateCertificateParams | RedactParams | RedactExecuteParams + | RemoveCertSignParams | RemovePasswordParams | SanitizePdfParams | TimestampPdfParams @@ -1675,20 +1612,24 @@ class ToolEndpoint(StrEnum): CBZ_TO_PDF = "/api/v1/convert/cbz/pdf" EBOOK_TO_PDF = "/api/v1/convert/ebook/pdf" EML_TO_PDF = "/api/v1/convert/eml/pdf" + FILE_TO_PDF = "/api/v1/convert/file/pdf" HTML_TO_PDF = "/api/v1/convert/html/pdf" IMG_TO_PDF = "/api/v1/convert/img/pdf" + MARKDOWN_TO_PDF = "/api/v1/convert/markdown/pdf" PDF_TO_CBR = "/api/v1/convert/pdf/cbr" PDF_TO_CBZ = "/api/v1/convert/pdf/cbz" PDF_TO_CSV = "/api/v1/convert/pdf/csv" PDF_TO_EPUB = "/api/v1/convert/pdf/epub" + PDF_TO_HTML = "/api/v1/convert/pdf/html" PDF_TO_IMG = "/api/v1/convert/pdf/img" + PDF_TO_MARKDOWN = "/api/v1/convert/pdf/markdown" PDF_TO_PDFA = "/api/v1/convert/pdf/pdfa" PDF_TO_PRESENTATION = "/api/v1/convert/pdf/presentation" PDF_TO_TEXT = "/api/v1/convert/pdf/text" - PDF_TO_TEXT_EDITOR = "/api/v1/convert/pdf/text-editor" PDF_TO_VECTOR = "/api/v1/convert/pdf/vector" PDF_TO_WORD = "/api/v1/convert/pdf/word" PDF_TO_XLSX = "/api/v1/convert/pdf/xlsx" + PDF_TO_XML = "/api/v1/convert/pdf/xml" SVG_TO_PDF = "/api/v1/convert/svg/pdf" URL_TO_PDF = "/api/v1/convert/url/pdf" VECTOR_TO_PDF = "/api/v1/convert/vector/pdf" @@ -1698,8 +1639,9 @@ class ToolEndpoint(StrEnum): EDIT_TEXT = "/api/v1/general/edit-text" MERGE_PDFS = "/api/v1/general/merge-pdfs" MULTI_PAGE_LAYOUT = "/api/v1/general/multi-page-layout" - OVERLAY_PDFS = "/api/v1/general/overlay-pdfs" + PDF_TO_SINGLE_PAGE = "/api/v1/general/pdf-to-single-page" REARRANGE_PAGES = "/api/v1/general/rearrange-pages" + REMOVE_IMAGE_PDF = "/api/v1/general/remove-image-pdf" REMOVE_PAGES = "/api/v1/general/remove-pages" ROTATE_PDF = "/api/v1/general/rotate-pdf" SCALE_PAGES = "/api/v1/general/scale-pages" @@ -1708,33 +1650,31 @@ class ToolEndpoint(StrEnum): SPLIT_PAGES = "/api/v1/general/split-pages" SPLIT_PDF_BY_CHAPTERS = "/api/v1/general/split-pdf-by-chapters" SPLIT_PDF_BY_SECTIONS = "/api/v1/general/split-pdf-by-sections" - ADD_ATTACHMENTS = "/api/v1/misc/add-attachments" ADD_COMMENTS = "/api/v1/misc/add-comments" - ADD_IMAGE = "/api/v1/misc/add-image" ADD_PAGE_NUMBERS = "/api/v1/misc/add-page-numbers" ADD_STAMP = "/api/v1/misc/add-stamp" AUTO_RENAME = "/api/v1/misc/auto-rename" AUTO_SPLIT_PDF = "/api/v1/misc/auto-split-pdf" COMPRESS_PDF = "/api/v1/misc/compress-pdf" DELETE_ATTACHMENT = "/api/v1/misc/delete-attachment" + EXTRACT_ATTACHMENTS = "/api/v1/misc/extract-attachments" EXTRACT_IMAGE_SCANS = "/api/v1/misc/extract-image-scans" EXTRACT_IMAGES = "/api/v1/misc/extract-images" FLATTEN = "/api/v1/misc/flatten" OCR_PDF = "/api/v1/misc/ocr-pdf" REMOVE_BLANKS = "/api/v1/misc/remove-blanks" RENAME_ATTACHMENT = "/api/v1/misc/rename-attachment" + REPAIR = "/api/v1/misc/repair" REPLACE_INVERT_PDF = "/api/v1/misc/replace-invert-pdf" SCANNER_EFFECT = "/api/v1/misc/scanner-effect" + UNLOCK_PDF_FORMS = "/api/v1/misc/unlock-pdf-forms" UPDATE_METADATA = "/api/v1/misc/update-metadata" ADD_PASSWORD = "/api/v1/security/add-password" ADD_WATERMARK = "/api/v1/security/add-watermark" AUTO_REDACT = "/api/v1/security/auto-redact" - CERT_SIGN = "/api/v1/security/cert-sign" - PKCS11_CERTIFICATES = "/api/v1/security/cert-sign/hardware/pkcs11-certificates" - SESSIONS = "/api/v1/security/cert-sign/sessions" - VALIDATE_CERTIFICATE = "/api/v1/security/cert-sign/validate-certificate" REDACT = "/api/v1/security/redact" REDACT_EXECUTE = "/api/v1/security/redact-execute" + REMOVE_CERT_SIGN = "/api/v1/security/remove-cert-sign" REMOVE_PASSWORD = "/api/v1/security/remove-password" SANITIZE_PDF = "/api/v1/security/sanitize-pdf" TIMESTAMP_PDF = "/api/v1/security/timestamp-pdf" @@ -1745,20 +1685,24 @@ OPERATIONS: dict[ToolEndpoint, ParamToolModelType] = { ToolEndpoint.CBZ_TO_PDF: CbzToPdfParams, ToolEndpoint.EBOOK_TO_PDF: EbookToPdfParams, ToolEndpoint.EML_TO_PDF: EmlToPdfParams, + ToolEndpoint.FILE_TO_PDF: FileToPdfParams, ToolEndpoint.HTML_TO_PDF: HtmlToPdfParams, ToolEndpoint.IMG_TO_PDF: ImgToPdfParams, + ToolEndpoint.MARKDOWN_TO_PDF: MarkdownToPdfParams, ToolEndpoint.PDF_TO_CBR: PdfToCbrParams, ToolEndpoint.PDF_TO_CBZ: PdfToCbzParams, ToolEndpoint.PDF_TO_CSV: PdfToCsvParams, ToolEndpoint.PDF_TO_EPUB: PdfToEpubParams, + ToolEndpoint.PDF_TO_HTML: PdfToHtmlParams, ToolEndpoint.PDF_TO_IMG: PdfToImgParams, + ToolEndpoint.PDF_TO_MARKDOWN: PdfToMarkdownParams, ToolEndpoint.PDF_TO_PDFA: PdfToPdfaParams, ToolEndpoint.PDF_TO_PRESENTATION: PdfToPresentationParams, ToolEndpoint.PDF_TO_TEXT: PdfToTextParams, - ToolEndpoint.PDF_TO_TEXT_EDITOR: PdfToTextEditorParams, ToolEndpoint.PDF_TO_VECTOR: PdfToVectorParams, ToolEndpoint.PDF_TO_WORD: PdfToWordParams, ToolEndpoint.PDF_TO_XLSX: PdfToXlsxParams, + ToolEndpoint.PDF_TO_XML: PdfToXmlParams, ToolEndpoint.SVG_TO_PDF: SvgToPdfParams, ToolEndpoint.URL_TO_PDF: UrlToPdfParams, ToolEndpoint.VECTOR_TO_PDF: VectorToPdfParams, @@ -1768,8 +1712,9 @@ OPERATIONS: dict[ToolEndpoint, ParamToolModelType] = { ToolEndpoint.EDIT_TEXT: EditTextParams, ToolEndpoint.MERGE_PDFS: MergePdfsParams, ToolEndpoint.MULTI_PAGE_LAYOUT: MultiPageLayoutParams, - ToolEndpoint.OVERLAY_PDFS: OverlayPdfsParams, + ToolEndpoint.PDF_TO_SINGLE_PAGE: PdfToSinglePageParams, ToolEndpoint.REARRANGE_PAGES: RearrangePagesParams, + ToolEndpoint.REMOVE_IMAGE_PDF: RemoveImagePdfParams, ToolEndpoint.REMOVE_PAGES: RemovePagesParams, ToolEndpoint.ROTATE_PDF: RotatePdfParams, ToolEndpoint.SCALE_PAGES: ScalePagesParams, @@ -1778,33 +1723,31 @@ OPERATIONS: dict[ToolEndpoint, ParamToolModelType] = { ToolEndpoint.SPLIT_PAGES: SplitPagesParams, ToolEndpoint.SPLIT_PDF_BY_CHAPTERS: SplitPdfByChaptersParams, ToolEndpoint.SPLIT_PDF_BY_SECTIONS: SplitPdfBySectionsParams, - ToolEndpoint.ADD_ATTACHMENTS: AddAttachmentsParams, ToolEndpoint.ADD_COMMENTS: AddCommentsParams, - ToolEndpoint.ADD_IMAGE: AddImageParams, ToolEndpoint.ADD_PAGE_NUMBERS: AddPageNumbersParams, ToolEndpoint.ADD_STAMP: AddStampParams, ToolEndpoint.AUTO_RENAME: AutoRenameParams, ToolEndpoint.AUTO_SPLIT_PDF: AutoSplitPdfParams, ToolEndpoint.COMPRESS_PDF: CompressPdfParams, ToolEndpoint.DELETE_ATTACHMENT: DeleteAttachmentParams, + ToolEndpoint.EXTRACT_ATTACHMENTS: ExtractAttachmentsParams, ToolEndpoint.EXTRACT_IMAGE_SCANS: ExtractImageScansParams, ToolEndpoint.EXTRACT_IMAGES: ExtractImagesParams, ToolEndpoint.FLATTEN: FlattenParams, ToolEndpoint.OCR_PDF: OcrPdfParams, ToolEndpoint.REMOVE_BLANKS: RemoveBlanksParams, ToolEndpoint.RENAME_ATTACHMENT: RenameAttachmentParams, + ToolEndpoint.REPAIR: RepairParams, ToolEndpoint.REPLACE_INVERT_PDF: ReplaceInvertPdfParams, ToolEndpoint.SCANNER_EFFECT: ScannerEffectParams, + ToolEndpoint.UNLOCK_PDF_FORMS: UnlockPdfFormsParams, ToolEndpoint.UPDATE_METADATA: UpdateMetadataParams, ToolEndpoint.ADD_PASSWORD: AddPasswordParams, ToolEndpoint.ADD_WATERMARK: AddWatermarkParams, ToolEndpoint.AUTO_REDACT: AutoRedactParams, - ToolEndpoint.CERT_SIGN: CertSignParams, - ToolEndpoint.PKCS11_CERTIFICATES: Pkcs11CertificatesParams, - ToolEndpoint.SESSIONS: SessionsParams, - ToolEndpoint.VALIDATE_CERTIFICATE: ValidateCertificateParams, ToolEndpoint.REDACT: RedactParams, ToolEndpoint.REDACT_EXECUTE: RedactExecuteParams, + ToolEndpoint.REMOVE_CERT_SIGN: RemoveCertSignParams, ToolEndpoint.REMOVE_PASSWORD: RemovePasswordParams, ToolEndpoint.SANITIZE_PDF: SanitizePdfParams, ToolEndpoint.TIMESTAMP_PDF: TimestampPdfParams, diff --git a/frontend/.gitignore b/frontend/.gitignore index e07dce196a..0605e6e79f 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -11,6 +11,7 @@ # production /build /dist +/dist-portal /storybook-static /editor/build diff --git a/frontend/.storybook/declarations.d.ts b/frontend/.storybook/declarations.d.ts new file mode 100644 index 0000000000..ef6d741f62 --- /dev/null +++ b/frontend/.storybook/declarations.d.ts @@ -0,0 +1 @@ +declare module "*.css" {} diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index 4d4aadc8f8..8547e9092a 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -61,6 +61,11 @@ const config: StorybookConfig = { config.define = { ...(config.define ?? {}), "import.meta.env.VITE_SAAS_API_URL": JSON.stringify("http://saas.mock"), + // Keep the Supabase auth env empty so ensureSaasSupabase() is a no-op and + // never replaces the mock SaaS client stubbed in preview.tsx. + "import.meta.env.VITE_SUPABASE_URL": JSON.stringify(""), + "import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY": + JSON.stringify(""), }; return config; }, diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index d086613f44..76463001fe 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -2,12 +2,11 @@ // the decorators below transpiles to React.createElement and needs React in // scope. (The app + story files use the automatic runtime via the portal vite // config; this import is specifically for the preview config file.) -import React, { useEffect } from "react"; +import React, { Suspense, useEffect } from "react"; import type { Decorator, Preview } from "@storybook/react-vite"; import { initialize, mswLoader } from "msw-storybook-addon"; import { MemoryRouter } from "react-router-dom"; import { withThemeByDataAttribute } from "@storybook/addon-themes"; -import { MantineProvider } from "@mantine/core"; // Reference React so the import isn't dropped as unused by the bundler — the // classic runtime needs it present even though it's not named in the JSX. @@ -15,9 +14,9 @@ void React; import { TierProvider, type Tier } from "@portal/contexts/TierContext"; import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext"; -import { ThemeProvider } from "@portal/contexts/ThemeContext"; +import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext"; import { UIProvider } from "@portal/contexts/UIContext"; -import { mantineTheme } from "@portal/theme/mantineTheme"; +import { SuiProvider } from "@portal/theme/SuiProvider"; import { handlers } from "@portal/mocks/handlers"; import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient"; @@ -30,10 +29,10 @@ initialize({ onUnhandledRequest: "bypass" }, handlers); // Storybook-only: stub a SaaS session so apiClient.saas reads (invoices, payment // method, wallet) clear the session check and reach the MSW handlers instead of -// failing with "No SaaS session". VITE_SAAS_SUPABASE_URL/KEY are intentionally -// unset, so ensureSaasSupabase() is a no-op and never replaces this client; only -// VITE_SAAS_API_URL (a mock origin MSW matches) is configured — injected via -// .storybook/main.ts's viteFinal define, not a frontend/.env file. +// failing with "No SaaS session". VITE_SUPABASE_URL/KEY are defined empty (see +// .storybook/main.ts), so ensureSaasSupabase() is a no-op and never replaces this +// client; only VITE_SAAS_API_URL (a mock origin MSW matches) is configured — +// injected via .storybook/main.ts's viteFinal define, not a frontend/.env file. const saasStub = configureSupabase({ url: "http://saas.mock", key: "storybook-anon-key", @@ -79,13 +78,21 @@ function TierKey({ ); } -/** Keeps useTheme() and the data-theme attribute in sync. */ -function ThemeWatcher() { +/** + * Makes the Storybook toolbar the SINGLE source of truth for the theme. + */ +function ThemeBridge({ + theme, + children, +}: { + theme: "light" | "dark"; + children: React.ReactNode; +}) { + const { setTheme } = useTheme(); useEffect(() => { - // The addon-themes decorator already sets data-theme on . - // We just read it on mount so ThemeProvider picks it up. - }, []); - return null; + setTheme(theme); + }, [theme, setTheme]); + return <>{children}; } const withProviders: Decorator = (Story, context) => { @@ -102,18 +109,21 @@ const withProviders: Decorator = (Story, context) => { return ( - - {/* LinkProvider must wrap TierProvider: TierContext derives its tier - from useLink() (matches App.tsx's nesting). */} - - - - - - - - - + + + {/* LinkProvider must wrap TierProvider: TierContext derives its tier + from useLink() (matches App.tsx's nesting). */} + + + + + + + + + + + ); diff --git a/frontend/.storybook/tsconfig.json b/frontend/.storybook/tsconfig.json new file mode 100644 index 0000000000..9615d34a22 --- /dev/null +++ b/frontend/.storybook/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "es2022", + "jsx": "react-jsx", + "module": "esnext", + "moduleResolution": "bundler", + "paths": { + "@app/*": [ + "../editor/src/desktop/*", + "../editor/src/proprietary/*", + "../editor/src/core/*" + ], + "@core/*": ["../editor/src/core/*"], + "@proprietary/*": ["../editor/src/proprietary/*"], + "@portal/*": ["../editor/src/portal/*"] + }, + "resolveJsonModule": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["./**/*"] +} diff --git a/frontend/editor/.env b/frontend/editor/.env index f8f3c2e308..264a01e952 100644 --- a/frontend/editor/.env +++ b/frontend/editor/.env @@ -7,6 +7,10 @@ # API base URL — use / for same-origin (default for web builds) VITE_API_BASE_URL=/ +# Include the admin portal's lazy route/chunk in the build (set true by +# -PbuildWithPortal in the JAR). Off by default; always on in dev. +VITE_INCLUDE_PORTAL=false + # Google Drive integration VITE_GOOGLE_DRIVE_CLIENT_ID= VITE_GOOGLE_DRIVE_API_KEY= @@ -22,3 +26,6 @@ VITE_STRIPE_PUBLISHABLE_KEY=pk_live_51Q56W2P9mY5IAnSnp3kcxG50uyFMLuhM4fFs774DAP3 # PostHog analytics VITE_PUBLIC_POSTHOG_KEY=phc_VOdeYnlevc2T63m3myFGjeBlRcIusRgmhfx6XL5a1iz VITE_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com + +# Calendly scheduling link (portal "Schedule a call") +VITE_CALENDLY_URL=https://calendly.com/d/cm4p-zz5-yy8/stirling-pdf-15-minute-group-discussion diff --git a/frontend/editor/.env.proprietary b/frontend/editor/.env.proprietary index ed5498ba3a..0ee7e66bc9 100644 --- a/frontend/editor/.env.proprietary +++ b/frontend/editor/.env.proprietary @@ -18,13 +18,6 @@ VITE_EDITOR_URL=/ # backend. VITE_PORTAL_MOCKS= -# Hosted SaaS Supabase project for the self-hosted portal's IN-APP account -# linking (both values are public). Set per deploy; absent -> the account-link -# UI shows a "configure" state. For local e2e, point these at the SaaS Supabase -# project the local backend links against. -VITE_SAAS_SUPABASE_URL= -VITE_SAAS_SUPABASE_ANON_KEY= - # Hosted SaaS Java backend base URL (e.g. https://api.stirlingpdf.com). Used for # ATTENDED portal -> SaaS reads (wallet, billing, plans, checkout) with the # admin's Supabase JWT. Distinct from the local backend (reached same-origin via diff --git a/frontend/editor/playwright.config.ts b/frontend/editor/playwright.config.ts index 7a049e2632..93e6572392 100644 --- a/frontend/editor/playwright.config.ts +++ b/frontend/editor/playwright.config.ts @@ -30,7 +30,18 @@ export default defineConfig({ forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : "50%", - reporter: [["html", { open: "never" }], ["list"]], + // In CI, add a JSON report alongside the HTML/list output so the workflow + // can flag flaky tests (passed only on retry) as warnings without failing + // the job. Path is pinned via PLAYWRIGHT_JSON_OUTPUT_FILE in the workflow; + // the outputFile here is just a sane default. Omitted locally to keep dev + // runs' terminal output clean. + reporter: process.env.CI + ? [ + ["html", { open: "never" }], + ["list"], + ["json", { outputFile: "playwright-report/results.json" }], + ] + : [["html", { open: "never" }], ["list"]], timeout: 60_000, expect: { timeout: 10_000 }, diff --git a/frontend/editor/postcss.config.js b/frontend/editor/postcss.config.js index 7b8895cce8..5bb7b5d6ef 100644 --- a/frontend/editor/postcss.config.js +++ b/frontend/editor/postcss.config.js @@ -1,3 +1,6 @@ -module.exports = { - plugins: [require("@tailwindcss/postcss"), require("autoprefixer")], +import tailwindcssPostcss from "@tailwindcss/postcss"; +import autoprefixer from "autoprefixer"; + +export default { + plugins: [tailwindcssPostcss, autoprefixer], }; diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index d07d8379da..e81f5eaebb 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7,6 +7,7 @@ black = "Black" blue = "Blue" cancel = "Cancel" chooseFile = "Choose File" +clear = "Clear" close = "Close" comingSoon = "Coming soon" confirm = "Confirm" @@ -87,6 +88,7 @@ processingCompleteMultiple = "{{count}} files are ready." property = "Property" quickPosition = "Quick Position" red = "Red" +remove = "Remove" reset = "Reset" review = "Review" save = "Save" @@ -180,6 +182,7 @@ addMoreFiles = "Add more files..." attachments = "Select Attachments" info = "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel." placeholder = "Choose files..." +removeFile = "Remove file" selectedFiles = "Selected Files" submit = "Add Attachments" @@ -1628,6 +1631,9 @@ title = "Do you want to help make Stirling PDF better?" tags = "annotate,highlight,draw,markup,comment,notes,review,redline,feedback,markup tools,sticky notes,shapes,arrows,text box,freehand" [annotation] +alignCenter = "Align center" +alignLeft = "Align left" +alignRight = "Align right" annotationStyle = "Annotation style" backgroundColor = "Background color" borderOff = "Border: Off" @@ -2531,6 +2537,7 @@ title = "Rule of thumb" [certSign.source] device = "This device" +noOtherSources = "No other certificate sources are available." server = "Server" stepTitle = "Certificate source" upload = "Upload" @@ -3595,7 +3602,9 @@ makeCopy = "Make a copy" mobileShort = "Mobile" mobileUpload = "Mobile Upload" mobileUploadNotAvailable = "Mobile upload not enabled" +moreOptions = "More options" myFiles = "My Files" +nextFile = "Next file" noFiles = "No files available" noFilesFound = "No files found matching your search" noRecentFiles = "No recent files found" @@ -3605,6 +3614,7 @@ openInFileEditor = "Open in File Editor" openInPageEditor = "Open in Page Editor" owner = "Owner" ownerUnknown = "Unknown" +previousFile = "Previous file" recent = "Recent" removeBoth = "Remove from both" removeFilePrompt = "This file is saved on this device and on your server. Where would you like to remove it from?" @@ -4614,10 +4624,10 @@ welcomeTitle = "You've been invited!" [landing] addFiles = "Add Files" heroSubtitle = "Drop in or add an existing PDF to get started." -heroTitle = "Stirling PDF" mobileUpload = "Upload from Mobile" openFromComputer = "Open from computer" uploadFromComputer = "Upload from computer" +workbenchEmptyStateHero = "Drop a PDF anywhere" [language] direction = "ltr" @@ -4930,6 +4940,7 @@ title = "Output" [onboarding] activeFiles = "The Active Files view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process." allTools = "This is the Tools panel, where you can browse and select from all available PDF tools." +close = "Close" cropSettings = "Now that we've selected the file we want crop, we can configure the Crop tool to choose the area that we want to crop the PDF to." fileCheckbox = "Clicking one of the files selects it for processing. You can select multiple files for batch operations." fileReplacement = "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools." @@ -4955,6 +4966,7 @@ skipTheTour = "Skip the tour" [onboarding.desktopInstall] body = "Stirling works best as a desktop app. You can use it offline, access documents faster, and make edits locally on your computer." +selectOs = "Select operating system" title = "Download" titleWithOs = "Download for {{osLabel}}" @@ -5566,7 +5578,9 @@ viewLabel = "PDF Editor" [pdfTextEditor.actions] applyChanges = "Apply Changes" +clearText = "Clear text" downloadCopy = "Download Copy" +moreOptions = "More options" reset = "Reset Changes" [pdfTextEditor.badges] @@ -6048,6 +6062,17 @@ stepOf = "Step {{step}} of {{total}}" toolChainDesc = "Configure the tools this policy runs on each document." typesSelected = "{{count}} types selected" +[policy] +badgeEnforcing = "{{name}} enforcing..." +badgeRan = "{{name}} policy ran on this file" +blockingAction = "{{action}} blocked while enforcing policy, please wait..." +dismiss = "Dismiss overlay" +enforcingTitle = "Enforcing policy..." +viewAnyway = "View file (policy still enforcing)" + +[portal] +comingSoon = "Coming soon" + [portal.accountLink.card] billingNote = "Unattended processing bills against your org wallet." eyebrow = "Account link" @@ -6229,9 +6254,19 @@ noClientSecret = "Edge function returned no client_secret." subtitle = "Add a card to keep going past your free Editor-plan grant. Stripe handles the rest." title = "Turn on the Processor plan" +[portal.billing.checkout.activationSlow] +body = "Your payment succeeded, but activation is taking a little longer than usual. It'll switch on automatically - close this and it'll appear here shortly." +close = "Close" +title = "Almost there" + [portal.billing.checkout.error] title = "Couldn't start checkout" +[portal.billing.checkout.finalizing] +body = "Your payment went through. We're switching on metered processing across your linked instances - this usually takes a few seconds." +hint = "Please keep this window open." +title = "Activating your Processor plan..." + [portal.billing.checkout.notConfigured] bodyAfter = "in the portal env to enable in-app checkout." bodyBefore = "Set" @@ -6249,7 +6284,6 @@ cost = "Cost" editorsDeployed = "Editors deployed" inviteTeammates = "Invite teammates" pdfsEdited = "PDFs edited" -previewBadge = "Preview · sample data" subtitle = "Deploy anywhere, for your whole team." title = "Free PDF Editors" @@ -6545,7 +6579,9 @@ beforeHelper = "header against your signing secret before trusting a payload. SD beforeSignature = "Verify the" [portal.documents] -subtitle = "Review and approve documents moving through your pipelines." +exportCsv = "Export CSV" +search = "Search by filename, ID..." +subtitle = "Every document your org has processed, with its full processing record. Content access is request-gated." title = "Documents" [portal.documents.audit] @@ -6583,42 +6619,38 @@ value = "Value" [portal.documents.filters] all = "All" -archived = "Archived" ariaLabel = "Filter documents by status" -needsReview = "Needs review" +flagged = "Flagged" +inReview = "In review" processed = "Processed" [portal.documents.overview] -confidence = "Confidence" -fieldsExtracted = "Fields extracted" -received = "Received" -source = "Source" +action = "Pipeline / Action" +product = "Product" +received = "Time" status = "Status" type = "Type" +user = "User" [portal.documents.queue.empty] description = "As sources feed documents into your pipelines they'll appear here for review." title = "No documents in the queue" -[portal.documents.summary] -avgConfidence = "Avg confidence" -inQueue = "In queue" -needsReview = "Needs review" -processedToday = "Processed today" - [portal.documents.table] +auto = "Auto" +editorAction = "Editor" empty = "No documents match this filter." +rowActions = "Row actions" sensitiveLabel = "Sensitive" sensitiveTitle = "Sensitive — access required" [portal.documents.table.columns] -confidence = "Confidence" -fields = "Fields" -name = "Name" -source = "Source" +action = "Pipeline / Action" +document = "Document" +product = "Product" status = "Status" time = "Time" -type = "Type" +user = "User" [portal.editorAdmin] subtitle = "Deploy the Stirling PDF Editor, pair self-hosted instances to your org, and operate the running fleet — targets, health, credentials, and offline activation in one place." @@ -6715,6 +6747,23 @@ ready = "Ready to deploy" description = "Once documents are processed, your 30-day usage appears here." title = "No usage yet" +[portal.home.editor] +activeUsers = "{{n}} active" +invite = "Invite teammates" +name = "Stirling PDF Editor" +open = "Open in browser" +updated = "updated {{time}}" + +[portal.home.editor.target] +cloud = "Managed Cloud" +docker = "Self-hosted · Docker" +kubernetes = "Self-hosted · Kubernetes" + +[portal.home.greeting] +afternoon = "Good afternoon" +evening = "Good evening" +morning = "Good morning" + [portal.home.kpis.enterprise] docs30d = "Docs / 30d" evalPassRate = "Eval pass rate" @@ -6735,15 +6784,31 @@ evalPassRate = "Eval pass rate" pipelines = "Pipelines" [portal.home.onboarding] -progress = "{{done}} / {{total}} done" -runAgain = "Run again" -start = "Start" -subtitle = "Four steps to a production-shaped Stirling project." -title = "Get to value" +dismiss = "Dismiss setup" +notStarted = "Not started" +progress = "{{done}} of {{total}} done" +title = "Finish setting up" -[portal.home.onboarding.empty] -description = "Onboarding tasks will appear here once your workspace is set up." -title = "No onboarding steps yet" +[portal.home.onboarding.enterprise] +body = "Org-wide SSO + SCIM + RBAC, committed volume pricing, and air-gapped deployment." +cta = "Start Trial" +ctaQuote = "Get Quote" +lead = "For 250+ employees." +tag = "Enterprise" + +[portal.home.onboarding.steps.editor] +blurb = "Deploy the desktop app, free forever." +title = "Download the PDF Editor" + +[portal.home.onboarding.steps.policies] +blurb = "{{active}} active · {{recommended}} recommended" +chip = "{{active}} active" +title = "Turn on policies" + +[portal.home.onboarding.steps.sources] +blurb = "{{connected}} connected · every PDF governed where it lands" +chip = "{{connected}} connected" +title = "Connect your sources" [portal.home.productGrid] ariaLabel = "Process PDFs at scale" @@ -6830,13 +6895,32 @@ actor = "Actor" event = "Event" latency = "Latency" status = "Status" -target = "Target" +target = "Resource" timestamp = "Timestamp" [portal.infrastructure.audit.empty] description = "Workspace activity will appear here as it happens." title = "No audit events" +[portal.infrastructure.audit.export] +cancel = "Cancel" +error = "Export failed. Please try again." +exportButton = "Export" +exporting = "Exporting…" +fieldsLegend = "Columns" +format = "Format" +open = "Export" +subtitle = "Download audit events as CSV or JSON. Choose which columns to include." +title = "Export audit log" + +[portal.infrastructure.audit.export.fields] +date = "Date" +documentName = "Document name" +ipAddress = "IP address" +outcome = "Outcome" +tool = "Tool" +username = "Username" + [portal.infrastructure.audit.filters] all = "All" auth = "Auth" @@ -6845,6 +6929,10 @@ elevation = "Elevation" processing = "Processing" security = "Security" +[portal.infrastructure.audit.forbidden] +description = "The audit log is available to team leads and admins. Ask your team lead or an admin for access." +title = "You don't have access" + [portal.infrastructure.audit.metrics] config = "Config" elevation = "Elevation" @@ -7173,18 +7261,39 @@ title = "Pipelines" [portal.pipelines.actions] newPipeline = "New pipeline" +[portal.pipelines.builder] +addStep = "Add tool" +back = "Back to pipelines" +discard = "Discard changes" +enabled = "Enabled" +keepEditing = "Keep editing" +needsUpload = "Needs an uploaded file" +noToolMatches = "No tools match your search." +pipelineSettings = "Pipeline settings" +searchTools = "Search tools" +selectToolBody = "Add a tool to build your pipeline." +selectToolTitle = "No tools yet" +toolSettings = "Tool settings" +unknownStep = "Unrecognized operation, kept as-is." +unsavedBody = "You have unsaved changes. Save them before leaving, or discard them?" +unsavedTitle = "Unsaved changes" +uploadUnsupported = "Uploaded files aren't supported in pipelines yet, so these steps can't be saved: {{tools}}." +usesDefaults = "Runs with default settings" + [portal.pipelines.composer] +addTool = "Add tool" cancel = "Cancel" -chainEmpty = "Add operations from the palette below." +chainEmpty = "Add a tool to start building your pipeline." create = "Create pipeline" directory = "Output folder" directoryHelp = "Absolute path on the server. Must be within the configured allowed folders." -editTitle = "Edit pipeline" +editingUnsupported = "Displaying these tool params for editing is not supported yet." moveDown = "Move down" moveUp = "Move up" name = "Name" namePlaceholder = "e.g. Redaction sweep" noSources = "No sources connected yet. The pipeline can still run on files supplied to it directly." +noToolSettings = "This tool has no configurable settings." operations_one = "Operation ({{count}})" operations_other = "Operations ({{count}})" output = "Output" @@ -7193,8 +7302,6 @@ save = "Save changes" scheduleEvery = "Run every" sources = "Sources" sourcesLoading = "Loading sources..." -subtitle = "Pick the sources it runs over, chain the operations, then choose when it runs and where output goes." -title = "New pipeline" trigger = "Trigger" triggerManual = "Manual only" @@ -7210,18 +7317,8 @@ confirm = "Delete" title = "Delete pipeline?" [portal.pipelines.detail] -closeAriaLabel = "Close detail" delete = "Delete pipeline" -edit = "Edit" -noSources = "No sources. Files are supplied directly to each run." -noSteps = "No operations configured." -output = "Output" -pause = "Pause" -resume = "Resume" run = "Run now" -sources = "Sources" -steps = "Operations" -subtitle = "{{trigger}} · {{status}}" [portal.pipelines.empty] action = "Create a pipeline" @@ -7243,6 +7340,7 @@ completed_other = "All {{count}} runs completed." empty = "Nothing to run: the sources had no documents to process." failed = "Run failed: {{error}}" running = "Run started; still in progress." +timeout = "Run is taking longer than expected; it may still finish in the background." [portal.pipelines.status] active = "Active" @@ -7420,7 +7518,7 @@ upgrade = "Upgrade" volumeSuffix = "PDFs processed · last 30 days" [portal.procurement] -enterpriseBadge = "Enterprise" +reset = "Reset procurement (demo)" subtitle = "Get your team evaluated, contracted, and onboarded. Every document in one place." title = "Procurement" @@ -7431,6 +7529,65 @@ request = "Request" sign = "Review & sign" upload = "Upload" +[portal.procurement.agreement] +agreeCta = "Agree & subscribe" +confirm = "I have read and agree to the Stirling Enterprise Agreement." +eyebrow = "Agreement" +intro = "One combined agreement covers your deal: Master Service Agreement, Order Form, EULA, and Data Processing Agreement. Review it, then agree to accept the quote into a committed subscription." +title = "Review your enterprise agreement" + +[portal.procurement.builder] +addons = "Add-ons" +back = "Back" +businessName = "Business name" +businessNamePlaceholder = "Your company" +continue = "Continue" +country = "Country" +countryEuro = "Eurozone (EUR €)" +countryUK = "United Kingdom (GBP £)" +countryUS = "United States (USD $)" +eula = "I have read and agree to the Stirling Enterprise EULA. It governs the agreement generated from this quote." +generate = "Generate quote" +included = "Included" +indemnification = "IP indemnification" +indemnificationSub = "We defend qualifying IP claims, per the EULA" +offlineLicense = "Offline / air-gapped licence" +offlineLicenseSub = "A downloadable licence file for an air-gapped self-hosted instance" +qbr = "Quarterly business reviews" +qbrSub = "Your SE reviews usage and roadmap each quarter" +running = "{{annual}} / yr · {{years}}-yr {{tcv}}" +s1Sub = "Your team, and the PDFs you expect to run each year." +# Step 1 — volume +s1Title = "How much will you process?" +s2Sub = "Longer terms discount the rate; your service level sets support." +# Step 2 — commitment & service +s2Title = "Commitment and service" +s3Sub = "For the quote and the agreement it generates." +# Step 3 — details +s3Title = "Your details" +serviceLevel = "Service level" +slDedicated = "Dedicated" +slDedicatedSub = "4 business hours · dedicated account manager · +30%" +slPriority = "Priority" +slPrioritySub = "Same business day · named CSM · +15%" +slStandard = "Standard" +slStandardSub = "Next business day · shared CSM · included" +stepOf = "Step {{n}} of {{total}}" +term = "Term" +termDiscount = "{{pct}}% multi-year commitment discount applied" +title = "Build your quote" +training = "Onboarding & training" +trainingSub = "Live sessions to get your team running" +users = "Total users" +usersPlaceholder = "e.g. 250" +volEstimated = "Estimated from {{count}} users (~2,000 PDFs each, including automation). Edit if you know better." +volManual = "Using your figure. Re-estimate from your team size any time." +volNoUsers = "Not sure? Enter your team size and we'll estimate it." +volume = "Annual PDF volume" +volumePlaceholder = "e.g. 1,000,000" +years_one = "{{count}} year" +years_other = "{{count}} years" + [portal.procurement.docs] count_one = "{{count}} doc" count_other = "{{count}} docs" @@ -7446,6 +7603,30 @@ supportingTitle = "Supporting your evaluation" title = "Documents" upcoming = "Upcoming" +[portal.procurement.error] +title = "Something went wrong" + +[portal.procurement.hero] +company = "Your enterprise deal" +ctaAgreement = "Review & sign agreement" +ctaLive = "You're live" +ctaPayment = "Add payment" +ctaQuote = "Review your quote" +ctaTrial = "Build your quote" +eyebrow = "Enterprise procurement" +inviteTeammates = "Invite teammates" +keyDocs = "Key documents" +nextStep = "Next step: {{action}}" +notStarted = "Not started" +open = "Open procurement" +scheduleCall = "Schedule a call" +setup1Sub = "Invite your teammates" +setup1Title = "Deploy the PDF Editor" +setup2Sub = "Turn on processing across editors and other sources" +setup2Title = "Connect the PDF Processor" +setup3Sub = "Turn on Security, Compliance, Routing, or Retention when you need them" +setup3Title = "Add recommended policies" + [portal.procurement.journey] daysLeft_one = "{{count}} day left" daysLeft_other = "{{count}} days left" @@ -7457,15 +7638,47 @@ subtitle = "Your solutions engineer is on every step. One next action at a time; title = "From trial to live, one guided path" trialTitle = "Enterprise trial" +[portal.procurement.license] +copied = "Copied" +copy = "Copy key" +downloadError = "Could not generate the offline licence file just yet — please try again in a moment." +downloadOffline = "Download offline licence (.lic)" +hint = "Paste this key into a self-hosted instance to activate it, or keep it for your records. Keep it safe." +label = "Your licence key" + +[portal.procurement.link] +cta = "Link account" +description = "Procurement runs on your linked Stirling account: it's how we provision the trial, price your quote, and start billing. Link an account to start." +eyebrow = "Enterprise" +title = "Link your account to begin" + +[portal.procurement.live] +description = "Your subscription is active and your licence is issued. Your team is provisioned and billing has started." +eyebrow = "Live" +title = "You're live on Stirling Enterprise" + [portal.procurement.locked] description = "Trial keys, committed-volume quotes, the one-signature agreement, payment, and your document ledger all live here once you start an enterprise evaluation." eyebrow = "Enterprise only" talkToSales = "Talk to sales" title = "The procurement track opens with Enterprise" +[portal.procurement.milestone] +accept = "Accept & continue" +description = "Download the PDF to share it with your team, come back to accept when you're ready, or make changes." +download = "Download PDF" +downloadError = "Could not download the quote PDF just yet — please try again in a moment." +edit = "Edit quote" +eyebrow = "Quote {{number}}" +perYear = " / yr" +preparedFor = "Prepared for {{company}}" +tcv = "{{value}} total contract value" +title = "Your quote is ready" + [portal.procurement.modal] cancel = "Cancel" chooseFile = "Choose file" +close = "Close" downloadBody = "Your download will begin shortly." downloadCta = "Download" downloadTitle = "Download" @@ -7484,6 +7697,19 @@ uploadBody = "Send us your PO and we invoice against it on your terms. Drag in t uploadCta = "Upload purchase order" uploadTitle = "Upload your purchase order" +[portal.procurement.payment] +description = "Your quote is accepted and your licence is already active — your team can start right away. Pay the first invoice when you're ready; you can pay or download it here, no email needed." +downloadInvoice = "Download invoice" +simulate = "Simulate payment received (demo)" +title = "Subscription created" +viewInvoice = "View & pay invoice" + +[portal.procurement.schedule] +fallback = "Couldn't load the scheduler." +fallbackLink = "Open scheduling in a new tab" +subtitle = "Your solutions engineer will walk your team through the rollout. Pick a time that suits you." +title = "Schedule a call" + [portal.procurement.status] action = "Action needed" available = "Available" @@ -7491,6 +7717,21 @@ complete = "Complete" pending = "Pending" request = "On request" +[portal.procurement.trial] +body = "Extending adds 7 days and notifies your solutions engineer." +bodyMaxed = "You have used all your extensions — talk to your solutions engineer if you need more time." +cancel = "Cancel trial" +extend = "Extend 7 days" +maxed = "Maxed out" +subtitle = "Your free trial runs through {{date}}. No card required." +title = "Enterprise trial" + +[portal.procurement.upsell] +homeBadge = "Enterprise" +homeBody = "Committed volume pricing, org-wide SSO + SCIM + RBAC, 90-day immutable audit, and a dedicated SE." +homeCta = "Start Trial →" +homeHeadline = "Process millions of PDFs." + [portal.recentActivity] title = "Recent activity" viewAll = "View all" @@ -7746,10 +7987,6 @@ loadWallet = "Couldn't load wallet" openStripePortal = "Couldn't open Stripe portal" walletUnavailable = "Wallet unavailable: {{status}} {{statusText}}" -[portal.usage.finalizing] -body = "It can take a few seconds for your subscription to activate. This page updates automatically." -title = "Finalizing your subscription…" - [portal.usage.sessionExpired] action = "Sign in again" body = "Your Stirling account session has expired. Sign in again to view billing — your instance stays linked." @@ -7868,36 +8105,14 @@ status = "Status" suspend = "Suspend" [portal.welcome] -ariaLabel = "Stirling product highlights" -pagination = "Carousel pagination" -slideLabel = "Slide {{number}}: {{title}}" - -[portal.welcome.ornament.editor] -critical = "Critical" -ocrClean = "OCR-clean" -schemaMatch = "schema match 0.97" -signed = "signed" - -[portal.welcome.slides.agents] -eyebrow = "AI Agents" -primary = "Try PDF Processor" -secondary = "View MCP docs" -sub = "Wire your agent via MCP, REST or tool definitions. Deterministic operations and guardrails — test with scenarios and evals before you ship." -title = "PDF Processor for AI Agents" - -[portal.welcome.slides.editor] -eyebrow = "PDF Editor" -primary = "Install PDF Editor" -secondary = "Connect an instance" -sub = "Annotate, sign, redact, and review locally or in the cloud. Brought to the platform as the credibility anchor of the Stirling control plane." -title = "The #1 PDF Editor on GitHub" - -[portal.welcome.slides.platform] -eyebrow = "Platform" -primary = "Try a PDF operation" -secondary = "Get an API key" -sub = "Ingest from agents, APIs and connectors. Run composable pipelines with evals and golden sets. Land in a vault with zero-standing-access controls." -title = "PDF Infrastructure for Developers" +ariaLabel = "Welcome to Stirling PDF" +badge = "Open-source" +installEditor = "Install the Editor" +inviteTeammates = "Invite teammates" +perks = "Free forever · Self-hostable" +subtitle = "The world's most secure PDF Editor is free for teams of all sizes. Includes 60+ PDF operations and SSO." +title = "Welcome to" +titleAccent = "Stirling PDF" [printFile] title = "Print File" @@ -8785,6 +9000,21 @@ manage = "Manage" description = "Policies and legal information for this service." title = "Legal Documents" +[settings.licenses] +backendDescription = "Licenses for backend dependencies bundled with this server." +backendLabel = "Backend Licenses" +backendTitle = "Backend 3rd Party Licenses" +empty = "No dependencies found." +frontendDescription = "Licenses for frontend dependencies bundled into the release build." +frontendLabel = "Frontend Licenses" +frontendTitle = "Frontend 3rd Party Licenses" +license = "License" +listDescription = "The list is shown directly in the UI from the release bundle or backend endpoint." +listTitle = "Bundled dependencies" +loadError = "Failed to load third-party licenses" +module = "Module" +version = "Version" + [settings.licensingAnalytics] audit = "Audit" plan = "Plan" @@ -9537,7 +9767,9 @@ memberRemoved = "Member removed successfully" namePlaceholder = "Enter team name" personal = "Personal" removeError = "Failed to remove member" +renameCancel = "Cancel rename" renameError = "Failed to rename team" +renameSubmit = "Save team name" renameSuccess = "Team renamed successfully" [team.invitationBanner] @@ -9553,6 +9785,7 @@ sendButton = "Send Invitation" title = "Invite Team Member" [team.members] +actions = "Member actions" emailColumn = "Email" empty = "No team members yet" nameColumn = "Name" @@ -9908,14 +10141,23 @@ zoomOut = "Zoom Out" [viewer.attachments] addAttachment = "Add attachment" close = "Close attachments" +closeSidebar = "Close attachments sidebar" +download = "Download attachment" empty = "No attachments in this document" loading = "Loading attachments..." noDocument = "Open a PDF to view its attachments." noMatch = "No attachments match your search" noSupport = "Attachment support is unavailable for this viewer." +retry = "Retry" searchPlaceholder = "Search attachments" title = "Attachments" +[viewer.bookmarks] +bookmarkTitle = "Bookmark title" +closeSidebar = "Close bookmarks sidebar" +collapseAll = "Collapse all bookmarks" +expandAll = "Expand all bookmarks" + [viewer.comments] addComment = "Add comment" addCommentPlaceholder = "Add comment..." @@ -9926,6 +10168,7 @@ clearAll = "Clear all comments" clearAllDescription = "This removes comments and replies from the sidebar while keeping any attached annotations in the document." clearAllTitle = "Clear all comments?" close = "Close comments" +closeSidebar = "Close comments sidebar" deleteAnnotationAndComment = "Delete annotation & comment" deleteDescription = "This annotation has a comment attached. You can remove just the comment from the sidebar while keeping the annotation, or delete everything." deleteTitle = "Remove annotation from comments?" @@ -9957,6 +10200,11 @@ title = "Form Fields" unsavedBadge = "Unsaved" unsavedDesc = "You have unsaved changes" +[viewer.layers] +closeSidebar = "Close layers sidebar" +hideAll = "Hide all layers" +showAll = "Show all layers" + [viewer.link] delete = "Delete link" @@ -9987,6 +10235,9 @@ resultsOf = "of {{total}}" [viewer.signature] delete = "Delete signature" +[viewer.thumbnails] +closeSidebar = "Close thumbnails sidebar" + [viewPdf] tags = "view,read,annotate,text,image,highlight,edit" title = "View/Edit PDF" @@ -10413,6 +10664,7 @@ loading = "Loading people..." locked = "locked" lockedBadge = "Locked" loginRequired = "Enable login mode first" +memberActions = "Member actions" noMembersFound = "No members found" role = "Role" searchMembers = "Search members..." @@ -10422,6 +10674,7 @@ unlockAccount = "Unlock Account" unlockUserError = "Failed to unlock user account" unlockUserSuccess = "User account unlocked successfully" user = "User" +userInfo = "User info" [workspace.people.actions] upgrade = "Upgrade" @@ -10567,6 +10820,7 @@ removeMemberError = "Failed to remove user from team" removeMemberSuccess = "User removed from team" renameTeamLabel = "Rename Team" system = "System" +teamActions = "Team actions" teamName = "Team Name" teamNotFound = "Team not found" title = "Teams" diff --git a/frontend/editor/public/og-metadata.json b/frontend/editor/public/og-metadata.json index e8aded59d2..65f1da789c 100644 --- a/frontend/editor/public/og-metadata.json +++ b/frontend/editor/public/og-metadata.json @@ -485,6 +485,16 @@ "title": "Legal Settings - Stirling PDF", "description": "The Free Adobe Acrobat alternative (10M+ Downloads)" }, + "/settings/backendThirdPartyLicenses": { + "image": "/og_images/home.png", + "title": "Backend Third Party Licenses Settings - Stirling PDF", + "description": "The Free Adobe Acrobat alternative (10M+ Downloads)" + }, + "/settings/frontendThirdPartyLicenses": { + "image": "/og_images/home.png", + "title": "Frontend Third Party Licenses Settings - Stirling PDF", + "description": "The Free Adobe Acrobat alternative (10M+ Downloads)" + }, "/settings/payg": { "image": "/og_images/home.png", "title": "Payg Settings - Stirling PDF", @@ -641,6 +651,8 @@ "/settings/adminMcp": "/settings/adminMcp", "/settings/help": "/settings/help", "/settings/legal": "/settings/legal", + "/settings/backendThirdPartyLicenses": "/settings/backendThirdPartyLicenses", + "/settings/frontendThirdPartyLicenses": "/settings/frontendThirdPartyLicenses", "/settings/payg": "/settings/payg" } } diff --git a/frontend/editor/public/og_images/auto-split-by-size-count.png b/frontend/editor/public/og_images/auto-split-by-size-count.png deleted file mode 100644 index 59c7ed77ca..0000000000 Binary files a/frontend/editor/public/og_images/auto-split-by-size-count.png and /dev/null differ diff --git a/frontend/editor/public/og_images/auto-split-pages.png b/frontend/editor/public/og_images/auto-split-pages.png deleted file mode 100644 index 6929078e7c..0000000000 Binary files a/frontend/editor/public/og_images/auto-split-pages.png and /dev/null differ diff --git a/frontend/editor/public/og_images/manage-certificates.png b/frontend/editor/public/og_images/manage-certificates.png deleted file mode 100644 index da02e0847d..0000000000 Binary files a/frontend/editor/public/og_images/manage-certificates.png and /dev/null differ diff --git a/frontend/editor/public/og_images/split-by-chapters.png b/frontend/editor/public/og_images/split-by-chapters.png deleted file mode 100644 index 26db04b4cf..0000000000 Binary files a/frontend/editor/public/og_images/split-by-chapters.png and /dev/null differ diff --git a/frontend/editor/public/og_images/split-by-sections.png b/frontend/editor/public/og_images/split-by-sections.png deleted file mode 100644 index e3601ddda3..0000000000 Binary files a/frontend/editor/public/og_images/split-by-sections.png and /dev/null differ diff --git a/frontend/editor/public/og_images/splitPdf.png b/frontend/editor/public/og_images/splitPdf.png deleted file mode 100644 index a77a065b42..0000000000 Binary files a/frontend/editor/public/og_images/splitPdf.png and /dev/null differ diff --git a/frontend/editor/public/og_images/view-pdf.png b/frontend/editor/public/og_images/view-pdf.png deleted file mode 100644 index bef62ad513..0000000000 Binary files a/frontend/editor/public/og_images/view-pdf.png and /dev/null differ diff --git a/frontend/editor/scripts/generate-icons.js b/frontend/editor/scripts/generate-icons.js index 7e6da2740e..70b4a091d2 100644 --- a/frontend/editor/scripts/generate-icons.js +++ b/frontend/editor/scripts/generate-icons.js @@ -1,8 +1,8 @@ #!/usr/bin/env node -const { icons } = require("@iconify-json/material-symbols"); -const fs = require("fs"); -const path = require("path"); +import { icons } from "@iconify-json/material-symbols"; +import fs from "node:fs"; +import path from "node:path"; // Check for verbose flag const isVerbose = @@ -19,7 +19,7 @@ const debug = (message) => { // Function to scan codebase for LocalIcon usage function scanForUsedIcons() { const usedIcons = new Set(); - const srcDir = path.join(__dirname, "..", "src"); + const srcDir = path.join(import.meta.dirname, "..", "src"); info("🔍 Scanning codebase for LocalIcon usage..."); @@ -140,7 +140,7 @@ async function main() { // Check if we need to regenerate (compare with existing) const outputPath = path.join( - __dirname, + import.meta.dirname, "..", "src", "assets", @@ -200,7 +200,7 @@ async function main() { } // Create output directory - const outputDir = path.join(__dirname, "..", "src", "assets"); + const outputDir = path.join(import.meta.dirname, "..", "src", "assets"); if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } diff --git a/frontend/editor/scripts/generate-licenses.js b/frontend/editor/scripts/generate-licenses.js index a82ec9a2bd..72da16c6a9 100644 --- a/frontend/editor/scripts/generate-licenses.js +++ b/frontend/editor/scripts/generate-licenses.js @@ -1,28 +1,21 @@ #!/usr/bin/env node -const { execSync } = require("node:child_process"); -const { - existsSync, - mkdirSync, - writeFileSync, - readFileSync, -} = require("node:fs"); -const path = require("node:path"); +import { execSync } from "node:child_process"; +import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { argv } from "node:process"; -const { argv } = require("node:process"); const inputIdx = argv.indexOf("--input"); const INPUT_FILE = inputIdx > -1 ? argv[inputIdx + 1] : null; const POSTPROCESS_ONLY = !!INPUT_FILE; -// __dirname is available in CommonJS by default - /** * Generate 3rd party licenses for frontend dependencies * This script creates a JSON file similar to the Java backend's 3rdPartyLicenses.json */ const OUTPUT_FILE = path.join( - __dirname, + import.meta.dirname, "..", "src", "assets", @@ -30,7 +23,7 @@ const OUTPUT_FILE = path.join( ); // package.json lives at the workspace root (frontend/), not editor/. The // script is at frontend/editor/scripts/, so walk up two levels. -const PACKAGE_JSON = path.join(__dirname, "..", "..", "package.json"); +const PACKAGE_JSON = path.join(import.meta.dirname, "..", "..", "package.json"); // Ensure the output directory exists const outputDir = path.dirname(OUTPUT_FILE); @@ -192,7 +185,7 @@ try { // Write license warnings to a separate file for CI/CD const warningsFile = path.join( - __dirname, + import.meta.dirname, "..", "src", "assets", diff --git a/frontend/editor/scripts/generate-og-image.mjs b/frontend/editor/scripts/generate-og-image.mjs index 7cef32c9b0..cf617bf7e8 100644 --- a/frontend/editor/scripts/generate-og-image.mjs +++ b/frontend/editor/scripts/generate-og-image.mjs @@ -16,11 +16,10 @@ /* global document, getComputedStyle */ // used inside page.evaluate (browser context) import fs from "node:fs/promises"; +import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { createRequire } from "node:module"; -const require = createRequire(import.meta.url); const HERE = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(HERE, ".."); @@ -69,11 +68,14 @@ export const THEME = { }; // ---- icon resolution (material-symbols via iconify) ------------------------ -function resolveIcon(icon) { +async function resolveIcon(icon) { if (!icon) return ""; if (icon.trim().startsWith(" let _browser = null; async function getBrowser() { if (_browser) return _browser; - const puppeteer = require("puppeteer"); + const { default: puppeteer } = await import("puppeteer"); _browser = await puppeteer.launch({ headless: "new", args: ["--no-sandbox"], @@ -163,7 +165,7 @@ export async function renderOgCard({ outFile, theme = THEME, }) { - const iconSvg = resolveIcon(icon); + const iconSvg = await resolveIcon(icon); const html = await buildHtml({ name, description, iconSvg, theme }); const browser = await getBrowser(); const page = await browser.newPage(); @@ -230,7 +232,7 @@ const kebab = (id) => id.replace(/([A-Z])/g, "-$1").toLowerCase(); // English name/description live next to each tool as the `t(key, fallback)` default. function readRegistryStrings() { - const src = require("node:fs").readFileSync( + const src = readFileSync( path.join(ROOT, "src/core/data/useTranslatedToolRegistry.tsx"), "utf8", ); @@ -268,7 +270,7 @@ export async function generateMissing(theme = THEME) { // Each tool's app icon lives as `icon=""` just before its // `name: t("home..title", …)`. Pair each title with the closest preceding icon. function readRegistryIcons() { - const src = require("node:fs").readFileSync( + const src = readFileSync( path.join(ROOT, "src/core/data/useTranslatedToolRegistry.tsx"), "utf8", ); @@ -288,25 +290,26 @@ function readRegistryIcons() { return byId; } -function iconExists(name) { +async function iconExists(name) { if (!name) return false; try { - const { getIconData } = require("@iconify/utils"); - return !!getIconData( - require("@iconify-json/material-symbols/icons.json"), - name, + const { getIconData } = await import("@iconify/utils"); + const { default: set } = await import( + "@iconify-json/material-symbols/icons.json", + { with: { type: "json" } } ); + return !!getIconData(set, name); } catch { return false; } } // First candidate that resolves; also tries dropping a "-rounded" suffix. -function firstResolvableIcon(candidates) { +async function firstResolvableIcon(candidates) { for (const c of candidates) { - if (iconExists(c)) return c; + if (await iconExists(c)) return c; const alt = c && c.replace(/-rounded$/, ""); - if (alt && alt !== c && iconExists(alt)) return alt; + if (alt && alt !== c && (await iconExists(alt))) return alt; } return "description-outline"; } @@ -324,14 +327,14 @@ export async function generateAll(theme = THEME) { const { titles, descs } = readRegistryStrings(); const regIcons = readRegistryIcons(); const ogMap = JSON.parse( - require("node:fs").readFileSync( - path.join(ROOT, "src/core/data/ogImageMap.json"), - "utf8", - ), + readFileSync(path.join(ROOT, "src/core/data/ogImageMap.json"), "utf8"), ); const results = []; for (const [id, basename] of Object.entries(ogMap)) { - const icon = firstResolvableIcon([regIcons[id], MISSING_TOOL_ICONS[id]]); + const icon = await firstResolvableIcon([ + regIcons[id], + MISSING_TOOL_ICONS[id], + ]); await renderOgCard({ name: titles[id] || humanizeId(id), description: descs[id] || "", diff --git a/frontend/editor/scripts/generate-tool-api-types.mts b/frontend/editor/scripts/generate-tool-api-types.mts new file mode 100644 index 0000000000..f12572620a --- /dev/null +++ b/frontend/editor/scripts/generate-tool-api-types.mts @@ -0,0 +1,400 @@ +/** + * Generates the committed frontend tool API types (toolApiTypes.ts) from the + * Java backend's OpenAPI spec, so the frontend's request shapes stay in step + * with the backend. + */ + +import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { parseArgs } from "node:util"; +import { compile, type JSONSchema } from "json-schema-to-typescript"; +import * as prettier from "prettier"; + +// The API namespaces whose endpoints are real, callable tools. `/api/v1/filter/` +// (pipeline-only) and `/api/v1/ai/tools/` (not in the spec) are intentionally +// excluded. Extend this list when other namespaces become tools. +const ALLOWED_PATH_PREFIXES = [ + "/api/v1/general/", + "/api/v1/misc/", + "/api/v1/security/", + "/api/v1/convert/", +]; + +// File plumbing, not user parameters: `fileInput` is the uploaded document and +// `fileId` a server-side handle. Stripped from every generated request model. +// Named file fields (stampImage, attachments, ...) are real parameters and kept. +const BASE_FILE_FIELDS = new Set(["fileInput", "fileId"]); + +// The shared "upload a file or provide a file ID" wrapper schema and its two +// branches. An endpoint whose body is exactly this has no parameters, so it must +// resolve to an empty model. It needs separate handling because the wrapper is a +// `oneOf`, which survives the flat-field stripping above and would otherwise leak +// the file fields into the output. +const FILE_WRAPPER_COMPONENTS = new Set([ + "PDFFile", + "PDFFileUpload", + "PDFFileRef", +]); + +const COMPONENT_REF_PREFIX = "#/components/schemas/"; + +const FILE_HEADER = [ + "// AUTO-GENERATED FILE. DO NOT EDIT.", + "// Generated by editor/scripts/generate-tool-api-types.mts from the Java OpenAPI spec", + "// (SwaggerDoc.json). Regenerate with: task frontend:tool-models", + "// Tools that take only a file input have no parameters; their model is Record.", +].join("\n"); + +type Json = Record; + +interface DiscoveredTool { + path: string; + className: string; +} + +function isObject(value: unknown): value is Json { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Recursively sort object keys so the output is byte-stable regardless of the + * key ordering springdoc happens to emit. + */ +function deepSortKeys(value: unknown): unknown { + if (Array.isArray(value)) return value.map(deepSortKeys); + if (isObject(value)) { + const sorted: Json = {}; + for (const key of Object.keys(value).sort()) { + sorted[key] = deepSortKeys(value[key]); + } + return sorted; + } + return value; +} + +function pascalCase(segment: string): string { + return segment + .split(/[-_/]/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(""); +} + +/** Fallback class name for an inline request body (no $ref to name it after). */ +function pathToClassName(path: string): string { + const relevant = path.replace(/^\/api\/v1\//, ""); + return `${pascalCase(relevant)}Request`; +} + +function dedupe(name: string, used: Set): string { + let candidate = name; + let n = 2; + while (used.has(candidate)) candidate = `${name}${n++}`; + used.add(candidate); + return candidate; +} + +/** The request body schema for a POST endpoint (multipart wins, then JSON), or null. */ +function requestBodySchema(pathItem: Json): Json | null { + const post = pathItem.post; + if (!isObject(post)) return null; + const requestBody = post.requestBody; + if (!isObject(requestBody)) return null; + const content = requestBody.content; + if (!isObject(content)) return null; + for (const mediaType of ["multipart/form-data", "application/json"]) { + const entry = content[mediaType]; + if (isObject(entry) && isObject(entry.schema)) return entry.schema; + } + return null; +} + +/** + * A POST endpoint's query parameters as a property map plus the required ones. + * Some tools take inputs on the query string alongside the multipart body (e.g. + * merge-pdfs' `fileOrder`), so a complete model has to fold them in. Ref-valued + * param schemas are inlined later by rewriteRefs. + */ +function queryParameters(pathItem: Json): { props: Json; required: string[] } { + const props: Json = {}; + const required: string[] = []; + const post = pathItem.post; + if (!isObject(post) || !Array.isArray(post.parameters)) + return { props, required }; + for (const param of post.parameters) { + if ( + !isObject(param) || + param.in !== "query" || + typeof param.name !== "string" + ) + continue; + if (!isObject(param.schema)) continue; + const schema = structuredClone(param.schema) as Json; + if (!("description" in schema) && typeof param.description === "string") { + schema.description = param.description; + } + props[param.name] = schema; + if (param.required === true) required.push(param.name); + } + return { props, required }; +} + +/** + * Rewrite every `#/components/schemas/X` ref to `#/definitions/X` in place (the + * form json-schema-to-typescript expects) and collect the referenced component + * names so the caller can inline them. + */ +function rewriteRefs(node: unknown, found: Set): void { + if (Array.isArray(node)) { + for (const item of node) rewriteRefs(item, found); + return; + } + if (!isObject(node)) return; + const ref = node.$ref; + if (typeof ref === "string" && ref.startsWith(COMPONENT_REF_PREFIX)) { + const name = ref.slice(COMPONENT_REF_PREFIX.length); + node.$ref = `#/definitions/${name}`; + found.add(name); + } + for (const value of Object.values(node)) rewriteRefs(value, found); +} + +/** Keep only fields the client must send: drop those with a default or already stripped. */ +function computeRequired(schema: Json, properties: Json): string[] { + const required = Array.isArray(schema.required) + ? (schema.required as string[]) + : []; + return required.filter((name) => { + const prop = properties[name]; + return name in properties && !(isObject(prop) && "default" in prop); + }); +} + +async function main(): Promise { + const { values } = parseArgs({ + options: { + spec: { type: "string" }, + output: { type: "string" }, + check: { type: "boolean", default: false }, + }, + }); + if (!values.spec || !values.output) { + throw new Error( + "Usage: generate-tool-api-types.mts --spec --output [--check]", + ); + } + const specPath = resolve(values.spec); + const outputPath = resolve(values.output); + + const spec = JSON.parse(readFileSync(specPath, "utf-8")) as Json; + const paths = isObject(spec.paths) ? spec.paths : {}; + const components = + isObject(spec.components) && isObject(spec.components.schemas) + ? spec.components.schemas + : {}; + + const tools: DiscoveredTool[] = []; + const definitions: Record = {}; + const usedClassNames = new Set(); + const pendingComponents = new Set(); + const skipped: string[] = []; + + for (const path of Object.keys(paths).sort()) { + if ( + path.includes("{") || + !ALLOWED_PATH_PREFIXES.some((p) => path.startsWith(p)) + ) + continue; + const pathItem = paths[path]; + if (!isObject(pathItem)) continue; + const bodySchema = requestBodySchema(pathItem); + if (!bodySchema) { + if (isObject(pathItem.post)) skipped.push(path); + continue; + } + + // Resolve the request model into a fresh, mutable clone so we never mutate the shared spec. + const ref = bodySchema.$ref; + const refComponent = + typeof ref === "string" && ref.startsWith(COMPONENT_REF_PREFIX) + ? ref.slice(COMPONENT_REF_PREFIX.length) + : null; + let className: string; + let modelSchema: Json; + if (refComponent && FILE_WRAPPER_COMPONENTS.has(refComponent)) { + // File-only endpoint: model it as an empty object so it becomes + // Record rather than the wrapper's file union. Named after + // the path since the wrapper schema is shared. Query params still fold in + // below. + className = pathToClassName(path); + modelSchema = { type: "object", properties: {} }; + } else if (refComponent) { + const component = components[refComponent]; + if (!isObject(component)) continue; + className = refComponent; + modelSchema = structuredClone(component) as Json; + } else { + className = pathToClassName(path); + modelSchema = structuredClone(bodySchema) as Json; + } + + // A component shared by several endpoints (e.g. GeneralFile) is only defined once. + if (!(className in definitions)) { + const uniqueName = dedupe(className, usedClassNames); + className = uniqueName; + const bodyProps: Json = isObject(modelSchema.properties) + ? (structuredClone(modelSchema.properties) as Json) + : {}; + const query = queryParameters(pathItem); + // Body wins over query on a name collision. + const properties: Json = { ...query.props, ...bodyProps }; + for (const field of BASE_FILE_FIELDS) delete properties[field]; + modelSchema.properties = properties; + const required = new Set(computeRequired(modelSchema, properties)); + for (const name of query.required) { + const prop = properties[name]; + if (name in properties && !(isObject(prop) && "default" in prop)) { + required.add(name); + } + } + if (required.size > 0) modelSchema.required = [...required]; + else delete modelSchema.required; + modelSchema.title = className; + rewriteRefs(modelSchema, pendingComponents); + definitions[className] = modelSchema; + } + + tools.push({ path, className }); + } + + // Transitively inline every referenced component into `definitions`, rewriting its refs too. + const queue = [...pendingComponents]; + while (queue.length > 0) { + const name = queue.pop() as string; + if (name in definitions) continue; + const component = components[name]; + if (!isObject(component)) continue; + const cloned = structuredClone(component) as Json; + cloned.title = name; + const nested = new Set(); + rewriteRefs(cloned, nested); + definitions[name] = cloned; + for (const next of nested) if (!(next in definitions)) queue.push(next); + } + + await compileAndWrite( + tools, + definitions, + outputPath, + values.check ?? false, + skipped, + ); +} + +async function compileAndWrite( + tools: DiscoveredTool[], + definitions: Record, + outputPath: string, + check: boolean, + skipped: string[], +): Promise { + // json-schema-to-typescript only emits a named, exported interface per schema + // if something references it, so wrap every model in one root object. The root + // interface itself is stripped from the output afterwards. + const rootName = "__ToolApiRootAutogen"; + const uniqueClassNames = [...new Set(tools.map((t) => t.className))]; + const rootSchema: JSONSchema = { + title: rootName, + type: "object", + additionalProperties: false, + properties: Object.fromEntries( + uniqueClassNames.map((name) => [name, { $ref: `#/definitions/${name}` }]), + ), + definitions: definitions as Record, + }; + + // Canonicalize key order so a reordering in SwaggerDoc.json can never change + // the generated file (which would flake the committed-types CI check). + const canonicalRoot = deepSortKeys(rootSchema) as JSONSchema; + + const compiled = await compile(canonicalRoot, rootName, { + bannerComment: "", + additionalProperties: false, + declareExternallyReferenced: true, + unreachableDefinitions: false, + strictIndexSignatures: true, + format: false, + }); + + // Drop the root wrapper interface, then rewrite empty models (file-only tools) + // to `Record` - the precise, lint-clean type for an object with + // no properties (json-schema-to-typescript always emits `{}` interfaces here). + const models = compiled + .replace(new RegExp(`export interface ${rootName} \\{[^}]*\\}`), "") + .replace( + /export interface (\w+) \{\s*\}/g, + "export type $1 = Record;", + ) + .trim(); + + const endpointUnion = tools + .map((t) => ` | ${JSON.stringify(t.path)}`) + .join("\n"); + const paramsEntries = tools + .map((t) => ` ${JSON.stringify(t.path)}: ${t.className};`) + .join("\n"); + const endpointList = tools + .map((t) => ` ${JSON.stringify(t.path)},`) + .join("\n"); + + const footer = [ + "/** Endpoint path for a generated tool operation (the operation identity across languages). */", + `export type ToolEndpoint =\n${endpointUnion};`, + "", + "/** Backend request-parameter model for each tool endpoint. */", + `export interface ToolApiParams {\n${paramsEntries}\n}`, + "", + "/** Every generated tool endpoint, for iteration. */", + `export const TOOL_ENDPOINTS = [\n${endpointList}\n] as const satisfies readonly ToolEndpoint[];`, + "", + "/** Union of every generated tool request model. */", + `export type ToolApiRequest = ToolApiParams[ToolEndpoint];`, + ].join("\n"); + + const body = `${FILE_HEADER}\n\n${models}\n\n${footer}\n`; + const prettierConfig = await prettier.resolveConfig(outputPath); + const formatted = await prettier.format(body, { + ...prettierConfig, + parser: "typescript", + }); + + if (check) { + let current = ""; + try { + current = readFileSync(outputPath, "utf-8"); + } catch { + // Missing file counts as out of date. + } + if (current !== formatted) { + throw new Error( + `${outputPath} is out of date. Run 'task frontend:tool-models' and commit the result.`, + ); + } + console.log(`Up to date: ${tools.length} tool endpoints.`); + return; + } + + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, formatted, "utf-8"); + console.log(`Generated ${tools.length} tool endpoints -> ${outputPath}`); + if (skipped.length > 0) { + console.log( + `Skipped ${skipped.length} POST endpoint(s) with no request body: ${skipped.join(", ")}`, + ); + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/frontend/editor/scripts/report-flaky-tests.mts b/frontend/editor/scripts/report-flaky-tests.mts new file mode 100644 index 0000000000..1b1039b3ae --- /dev/null +++ b/frontend/editor/scripts/report-flaky-tests.mts @@ -0,0 +1,127 @@ +// Reads a Playwright JSON report and surfaces "flaky" tests (tests that +// failed at least once, then passed on retry) in GitHub Actions WITHOUT +// failing the job: +// - emits one ::warning:: workflow command per flaky test, so the run and +// PR show a yellow warning triangle + count, and the annotation links to +// the test's source line +// - appends a summary table to the job summary ($GITHUB_STEP_SUMMARY) +// +// A green-but-flaky job is otherwise invisible (Playwright exits 0 once a +// retry passes), which lets flakes accrete unnoticed. This makes them visible +// without turning them into hard failures. +// +// Run: `npx tsx editor/scripts/report-flaky-tests.mts [more.json...]` +// (a single path is also read from PLAYWRIGHT_JSON_OUTPUT_FILE). Multiple +// reports are merged + de-duplicated, so a job that runs Playwright in +// several segments (e.g. the enterprise OAuth/SAML/license phases) can +// pass one report per phase. A missing report or zero flaky tests is a +// silent no-op, so it is safe to run with `if: always()` after any +// Playwright step. + +import { appendFileSync, existsSync, readFileSync } from "fs"; +import { isAbsolute, join, relative } from "path"; +import type { JSONReport, JSONReportSuite } from "@playwright/test/reporter"; + +interface FlakyTest { + file: string; + line: number; + title: string; +} + +// Playwright records each test's outcome as expected|unexpected|flaky|skipped. +// "flaky" means it needed a retry to pass, which is exactly what we surface. +function collectFlaky( + report: JSONReport, + workspace: string, + rootDir: string, +): FlakyTest[] { + const flaky: FlakyTest[] = []; + const walk = (suite: JSONReportSuite, trail: string[], depth: number) => { + // The outermost suite per file has title === the file path; skip it so the + // human-readable title is just "describe > test" (the path is shown + // separately as the location). Nested suites are the describe() blocks. + const titles = depth > 0 && suite.title ? [...trail, suite.title] : trail; + for (const spec of suite.specs ?? []) { + if ((spec.tests ?? []).some((t) => t.status === "flaky")) { + const abs = spec.file + ? isAbsolute(spec.file) + ? spec.file + : join(rootDir, spec.file) + : ""; + const rel = abs ? relative(workspace, abs) : ""; + flaky.push({ + // Drop the path from the annotation if it escapes the workspace, so + // we never emit a broken file= link (the warning still shows). + file: rel && !rel.startsWith("..") ? rel : "", + line: spec.line || 0, + title: [...titles, spec.title].filter(Boolean).join(" > "), + }); + } + } + for (const child of suite.suites ?? []) walk(child, titles, depth + 1); + }; + for (const suite of report.suites ?? []) walk(suite, [], 0); + return flaky; +} + +// Deliberately no process.exit() calls: every path falls through to a natural +// exit(0). This step must never fail the job, and it keeps CI green even when +// the report is missing or clean. +function main(): void { + // Accept one or more report paths: a job may run Playwright in several + // segments, each writing its own report (the enterprise job does this for + // OAuth / SAML / license phases). Fall back to the env var when no paths are + // passed. Missing files are skipped, not fatal. + const reportPaths = process.argv.slice(2); + const envPath = process.env.PLAYWRIGHT_JSON_OUTPUT_FILE; + if (reportPaths.length === 0 && envPath) { + reportPaths.push(envPath); + } + + const workspace = process.env.GITHUB_WORKSPACE || process.cwd(); + const seen = new Set(); + const flaky: FlakyTest[] = []; + for (const reportPath of reportPaths) { + if (!reportPath || !existsSync(reportPath)) { + // No report (e.g. the build failed before this segment ran). + continue; + } + const report = JSON.parse(readFileSync(reportPath, "utf8")) as JSONReport; + const rootDir = report.config?.rootDir || process.cwd(); + for (const test of collectFlaky(report, workspace, rootDir)) { + const key = `${test.file}:${test.line}:${test.title}`; + if (!seen.has(key)) { + seen.add(key); + flaky.push(test); + } + } + } + if (flaky.length === 0) { + return; + } + + for (const f of flaky) { + const loc = f.file ? `file=${f.file},line=${f.line},` : ""; + process.stdout.write( + `::warning ${loc}title=Flaky test::${f.title} passed only on retry\n`, + ); + } + + const summaryPath = process.env.GITHUB_STEP_SUMMARY; + if (summaryPath) { + const plural = flaky.length === 1 ? "" : "s"; + const lines = [ + `### :warning: ${flaky.length} flaky test${plural} (passed on retry)`, + "", + "These passed, but not on the first attempt. Worth fixing before they turn into hard failures.", + "", + "| Test | Location |", + "| --- | --- |", + ...flaky.map((f) => `| ${f.title} | \`${f.file || "?"}:${f.line}\` |`), + "", + ]; + appendFileSync(summaryPath, lines.join("\n") + "\n"); + } +} + +main(); diff --git a/frontend/editor/src-tauri/stirling-pdf.desktop b/frontend/editor/src-tauri/stirling-pdf.desktop index e9e26a39c1..13da1b88c6 100644 --- a/frontend/editor/src-tauri/stirling-pdf.desktop +++ b/frontend/editor/src-tauri/stirling-pdf.desktop @@ -10,7 +10,8 @@ Terminal=false MimeType=application/pdf; Categories=Office;Graphics;Utility; Actions=open-file; +StartupWMClass=Stirling-PDF [Desktop Action open-file] Name=Open PDF File -Exec={{exec}} %F \ No newline at end of file +Exec={{exec}} %F diff --git a/frontend/editor/src-tauri/tauri.conf.json b/frontend/editor/src-tauri/tauri.conf.json index 87f3a48a1d..6cfe33cbe6 100644 --- a/frontend/editor/src-tauri/tauri.conf.json +++ b/frontend/editor/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "Stirling PDF", "mainBinaryName": "Stirling-PDF", - "version": "2.14.0", + "version": "2.14.1", "identifier": "stirling.pdf.dev", "build": { "frontendDist": "../dist", diff --git a/frontend/editor/src/assets/3rdPartyLicenses.json b/frontend/editor/src/assets/3rdPartyLicenses.json index baf60879b6..3b606376de 100644 --- a/frontend/editor/src/assets/3rdPartyLicenses.json +++ b/frontend/editor/src/assets/3rdPartyLicenses.json @@ -3,133 +3,182 @@ { "moduleName": "@atlaskit/pragmatic-drag-and-drop", "moduleUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git", - "moduleVersion": "1.7.7", + "moduleVersion": "1.7.9", "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git" }, { - "moduleName": "@embedpdf/core", - "moduleUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz", - "moduleVersion": "1.3.0", + "moduleName": "@cantoo/pdf-lib", + "moduleUrl": "git+https://github.com/cantoo-scribe/pdf-lib.git", + "moduleVersion": "2.6.5", "moduleLicense": "MIT", - "moduleLicenseUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz" + "moduleLicenseUrl": "git+https://github.com/cantoo-scribe/pdf-lib.git" + }, + { + "moduleName": "@dnd-kit/core", + "moduleUrl": "git+https://github.com/clauderic/dnd-kit.git", + "moduleVersion": "6.3.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/clauderic/dnd-kit.git" + }, + { + "moduleName": "@embedpdf/core", + "moduleUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-2.14.4.tgz", + "moduleVersion": "2.14.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-2.14.4.tgz" }, { "moduleName": "@embedpdf/engines", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" + }, + { + "moduleName": "@embedpdf/models", + "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, { "moduleName": "@embedpdf/plugin-annotation", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" + }, + { + "moduleName": "@embedpdf/plugin-attachment", + "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", + "moduleVersion": "2.14.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" + }, + { + "moduleName": "@embedpdf/plugin-bookmark", + "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", + "moduleVersion": "2.14.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" + }, + { + "moduleName": "@embedpdf/plugin-document-manager", + "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, { "moduleName": "@embedpdf/plugin-export", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, { "moduleName": "@embedpdf/plugin-history", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, { "moduleName": "@embedpdf/plugin-interaction-manager", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" - }, - { - "moduleName": "@embedpdf/plugin-loader", - "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, { "moduleName": "@embedpdf/plugin-pan", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" + }, + { + "moduleName": "@embedpdf/plugin-print", + "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", + "moduleVersion": "2.14.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" + }, + { + "moduleName": "@embedpdf/plugin-redaction", + "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, { "moduleName": "@embedpdf/plugin-render", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, { "moduleName": "@embedpdf/plugin-rotate", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, { "moduleName": "@embedpdf/plugin-scroll", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, { "moduleName": "@embedpdf/plugin-search", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, { "moduleName": "@embedpdf/plugin-selection", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, { "moduleName": "@embedpdf/plugin-spread", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, { "moduleName": "@embedpdf/plugin-thumbnail", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, { "moduleName": "@embedpdf/plugin-tiling", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, { "moduleName": "@embedpdf/plugin-viewport", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, { "moduleName": "@embedpdf/plugin-zoom", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, @@ -157,94 +206,185 @@ { "moduleName": "@mantine/core", "moduleUrl": "git+https://github.com/mantinedev/mantine.git", - "moduleVersion": "8.3.1", + "moduleVersion": "8.3.18", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git" }, { "moduleName": "@mantine/dates", "moduleUrl": "git+https://github.com/mantinedev/mantine.git", - "moduleVersion": "8.3.1", + "moduleVersion": "8.3.18", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git" }, { "moduleName": "@mantine/dropzone", "moduleUrl": "git+https://github.com/mantinedev/mantine.git", - "moduleVersion": "8.3.1", + "moduleVersion": "8.3.18", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git" }, { "moduleName": "@mantine/hooks", "moduleUrl": "git+https://github.com/mantinedev/mantine.git", - "moduleVersion": "8.3.1", + "moduleVersion": "8.3.18", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git" }, { "moduleName": "@mui/icons-material", "moduleUrl": "git+https://github.com/mui/material-ui.git", - "moduleVersion": "7.3.2", + "moduleVersion": "9.0.0", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/mui/material-ui.git" }, { "moduleName": "@mui/material", "moduleUrl": "git+https://github.com/mui/material-ui.git", - "moduleVersion": "7.3.2", + "moduleVersion": "9.0.0", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/mui/material-ui.git" }, { - "moduleName": "@tailwindcss/postcss", - "moduleUrl": "git+https://github.com/tailwindlabs/tailwindcss.git", - "moduleVersion": "4.1.13", + "moduleName": "@posthog/react", + "moduleUrl": "git+https://github.com/PostHog/posthog-js.git", + "moduleVersion": "1.8.2", "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/tailwindlabs/tailwindcss.git" + "moduleLicenseUrl": "git+https://github.com/PostHog/posthog-js.git" + }, + { + "moduleName": "@reactour/tour", + "moduleUrl": "git+https://github.com/elrumordelaluz/reactour.git", + "moduleVersion": "3.8.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/elrumordelaluz/reactour.git" + }, + { + "moduleName": "@stripe/react-stripe-js", + "moduleUrl": "https://github.com/stripe/react-stripe-js.git", + "moduleVersion": "4.0.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://github.com/stripe/react-stripe-js.git" + }, + { + "moduleName": "@stripe/stripe-js", + "moduleUrl": "https://github.com/stripe/stripe-js.git", + "moduleVersion": "7.9.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://github.com/stripe/stripe-js.git" + }, + { + "moduleName": "@supabase/supabase-js", + "moduleUrl": "https://github.com/supabase/supabase-js.git", + "moduleVersion": "2.100.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://github.com/supabase/supabase-js.git" + }, + { + "moduleName": "@tailwindcss/postcss", + "moduleUrl": "https://github.com/tailwindlabs/tailwindcss.git", + "moduleVersion": "4.2.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://github.com/tailwindlabs/tailwindcss.git" }, { "moduleName": "@tanstack/react-virtual", "moduleUrl": "git+https://github.com/TanStack/virtual.git", - "moduleVersion": "3.13.12", + "moduleVersion": "3.13.23", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/TanStack/virtual.git" }, + { + "moduleName": "@tauri-apps/api", + "moduleUrl": "git+https://github.com/tauri-apps/tauri.git", + "moduleVersion": "2.10.1", + "moduleLicense": "Apache-2.0 OR MIT", + "moduleLicenseUrl": "git+https://github.com/tauri-apps/tauri.git" + }, + { + "moduleName": "@tauri-apps/plugin-dialog", + "moduleUrl": "git+https://github.com/tauri-apps/plugins-workspace.git", + "moduleVersion": "2.7.0", + "moduleLicense": "MIT OR Apache-2.0", + "moduleLicenseUrl": "git+https://github.com/tauri-apps/plugins-workspace.git" + }, + { + "moduleName": "@tauri-apps/plugin-fs", + "moduleUrl": "git+https://github.com/tauri-apps/plugins-workspace.git", + "moduleVersion": "2.5.0", + "moduleLicense": "MIT OR Apache-2.0", + "moduleLicenseUrl": "git+https://github.com/tauri-apps/plugins-workspace.git" + }, + { + "moduleName": "@tauri-apps/plugin-http", + "moduleUrl": "git+https://github.com/tauri-apps/plugins-workspace.git", + "moduleVersion": "2.5.7", + "moduleLicense": "MIT OR Apache-2.0", + "moduleLicenseUrl": "git+https://github.com/tauri-apps/plugins-workspace.git" + }, + { + "moduleName": "@tauri-apps/plugin-notification", + "moduleUrl": "git+https://github.com/tauri-apps/plugins-workspace.git", + "moduleVersion": "2.3.3", + "moduleLicense": "MIT OR Apache-2.0", + "moduleLicenseUrl": "git+https://github.com/tauri-apps/plugins-workspace.git" + }, + { + "moduleName": "@tauri-apps/plugin-shell", + "moduleUrl": "git+https://github.com/tauri-apps/plugins-workspace.git", + "moduleVersion": "2.3.5", + "moduleLicense": "MIT OR Apache-2.0", + "moduleLicenseUrl": "git+https://github.com/tauri-apps/plugins-workspace.git" + }, + { + "moduleName": "@userback/widget", + "moduleUrl": "git+https://github.com/userback/widget-js.git", + "moduleVersion": "0.3.12", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/userback/widget-js.git" + }, { "moduleName": "autoprefixer", "moduleUrl": "git+https://github.com/postcss/autoprefixer.git", - "moduleVersion": "10.4.21", + "moduleVersion": "10.4.27", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/postcss/autoprefixer.git" }, { "moduleName": "axios", "moduleUrl": "git+https://github.com/axios/axios.git", - "moduleVersion": "1.12.2", + "moduleVersion": "1.15.0", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/axios/axios.git" }, + { + "moduleName": "d3", + "moduleUrl": "git+https://github.com/d3/d3.git", + "moduleVersion": "7.9.0", + "moduleLicense": "ISC", + "moduleLicenseUrl": "git+https://github.com/d3/d3.git" + }, + { + "moduleName": "globals", + "moduleUrl": "git+https://github.com/sindresorhus/globals.git", + "moduleVersion": "17.5.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/sindresorhus/globals.git" + }, { "moduleName": "i18next", "moduleUrl": "git+https://github.com/i18next/i18next.git", - "moduleVersion": "25.5.2", + "moduleVersion": "25.10.10", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/i18next/i18next.git" }, { "moduleName": "i18next-browser-languagedetector", "moduleUrl": "git+https://github.com/i18next/i18next-browser-languageDetector.git", - "moduleVersion": "8.2.0", + "moduleVersion": "8.2.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/i18next/i18next-browser-languageDetector.git" }, - { - "moduleName": "i18next-http-backend", - "moduleUrl": "git+ssh://git@github.com/i18next/i18next-http-backend.git", - "moduleVersion": "3.0.2", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+ssh://git@github.com/i18next/i18next-http-backend.git" - }, { "moduleName": "jszip", "moduleUrl": "git+https://github.com/Stuk/jszip.git", @@ -254,66 +394,129 @@ }, { "moduleName": "license-report", - "moduleUrl": "git+https://github.com/kessler/license-report.git", - "moduleVersion": "6.8.0", + "moduleUrl": "git+https://github.com/bepo65/license-report.git", + "moduleVersion": "6.8.2", "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/kessler/license-report.git" - }, - { - "moduleName": "pdf-lib", - "moduleUrl": "git+https://github.com/Hopding/pdf-lib.git", - "moduleVersion": "1.17.1", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/Hopding/pdf-lib.git" + "moduleLicenseUrl": "git+https://github.com/bepo65/license-report.git" }, { "moduleName": "pdfjs-dist", "moduleUrl": "git+https://github.com/mozilla/pdf.js.git", - "moduleVersion": "5.4.149", + "moduleVersion": "5.5.207", "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "git+https://github.com/mozilla/pdf.js.git" }, + { + "moduleName": "peerjs", + "moduleUrl": "git+https://github.com/peers/peerjs.git", + "moduleVersion": "1.5.5", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/peers/peerjs.git" + }, + { + "moduleName": "pixelmatch", + "moduleUrl": "git+https://github.com/mapbox/pixelmatch.git", + "moduleVersion": "7.1.0", + "moduleLicense": "ISC", + "moduleLicenseUrl": "git+https://github.com/mapbox/pixelmatch.git" + }, { "moduleName": "posthog-js", - "moduleUrl": "git+https://github.com/PostHog/posthog-js.git", - "moduleVersion": "1.268.0", + "moduleUrl": "https://github.com/PostHog/posthog-js", + "moduleVersion": "1.363.3", "moduleLicense": "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE", - "moduleLicenseUrl": "git+https://github.com/PostHog/posthog-js.git" + "moduleLicenseUrl": "https://github.com/PostHog/posthog-js" + }, + { + "moduleName": "qrcode.react", + "moduleUrl": "git+https://github.com/zpao/qrcode.react.git", + "moduleVersion": "4.2.0", + "moduleLicense": "ISC", + "moduleLicenseUrl": "git+https://github.com/zpao/qrcode.react.git" }, { "moduleName": "react", "moduleUrl": "git+https://github.com/facebook/react.git", - "moduleVersion": "19.1.1", + "moduleVersion": "19.2.4", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/facebook/react.git" }, { "moduleName": "react-dom", "moduleUrl": "git+https://github.com/facebook/react.git", - "moduleVersion": "19.1.1", + "moduleVersion": "19.2.4", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/facebook/react.git" }, + { + "moduleName": "react-easy-crop", + "moduleUrl": "git+https://github.com/ValentinH/react-easy-crop.git", + "moduleVersion": "5.5.6", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/ValentinH/react-easy-crop.git" + }, { "moduleName": "react-i18next", "moduleUrl": "git+https://github.com/i18next/react-i18next.git", - "moduleVersion": "15.7.3", + "moduleVersion": "16.6.6", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/i18next/react-i18next.git" }, + { + "moduleName": "react-markdown", + "moduleUrl": "git+https://github.com/remarkjs/react-markdown.git", + "moduleVersion": "9.1.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/remarkjs/react-markdown.git" + }, + { + "moduleName": "react-rnd", + "moduleUrl": "git+https://github.com/bokuweb/react-rnd.git", + "moduleVersion": "10.5.3", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/bokuweb/react-rnd.git" + }, { "moduleName": "react-router-dom", "moduleUrl": "git+https://github.com/remix-run/react-router.git", - "moduleVersion": "7.9.1", + "moduleVersion": "7.13.2", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/remix-run/react-router.git" }, { - "moduleName": "tailwindcss", - "moduleUrl": "git+https://github.com/tailwindlabs/tailwindcss.git", - "moduleVersion": "4.1.13", + "moduleName": "recharts", + "moduleUrl": "git+https://github.com/recharts/recharts.git", + "moduleVersion": "3.8.0", "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/tailwindlabs/tailwindcss.git" + "moduleLicenseUrl": "git+https://github.com/recharts/recharts.git" + }, + { + "moduleName": "remark-gfm", + "moduleUrl": "git+https://github.com/remarkjs/remark-gfm.git", + "moduleVersion": "4.0.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/remarkjs/remark-gfm.git" + }, + { + "moduleName": "signature_pad", + "moduleUrl": "git+https://github.com/szimek/signature_pad.git", + "moduleVersion": "5.1.3", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/szimek/signature_pad.git" + }, + { + "moduleName": "smol-toml", + "moduleUrl": "github:squirrelchat/smol-toml", + "moduleVersion": "1.6.1", + "moduleLicense": "BSD-3-Clause", + "moduleLicenseUrl": "github:squirrelchat/smol-toml" + }, + { + "moduleName": "tailwindcss", + "moduleUrl": "https://github.com/tailwindlabs/tailwindcss.git", + "moduleVersion": "4.2.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://github.com/tailwindlabs/tailwindcss.git" }, { "moduleName": "web-vitals", diff --git a/frontend/editor/src/cloud/components/onboarding/renderButtons.tsx b/frontend/editor/src/cloud/components/onboarding/renderButtons.tsx index f2857af460..7ff108d121 100644 --- a/frontend/editor/src/cloud/components/onboarding/renderButtons.tsx +++ b/frontend/editor/src/cloud/components/onboarding/renderButtons.tsx @@ -1,5 +1,7 @@ import React from "react"; -import { Button, Group, ActionIcon } from "@mantine/core"; +import { Group } from "@mantine/core"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; import { TFunction } from "i18next"; import { @@ -31,22 +33,6 @@ export function renderButtons({ (btn) => btn.group === "right", ); - const buttonStyles = (variant: ButtonDefinition["variant"]) => - variant === "primary" - ? { - root: { - background: "var(--onboarding-primary-button-bg)", - color: "var(--onboarding-primary-button-text)", - }, - } - : { - root: { - background: "var(--onboarding-secondary-button-bg)", - border: "1px solid var(--onboarding-secondary-button-border)", - color: "var(--onboarding-secondary-button-text)", - }, - }; - const resolveButtonLabel = (button: ButtonDefinition) => { // Translate the label (it's a translation key) const label = button.label ?? ""; @@ -65,20 +51,15 @@ export function renderButtons({ onAction(button.action)} - radius="md" - size={40} + size="lg" + variant="secondary" + accent="neutral" disabled={disabled} - styles={{ - root: { - background: "var(--onboarding-secondary-button-bg)", - border: "1px solid var(--onboarding-secondary-button-border)", - color: "var(--onboarding-secondary-button-text)", - }, - }} + aria-label={t("onboarding.buttons.back", "Back")} > - {button.icon === "chevron-left" && ( + {button.icon === "chevron-left" ? ( - )} + ) : null} ); } @@ -91,7 +72,8 @@ export function renderButtons({ key={button.key} onClick={() => onAction(button.action)} disabled={disabled} - styles={buttonStyles(variant)} + variant={variant === "primary" ? "primary" : "secondary"} + accent="neutral" > {label} diff --git a/frontend/editor/src/cloud/components/onboarding/slides/TeamSlide.tsx b/frontend/editor/src/cloud/components/onboarding/slides/TeamSlide.tsx index 03eadf0f4c..a1948f2f06 100644 --- a/frontend/editor/src/cloud/components/onboarding/slides/TeamSlide.tsx +++ b/frontend/editor/src/cloud/components/onboarding/slides/TeamSlide.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from "react"; -import { Badge, Button, TextInput } from "@mantine/core"; +import { Badge, TextInput } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import { SlideConfig } from "@app/types/types"; import { createLightSlideBackground } from "@app/components/onboarding/slides/unifiedBackgroundConfig"; diff --git a/frontend/editor/src/cloud/components/shared/FreeLimitReachedModal.tsx b/frontend/editor/src/cloud/components/shared/FreeLimitReachedModal.tsx index 7db30887e7..177a15b7af 100644 --- a/frontend/editor/src/cloud/components/shared/FreeLimitReachedModal.tsx +++ b/frontend/editor/src/cloud/components/shared/FreeLimitReachedModal.tsx @@ -1,5 +1,6 @@ import { useMemo } from "react"; -import { Modal, Stack, Button } from "@mantine/core"; +import { Modal, Stack } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import CelebrationIcon from "@mui/icons-material/CelebrationOutlined"; import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground"; @@ -169,7 +170,7 @@ export function FreeLimitReachedModal({ onClose }: FreeLimitReachedModalProps) { > + {open && (

      @@ -446,16 +447,16 @@ function CapReachedHelp() { const [open, setOpen] = useState(false); return (
      - + {open && (
      @@ -656,7 +657,7 @@ function StripePortalLink({ onClick={handleClick} loading={loading} rightSection={} - variant="light" + variant="secondary" > {t("payg.stripe.open", "Open billing portal")} diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/PaygFree.css b/frontend/editor/src/cloud/components/shared/config/configSections/PaygFree.css index 17ef96e8b3..6ade6c7b6d 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/PaygFree.css +++ b/frontend/editor/src/cloud/components/shared/config/configSections/PaygFree.css @@ -217,25 +217,6 @@ border-top: 1px solid var(--payg-divider); padding-top: 16px; } -.paygf-cta__button { - padding: 13px 22px; - border: none; - border-radius: 11px; - background: linear-gradient(135deg, var(--payg-accent) 0%, #6c5ce7 100%); - color: white; - font-weight: 600; - font-size: 0.95rem; - font-family: inherit; - cursor: pointer; - transition: - transform 120ms ease, - box-shadow 120ms ease; - white-space: nowrap; -} -.paygf-cta__button:hover { - transform: translateY(-1px); - box-shadow: 0 8px 22px -6px rgba(10, 139, 255, 0.55); -} .paygf-cta__reassurance { margin: 0; font-size: 0.78rem; diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/PaygFree.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/PaygFree.tsx index 330bffa71e..ae3738bd5f 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/PaygFree.tsx +++ b/frontend/editor/src/cloud/components/shared/config/configSections/PaygFree.tsx @@ -25,6 +25,7 @@ */ import React, { useState } from "react"; import { Stack } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import BoltIcon from "@mui/icons-material/BoltRounded"; import AllInclusiveIcon from "@mui/icons-material/AllInclusiveRounded"; import CheckIcon from "@mui/icons-material/CheckRounded"; @@ -171,14 +172,14 @@ function ProcessorCard({ snap, isLeader, onTurnOn }: ProcessorCardProps) { {isLeader ? ( <> - + {t( "payg.free.cta.reassurance", diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.css b/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.css index 90083fb80e..bc9ce0cef8 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.css +++ b/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.css @@ -22,10 +22,10 @@ gap: 14px; } [data-mantine-color-scheme="dark"] .scc { - --scc-accent-text: #66b8ff; - --scc-accent-soft: rgba(10, 139, 255, 0.16); - --scc-chip-bg: #272d35; - --scc-chip-border: #3d444e; + --scc-accent-text: #7ab4ff; + --scc-accent-soft: rgba(79, 142, 245, 0.16); + --scc-chip-bg: #1c2340; + --scc-chip-border: #2d3560; } /* ── Inline row: presets · custom · no-cap · (save) ──────────────────── */ diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/StripeCheckoutPanel.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/StripeCheckoutPanel.tsx index 1ffc9f4ee2..03584f11de 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/StripeCheckoutPanel.tsx +++ b/frontend/editor/src/cloud/components/shared/config/configSections/StripeCheckoutPanel.tsx @@ -64,6 +64,7 @@ */ import React, { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui/Button"; import { useAuth } from "@app/auth/UseSession"; import { createCheckoutSession, @@ -267,17 +268,12 @@ const StripeCheckoutPanel: React.FC = ({ )}
      - +
      ); diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/TeamSection.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/TeamSection.tsx index 6755a4d746..44fc59c738 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/TeamSection.tsx +++ b/frontend/editor/src/cloud/components/shared/config/configSections/TeamSection.tsx @@ -1,6 +1,5 @@ import React, { useState, useEffect } from "react"; import { - Button, TextInput, Group, Text, @@ -8,9 +7,10 @@ import { Alert, Table, Badge, - ActionIcon, Menu, } from "@mantine/core"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; import { useTranslation } from "react-i18next"; import { useSaaSTeam } from "@app/contexts/SaaSTeamContext"; import LocalIcon from "@app/components/shared/LocalIcon"; @@ -233,19 +233,18 @@ const TeamSection: React.FC = () => { }} /> @@ -257,7 +256,7 @@ const TeamSection: React.FC = () => { {isTeamLeader && !isPersonalTeam && ( {
      {!isPersonalTeam && !isTeamLeader && !isEditingName && ( + )}

      {step === "confirm" @@ -170,14 +172,13 @@ export default function UpgradeModal({ )}

    - + {/* Step indicator. Hidden on the confirmation panel since the @@ -198,13 +199,13 @@ export default function UpgradeModal({ "{{symbol}}{{amount}} / month", { symbol: sym, amount: effectiveCap }, )} - + ) : ( @@ -264,36 +265,23 @@ export default function UpgradeModal({
    {step === "cap" && ( <> - - + + )} {step === "confirm" && ( - + )}
    diff --git a/frontend/editor/src/core/assets/login/github.svg b/frontend/editor/src/core/assets/login/github.svg index 651eaac2b8..1174b67928 100644 --- a/frontend/editor/src/core/assets/login/github.svg +++ b/frontend/editor/src/core/assets/login/github.svg @@ -1,3 +1,3 @@ - + diff --git a/frontend/editor/src/core/components/AppProviders.tsx b/frontend/editor/src/core/components/AppProviders.tsx index ede9ed266a..f050ae7f9b 100644 --- a/frontend/editor/src/core/components/AppProviders.tsx +++ b/frontend/editor/src/core/components/AppProviders.tsx @@ -27,6 +27,7 @@ import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestra import { PageEditorProvider } from "@app/contexts/PageEditorContext"; import { BannerProvider } from "@app/contexts/BannerContext"; import ErrorBoundary from "@app/components/shared/ErrorBoundary"; +import { usePosthogTracking } from "@app/hooks/usePosthogTracking"; import { useScarfTracking } from "@app/hooks/useScarfTracking"; import { useAppInitialization } from "@app/hooks/useAppInitialization"; import { useLogoAssets } from "@app/hooks/useLogoAssets"; @@ -43,6 +44,11 @@ function ScarfTrackingInitializer() { return null; } +function PosthogTrackingInitializer() { + usePosthogTracking(); + return null; +} + // Component to run app-level initialization (must be inside AppProviders for context access) function AppInitializer() { useAppInitialization(); @@ -122,6 +128,7 @@ export function AppProviders({ retryOptions={appConfigRetryOptions} {...appConfigProviderProps} > + diff --git a/frontend/editor/src/core/components/StorageStatsCard.tsx b/frontend/editor/src/core/components/StorageStatsCard.tsx index 468d24fe37..186b56c4ca 100644 --- a/frontend/editor/src/core/components/StorageStatsCard.tsx +++ b/frontend/editor/src/core/components/StorageStatsCard.tsx @@ -1,5 +1,6 @@ import React from "react"; -import { Card, Group, Text, Button, Progress } from "@mantine/core"; +import { Card, Group, Text, Progress } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import StorageIcon from "@mui/icons-material/Storage"; import DeleteIcon from "@mui/icons-material/Delete"; @@ -58,21 +59,16 @@ const StorageStatsCard: React.FC = ({ {filesCount > 0 && ( )} - diff --git a/frontend/editor/src/core/components/annotation/shared/ColorControl.tsx b/frontend/editor/src/core/components/annotation/shared/ColorControl.tsx index ca41f0f6a2..524314ee62 100644 --- a/frontend/editor/src/core/components/annotation/shared/ColorControl.tsx +++ b/frontend/editor/src/core/components/annotation/shared/ColorControl.tsx @@ -1,5 +1,4 @@ import { - ActionIcon, Tooltip, Popover, Stack, @@ -10,6 +9,7 @@ import { import { useState, useCallback, useEffect } from "react"; import { useTranslation } from "react-i18next"; import ColorizeIcon from "@mui/icons-material/Colorize"; +import { ActionIcon } from "@app/ui/ActionIcon"; // safari and firefox do not support the eye dropper API, only edge, chrome and opera do. // the button is hidden in the UI if the API is not supported. @@ -66,24 +66,12 @@ export function ColorControl({ setOpened(!opened)} disabled={disabled} - styles={{ - root: { - flexShrink: 0, - backgroundColor: "var(--bg-raised)", - border: "1px solid var(--border-default)", - color: "var(--text-secondary)", - "&:hover": { - backgroundColor: "var(--hover-bg)", - borderColor: "var(--border-strong)", - color: "var(--text-primary)", - }, - }, - }} > @@ -117,11 +105,14 @@ export function ColorControl({ label={t("color.eyeDropper.tooltip", "Pick colour from screen")} > diff --git a/frontend/editor/src/core/components/annotation/shared/ColorPicker.tsx b/frontend/editor/src/core/components/annotation/shared/ColorPicker.tsx index e12db7f340..ec854426e6 100644 --- a/frontend/editor/src/core/components/annotation/shared/ColorPicker.tsx +++ b/frontend/editor/src/core/components/annotation/shared/ColorPicker.tsx @@ -4,13 +4,12 @@ import { Stack, ColorPicker as MantineColorPicker, Group, - Button, ColorSwatch, Slider, Text, } from "@mantine/core"; import { useTranslation } from "react-i18next"; - +import { Button } from "@app/ui/Button"; interface ColorPickerProps { isOpen: boolean; onClose: () => void; diff --git a/frontend/editor/src/core/components/annotation/shared/DrawingCanvas.tsx b/frontend/editor/src/core/components/annotation/shared/DrawingCanvas.tsx index 08808b5b61..c10d9d9023 100644 --- a/frontend/editor/src/core/components/annotation/shared/DrawingCanvas.tsx +++ b/frontend/editor/src/core/components/annotation/shared/DrawingCanvas.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useRef, useState } from "react"; -import { Paper, Button, Modal, Stack, Text, Group } from "@mantine/core"; +import { Paper, Modal, Stack, Text, Group } from "@mantine/core"; import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui/Button"; import { ColorSwatchButton } from "@app/components/annotation/shared/ColorPicker"; import PenSizeSelector from "@app/components/tools/sign/PenSizeSelector"; import SignaturePad from "signature_pad"; @@ -331,7 +332,7 @@ export const DrawingCanvas: React.FC = ({
    - diff --git a/frontend/editor/src/core/components/annotation/shared/DrawingControls.tsx b/frontend/editor/src/core/components/annotation/shared/DrawingControls.tsx index 7118a817e8..0844125117 100644 --- a/frontend/editor/src/core/components/annotation/shared/DrawingControls.tsx +++ b/frontend/editor/src/core/components/annotation/shared/DrawingControls.tsx @@ -1,7 +1,9 @@ import React from "react"; -import { Group, Button, ActionIcon, Tooltip } from "@mantine/core"; +import { Group, Tooltip } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { LocalIcon } from "@app/components/shared/LocalIcon"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; interface DrawingControlsProps { onUndo?: () => void; @@ -37,12 +39,11 @@ export const DrawingControls: React.FC = ({ {onUndo && ( = ({ {onRedo && ( = ({ {/* Place Signature Button */} {showPlaceButton && onPlaceSignature && ( diff --git a/frontend/editor/src/core/components/annotation/shared/OpacityControl.tsx b/frontend/editor/src/core/components/annotation/shared/OpacityControl.tsx index 496b5c4070..3853fa32c2 100644 --- a/frontend/editor/src/core/components/annotation/shared/OpacityControl.tsx +++ b/frontend/editor/src/core/components/annotation/shared/OpacityControl.tsx @@ -1,14 +1,8 @@ -import { - ActionIcon, - Tooltip, - Popover, - Stack, - Slider, - Text, -} from "@mantine/core"; +import { Tooltip, Popover, Stack, Slider, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { useState } from "react"; import OpacityIcon from "@mui/icons-material/Opacity"; +import { ActionIcon } from "@app/ui/ActionIcon"; interface OpacityControlProps { value: number; // 0-100 @@ -29,24 +23,12 @@ export function OpacityControl({ setOpened(!opened)} disabled={disabled} - styles={{ - root: { - flexShrink: 0, - backgroundColor: "var(--bg-raised)", - border: "1px solid var(--border-default)", - color: "var(--text-secondary)", - "&:hover": { - backgroundColor: "var(--hover-bg)", - borderColor: "var(--border-strong)", - color: "var(--text-primary)", - }, - }, - }} > diff --git a/frontend/editor/src/core/components/annotation/shared/PropertiesPopover.tsx b/frontend/editor/src/core/components/annotation/shared/PropertiesPopover.tsx index 5f23b81ede..2f2a42b982 100644 --- a/frontend/editor/src/core/components/annotation/shared/PropertiesPopover.tsx +++ b/frontend/editor/src/core/components/annotation/shared/PropertiesPopover.tsx @@ -1,14 +1,7 @@ -import { - ActionIcon, - Tooltip, - Popover, - Stack, - Slider, - Text, - Group, - Button, -} from "@mantine/core"; +import { Tooltip, Popover, Stack, Slider, Text, Group } from "@mantine/core"; import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; import { useState } from "react"; import type { TrackedAnnotation } from "@embedpdf/plugin-annotation"; import type { PdfAnnotationObject } from "@embedpdf/models"; @@ -106,21 +99,24 @@ export function PropertiesPopover({ onUpdate({ textAlign: 0 })} size="md" > onUpdate({ textAlign: 1 })} size="md" > onUpdate({ textAlign: 2 })} size="md" > @@ -176,8 +172,8 @@ export function PropertiesPopover({ />
    - - @@ -633,14 +670,10 @@ const FileEditorThumbnail = ({ {file.name} - - diff --git a/frontend/editor/src/core/components/fileManager/CompactFileDetails.tsx b/frontend/editor/src/core/components/fileManager/CompactFileDetails.tsx index 09ca9a10b1..11657cc288 100644 --- a/frontend/editor/src/core/components/fileManager/CompactFileDetails.tsx +++ b/frontend/editor/src/core/components/fileManager/CompactFileDetails.tsx @@ -1,5 +1,7 @@ import React from "react"; -import { Stack, Box, Text, Button, ActionIcon, Center } from "@mantine/core"; +import { Stack, Box, Text, Center } from "@mantine/core"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; import ChevronRightIcon from "@mui/icons-material/ChevronRight"; @@ -128,18 +130,20 @@ const CompactFileDetails: React.FC = ({ {hasMultipleFiles && ( @@ -150,16 +154,10 @@ const CompactFileDetails: React.FC = ({ {/* Action Button */} {!shouldHideGoogleDrive && ( + {fieldsOpen && (
    } - variant="default" + variant="secondary" onClick={onOpenVersionHistory} > {t( @@ -291,7 +300,6 @@ export function FileDetailsPanel({
    @@ -309,7 +311,7 @@ function EmptyState({ ) : ( @@ -942,7 +935,7 @@ export default function FileManagerView() { ) : (
    )} @@ -1147,8 +1139,8 @@ export default function FileManagerView() { w={280} >
    @@ -1712,8 +1702,8 @@ function Breadcrumbs() { const isLast = idx === trail.length - 1; return ( - + {!isLast && ( {FOLDER_COLOR_PALETTE.map((c) => ( - +
    ); } diff --git a/frontend/editor/src/core/components/filesPage/FolderNameDialog.tsx b/frontend/editor/src/core/components/filesPage/FolderNameDialog.tsx index 4197d652ed..62858076cd 100644 --- a/frontend/editor/src/core/components/filesPage/FolderNameDialog.tsx +++ b/frontend/editor/src/core/components/filesPage/FolderNameDialog.tsx @@ -1,8 +1,10 @@ import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Alert, Button, Group, Modal, Stack, TextInput } from "@mantine/core"; +import { Alert, Group, Modal, Stack, TextInput } from "@mantine/core"; import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined"; +import { Button } from "@app/ui/Button"; + interface FolderNameDialogProps { opened: boolean; title: string; @@ -94,7 +96,7 @@ export function FolderNameDialog({ )} - + ); } diff --git a/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx b/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx index 937db8c384..0b6a75878b 100644 --- a/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx +++ b/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx @@ -1,6 +1,8 @@ import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { ActionIcon, Badge, Menu } from "@mantine/core"; +import { Badge, Menu } from "@mantine/core"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import DeleteIcon from "@mui/icons-material/Delete"; import DownloadIcon from "@mui/icons-material/Download"; @@ -144,8 +146,8 @@ export function VersionTimeline({ )} - +
  • ); } @@ -181,24 +183,31 @@ export function VersionTimeline({ )}
    - +
    {formatFileSize(v.size)} {v.lastModified ? ( @@ -230,7 +232,7 @@ export function VersionTimeline({ {collapsible && showAllCollapsed && ( - + )}
    ); diff --git a/frontend/editor/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx b/frontend/editor/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx index fc7914db55..86a087f325 100644 --- a/frontend/editor/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx +++ b/frontend/editor/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx @@ -1,5 +1,7 @@ import React from "react"; -import { Button, Group, ActionIcon } from "@mantine/core"; +import { Group } from "@mantine/core"; +import { ActionIcon } from "@app/ui/ActionIcon"; +import { Button } from "@app/ui/Button"; import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; import { useTranslation } from "react-i18next"; import { @@ -33,22 +35,6 @@ export function SlideButtons({ (btn) => btn.group === "right", ); - const buttonStyles = (variant: ButtonDefinition["variant"]) => - variant === "primary" - ? { - root: { - background: "var(--onboarding-primary-button-bg)", - color: "var(--onboarding-primary-button-text)", - }, - } - : { - root: { - background: "var(--onboarding-secondary-button-bg)", - border: "1px solid var(--onboarding-secondary-button-border)", - color: "var(--onboarding-secondary-button-text)", - }, - }; - const resolveButtonLabel = (button: ButtonDefinition) => { // Special case: override "See Plans" with "Upgrade now" when over limit if ( @@ -77,20 +63,14 @@ export function SlideButtons({ onAction(button.action)} - radius="md" - size={40} + variant="secondary" + accent="neutral" disabled={disabled} - styles={{ - root: { - background: "var(--onboarding-secondary-button-bg)", - border: "1px solid var(--onboarding-secondary-button-border)", - color: "var(--onboarding-secondary-button-text)", - }, - }} + aria-label={t("onboarding.buttons.back", "Back")} > - {button.icon === "chevron-left" && ( + {button.icon === "chevron-left" ? ( - )} + ) : null} ); } @@ -103,7 +83,10 @@ export function SlideButtons({ key={button.key} onClick={() => onAction(button.action)} disabled={disabled} - styles={buttonStyles(variant)} + variant={variant === "primary" ? "primary" : "secondary"} + accent={ + button.accent ?? (variant === "primary" ? "default" : "neutral") + } > {label} diff --git a/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.tsx b/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.tsx index b1bc731d52..b8135739bd 100644 --- a/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.tsx +++ b/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.tsx @@ -6,9 +6,10 @@ */ import React from "react"; -import { Modal, Stack, ActionIcon } from "@mantine/core"; +import { Modal, Stack } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { ActionIcon } from "@app/ui/ActionIcon"; import DiamondOutlinedIcon from "@mui/icons-material/DiamondOutlined"; -import CloseIcon from "@mui/icons-material/Close"; import type { SlideDefinition, @@ -45,6 +46,7 @@ export default function OnboardingModalSlide({ onAction, allowDismiss = true, }: OnboardingModalSlideProps) { + const { t } = useTranslation(); const renderHero = () => { if (slideDefinition.hero.type === "dual-icon") { return ( @@ -139,8 +141,9 @@ export default function OnboardingModalSlide({ {allowDismiss && ( - + )}
    diff --git a/frontend/editor/src/core/components/onboarding/OnboardingTour.tsx b/frontend/editor/src/core/components/onboarding/OnboardingTour.tsx index 1f3ca67692..c38d9f952b 100644 --- a/frontend/editor/src/core/components/onboarding/OnboardingTour.tsx +++ b/frontend/editor/src/core/components/onboarding/OnboardingTour.tsx @@ -8,7 +8,7 @@ import React from "react"; import { TourProvider, useTour, type StepType } from "@reactour/tour"; -import { CloseButton, ActionIcon } from "@mantine/core"; +import { ActionIcon } from "@app/ui/ActionIcon"; import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import CheckIcon from "@mui/icons-material/Check"; @@ -137,7 +137,7 @@ export default function OnboardingTour({ setIsOpen, }) } - variant="subtle" + variant="tertiary" size="lg" aria-label={ isLast @@ -151,11 +151,15 @@ export default function OnboardingTour({ }} components={{ Close: ({ onClick }) => ( - + > + × + ), Content: ({ content }: { content: string }) => (
    boolean; @@ -238,6 +241,7 @@ export const SLIDE_DEFINITIONS: Record = { type: "button", label: "onboarding.serverLicense.seePlans", variant: "primary", + accent: "premium", group: "right", action: "see-plans", }, diff --git a/frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx b/frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx index 6960b88c27..23566a5527 100644 --- a/frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx +++ b/frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Trans } from "react-i18next"; -import { Button } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import i18n from "@app/i18n"; import { SlideConfig } from "@app/types/types"; @@ -36,7 +36,7 @@ export default function AnalyticsChoiceSlide({
    diff --git a/frontend/editor/src/core/components/onboarding/slides/MFASetupSlide.tsx b/frontend/editor/src/core/components/onboarding/slides/MFASetupSlide.tsx index de76eadd6b..c018d69265 100644 --- a/frontend/editor/src/core/components/onboarding/slides/MFASetupSlide.tsx +++ b/frontend/editor/src/core/components/onboarding/slides/MFASetupSlide.tsx @@ -8,13 +8,13 @@ import { import { Alert, Box, - Button, Group, Loader, Stack, Text, TextInput, } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { QRCodeSVG } from "qrcode.react"; import { useTranslation } from "react-i18next"; import { SlideConfig } from "@app/types/types"; @@ -210,8 +210,8 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) { - diff --git a/frontend/editor/src/core/components/pageEditor/FileThumbnail.tsx b/frontend/editor/src/core/components/pageEditor/FileThumbnail.tsx index 93ff3c2ab9..472fc141d7 100644 --- a/frontend/editor/src/core/components/pageEditor/FileThumbnail.tsx +++ b/frontend/editor/src/core/components/pageEditor/FileThumbnail.tsx @@ -5,7 +5,9 @@ import React, { useMemo, useEffect, } from "react"; -import { ActionIcon, CheckboxIndicator } from "@mantine/core"; +import { CheckboxIndicator } from "@mantine/core"; +import { ActionIcon } from "@app/ui/ActionIcon"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import MoreVertIcon from "@mui/icons-material/MoreVert"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; @@ -259,7 +261,7 @@ const FileThumbnail = ({ {/* Kebab menu */} { e.stopPropagation(); @@ -277,8 +279,18 @@ const FileThumbnail = ({ style={{ width: actionsWidth }} onClick={(e) => e.stopPropagation()} > - - - - + {terminology.download} +
    - - + {t("delete", "Delete")} +
    )} diff --git a/frontend/editor/src/core/components/pageEditor/PageEditorControls.tsx b/frontend/editor/src/core/components/pageEditor/PageEditorControls.tsx index 2e8653fa89..96762a9203 100644 --- a/frontend/editor/src/core/components/pageEditor/PageEditorControls.tsx +++ b/frontend/editor/src/core/components/pageEditor/PageEditorControls.tsx @@ -1,4 +1,5 @@ -import { Tooltip, ActionIcon } from "@mantine/core"; +import { Tooltip } from "@mantine/core"; +import { ActionIcon } from "@app/ui/ActionIcon"; import UndoIcon from "@mui/icons-material/Undo"; import RedoIcon from "@mui/icons-material/Redo"; import ContentCutIcon from "@mui/icons-material/ContentCut"; @@ -135,28 +136,22 @@ const PageEditorControls = ({ {/* Undo/Redo */} @@ -176,17 +171,14 @@ const PageEditorControls = ({ label={t("pageEditor.toolbar.rotateLeft", "Rotate Selected Left")} > onRotate("left")} disabled={selectedPageIds.length === 0} - variant="subtle" - style={{ - color: - selectedPageIds.length > 0 - ? "var(--text-secondary)" - : "var(--text-muted)", - }} - radius="md" - size="lg" + aria-label={t( + "pageEditor.toolbar.rotateLeft", + "Rotate Selected Left", + )} > @@ -195,68 +187,47 @@ const PageEditorControls = ({ label={t("pageEditor.toolbar.rotateRight", "Rotate Selected Right")} > onRotate("right")} disabled={selectedPageIds.length === 0} - variant="subtle" - style={{ - color: - selectedPageIds.length > 0 - ? "var(--text-secondary)" - : "var(--text-muted)", - }} - radius="md" - size="lg" + aria-label={t( + "pageEditor.toolbar.rotateRight", + "Rotate Selected Right", + )} > 0 - ? "var(--text-secondary)" - : "var(--text-muted)", - }} - radius="md" - size="lg" + aria-label={t("pageEditor.toolbar.delete", "Delete Selected")} > 0 - ? "var(--text-secondary)" - : "var(--text-muted)", - }} - radius="md" - size="lg" + aria-label={getSplitTooltip()} > 0 - ? "var(--text-secondary)" - : "var(--text-muted)", - }} - radius="md" - size="lg" + aria-label={getPageBreakTooltip()} > diff --git a/frontend/editor/src/core/components/pageEditor/PageSelectByNumberButton.tsx b/frontend/editor/src/core/components/pageEditor/PageSelectByNumberButton.tsx index fff0b8ef8f..dc2032fa4a 100644 --- a/frontend/editor/src/core/components/pageEditor/PageSelectByNumberButton.tsx +++ b/frontend/editor/src/core/components/pageEditor/PageSelectByNumberButton.tsx @@ -1,4 +1,5 @@ -import { ActionIcon, Popover } from "@mantine/core"; +import { Popover } from "@mantine/core"; +import { ActionIcon } from "@app/ui/ActionIcon"; import LocalIcon from "@app/components/shared/LocalIcon"; import { Tooltip } from "@app/components/shared/Tooltip"; import BulkSelectionPanel from "@app/components/pageEditor/BulkSelectionPanel"; @@ -37,8 +38,7 @@ export default function PageSelectByNumberButton({
    diff --git a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.tsx b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.tsx index 71c07d26b3..d2ff79ad97 100644 --- a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.tsx +++ b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.tsx @@ -1,4 +1,5 @@ -import { Button, Text, Group, Divider } from "@mantine/core"; +import { Text, Group, Divider } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import classes from "@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css"; import { LogicalOperator } from "@app/utils/bulkselection/selectionBuilders"; @@ -22,7 +23,7 @@ const OperatorsSection = ({ + × + ) } onKeyDown={(e) => e.key === "Enter" && onUpdatePagesFromCSV()} diff --git a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectPages.tsx b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectPages.tsx index 314ca282b6..5fc7341430 100644 --- a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectPages.tsx +++ b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectPages.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; -import { Button, Text, NumberInput, Group } from "@mantine/core"; +import { Text, NumberInput, Group } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import classes from "@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css"; interface SelectPagesProps { diff --git a/frontend/editor/src/core/components/policies/policyRunStore.ts b/frontend/editor/src/core/components/policies/policyRunStore.ts new file mode 100644 index 0000000000..798149a911 --- /dev/null +++ b/frontend/editor/src/core/components/policies/policyRunStore.ts @@ -0,0 +1,27 @@ +/** + * Core stub — the real implementation lives in the proprietary overlay. + * Returns empty state so core-build consumers compile without a policyRunStore module. + */ + +export interface PolicyRunRecord { + runId: string; + categoryId: string; + fileId: string; + fileName: string; + status: string; + currentStep?: number; + stepCount?: number; + error: string | null; + retrying?: boolean; + startedAt: number; +} + +export const POLICY_IN_FLIGHT_STATUSES = [ + "PENDING", + "RUNNING", + "WAITING_FOR_INPUT", +] as const; + +export function usePolicyRuns(): PolicyRunRecord[] { + return []; +} diff --git a/frontend/editor/src/core/components/shared/AppConfigModal.tsx b/frontend/editor/src/core/components/shared/AppConfigModal.tsx index 365d42608a..264db178ac 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/core/components/shared/AppConfigModal.tsx @@ -5,7 +5,8 @@ import React, { useCallback, useRef, } from "react"; -import { Badge, Modal, Text, ActionIcon, Tooltip, Group } from "@mantine/core"; +import { Badge, Modal, Text, Tooltip, Group } from "@mantine/core"; +import { ActionIcon } from "@app/ui/ActionIcon"; import { useNavigate, useLocation } from "react-router-dom"; import { useTranslation } from "react-i18next"; import LocalIcon from "@app/components/shared/LocalIcon"; @@ -365,7 +366,7 @@ const AppConfigModalInner: React.FC = ({ /> + + + ); +} + +interface AppSwitchProps { + /** The app this switcher is rendered in (shown as active in the menu). */ + current: AppSwitchTarget; + /** Resolved color scheme; picks the brand mark for the menu items. */ + theme: "light" | "dark"; + /** Invoked with the selected app; only called for apps other than `current`. */ + onSwitch: (app: AppSwitchTarget) => void; + className?: string; +} + +/** + * The editor ⇄ processor app switcher (chevron button → app menu). The editor + * and portal sidebars render this same element so the two apps present one + * identical switcher; each host supplies its own theme source and navigation. + */ +export function AppSwitch({ + current, + theme, + onSwitch, + className, +}: AppSwitchProps) { + const { t } = useTranslation(); + const mark = theme === "dark" ? markDark : markLight; + const apps: Array<{ id: AppSwitchTarget; label: string }> = [ + { + id: "processor", + label: t("portal.shell.sidebar.appProcessor", "Processor"), + }, + { id: "editor", label: t("portal.shell.sidebar.appEditor", "Editor") }, + ]; + return ( + + + + + + {apps.map((app) => ( + onSwitch(app.id)} + leading={} + > + {app.label} + + ))} + + + ); +} diff --git a/frontend/editor/src/core/components/shared/AppSwitcher.tsx b/frontend/editor/src/core/components/shared/AppSwitcher.tsx new file mode 100644 index 0000000000..298a46e1ac --- /dev/null +++ b/frontend/editor/src/core/components/shared/AppSwitcher.tsx @@ -0,0 +1,8 @@ +/** + * Core stub for the sidebar app switcher. Builds that bundle the admin portal + * (proprietary/saas) shadow this with a real switcher; core has no portal, so + * there is nothing to switch to. + */ +export function AppSwitcher() { + return null; +} diff --git a/frontend/editor/src/core/components/shared/BulkShareModal.tsx b/frontend/editor/src/core/components/shared/BulkShareModal.tsx index 978b321232..e210d2f201 100644 --- a/frontend/editor/src/core/components/shared/BulkShareModal.tsx +++ b/frontend/editor/src/core/components/shared/BulkShareModal.tsx @@ -3,13 +3,13 @@ import { Modal, Stack, Text, - Button, Group, Alert, TextInput, Paper, Select, } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import LinkIcon from "@mui/icons-material/Link"; import ContentCopyRoundedIcon from "@mui/icons-material/ContentCopyRounded"; import { useTranslation } from "react-i18next"; @@ -243,8 +243,8 @@ const BulkShareModal: React.FC = ({ label={t("storageShare.linkLabel", "Share link")} rightSection={ - ); - - // Wrap with tooltip if provided (useful for disabled state explanations) - if (option.tooltip && isDisabled) { - return ( - - - {button} - - - ); - } - - return ( - - {button} - - ); - })} - + ); }; diff --git a/frontend/editor/src/core/components/shared/ButtonToggle.tsx b/frontend/editor/src/core/components/shared/ButtonToggle.tsx index 5e02fb169b..9932c88062 100644 --- a/frontend/editor/src/core/components/shared/ButtonToggle.tsx +++ b/frontend/editor/src/core/components/shared/ButtonToggle.tsx @@ -1,5 +1,4 @@ -import { Button, Stack } from "@mantine/core"; -import React from "react"; +import { SegmentedControl } from "@app/ui/SegmentedControl"; export interface ButtonToggleOption { value: string; @@ -13,70 +12,49 @@ export interface ButtonToggleProps { value: string; onChange: (value: string) => void; disabled?: boolean; - orientation?: "vertical" | "horizontal"; size?: "xs" | "sm" | "md" | "lg"; fullWidth?: boolean; } -export const ButtonToggle: React.FC = ({ +export const ButtonToggle = ({ options, value, onChange, disabled = false, - orientation = "vertical", size = "md", fullWidth = true, -}) => { - const isVertical = orientation === "vertical"; +}: ButtonToggleProps) => { + const segmentedSize = size === "xs" || size === "sm" ? "sm" : "md"; - const buttonStyle: React.CSSProperties = { - justifyContent: "flex-start", - height: isVertical ? "auto" : undefined, - minHeight: isVertical ? "50px" : undefined, - padding: isVertical ? "12px 16px" : undefined, - textAlign: "left", - }; - - const renderButton = (option: ButtonToggleOption) => { - const isSelected = value === option.value; - const isDisabled = disabled || option.disabled; - - return ( - - ); - }; - - if (isVertical) { - return {options.map(renderButton)}; - } + const segmentedOptions = options.map((option) => ({ + value: option.value, + disabled: disabled || option.disabled, + label: ( +
    +
    {option.label}
    + {option.description && ( +
    + {option.description} +
    + )} +
    + ), + })); return ( -
    - {options.map(renderButton)} -
    + ); }; diff --git a/frontend/editor/src/core/components/shared/DismissAllErrorsButton.tsx b/frontend/editor/src/core/components/shared/DismissAllErrorsButton.tsx index 3bcc53daf7..5f3f4ada2a 100644 --- a/frontend/editor/src/core/components/shared/DismissAllErrorsButton.tsx +++ b/frontend/editor/src/core/components/shared/DismissAllErrorsButton.tsx @@ -1,9 +1,9 @@ import React from "react"; -import { Button, Group } from "@mantine/core"; +import { Group } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import { useFileState } from "@app/contexts/FileContext"; import { useFileActions } from "@app/contexts/file/fileHooks"; -import CloseIcon from "@mui/icons-material/Close"; import { Z_INDEX_TOAST } from "@app/styles/zIndex"; interface DismissAllErrorsButtonProps { @@ -32,10 +32,9 @@ const DismissAllErrorsButton: React.FC = ({ return ( diff --git a/frontend/editor/src/core/components/shared/FileCard.tsx b/frontend/editor/src/core/components/shared/FileCard.tsx index 5d53418d7f..c731092459 100644 --- a/frontend/editor/src/core/components/shared/FileCard.tsx +++ b/frontend/editor/src/core/components/shared/FileCard.tsx @@ -1,15 +1,7 @@ import { useState } from "react"; -import { - Card, - Stack, - Text, - Group, - Badge, - Button, - Box, - ActionIcon, - Tooltip, -} from "@mantine/core"; +import { Card, Stack, Text, Group, Badge, Box, Tooltip } from "@mantine/core"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; import { useTranslation } from "react-i18next"; import StorageIcon from "@mui/icons-material/Storage"; import VisibilityIcon from "@mui/icons-material/Visibility"; @@ -114,8 +106,8 @@ const FileCard = ({ { e.stopPropagation(); onView(); @@ -131,8 +123,12 @@ const FileCard = ({ > { e.stopPropagation(); onEdit(); @@ -182,14 +178,14 @@ const FileCard = ({ diff --git a/frontend/editor/src/core/components/shared/FileDropdownMenu.tsx b/frontend/editor/src/core/components/shared/FileDropdownMenu.tsx index 519366b9d6..735d633fee 100644 --- a/frontend/editor/src/core/components/shared/FileDropdownMenu.tsx +++ b/frontend/editor/src/core/components/shared/FileDropdownMenu.tsx @@ -1,5 +1,6 @@ import React from "react"; -import { Menu, Loader, Group, Text, ActionIcon, Tooltip } from "@mantine/core"; +import { Menu, Loader, Group, Text, Tooltip } from "@mantine/core"; +import { ActionIcon } from "@app/ui/ActionIcon"; import { useTranslation } from "react-i18next"; import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; @@ -102,10 +103,14 @@ export const FileDropdownMenu: React.FC = ({ withArrow > { e.stopPropagation(); onFileRemove(file.fileId as FileId); diff --git a/frontend/editor/src/core/components/shared/FileGrid.tsx b/frontend/editor/src/core/components/shared/FileGrid.tsx index 2ade54426f..c1e87ab18a 100644 --- a/frontend/editor/src/core/components/shared/FileGrid.tsx +++ b/frontend/editor/src/core/components/shared/FileGrid.tsx @@ -1,13 +1,6 @@ import { useState } from "react"; -import { - Box, - Flex, - Group, - Text, - Button, - TextInput, - Select, -} from "@mantine/core"; +import { Box, Flex, Group, Text, TextInput, Select } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import SearchIcon from "@mui/icons-material/Search"; import SortIcon from "@mui/icons-material/Sort"; @@ -121,7 +114,7 @@ const FileGrid = ({ {onDeleteAll && ( - )} @@ -179,7 +172,7 @@ const FileGrid = ({ {/* Show All Button */} {hasMoreFiles && onShowAll && ( - diff --git a/frontend/editor/src/core/components/shared/FilePickerModal.tsx b/frontend/editor/src/core/components/shared/FilePickerModal.tsx index da1bb91b6c..5e077d3437 100644 --- a/frontend/editor/src/core/components/shared/FilePickerModal.tsx +++ b/frontend/editor/src/core/components/shared/FilePickerModal.tsx @@ -2,7 +2,6 @@ import { useState, useEffect } from "react"; import { Modal, Text, - Button, Group, Stack, Checkbox, @@ -11,6 +10,7 @@ import { Badge, SimpleGrid, } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail"; import { FileId } from "@app/types/file"; @@ -151,10 +151,10 @@ const FilePickerModal = ({ )} - - @@ -249,7 +249,7 @@ const FilePickerModal = ({ {/* Action buttons */} - + ); }) )} diff --git a/frontend/editor/src/core/components/shared/FileSidebar.css b/frontend/editor/src/core/components/shared/FileSidebar.css index d06982179e..0b495d723b 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.css +++ b/frontend/editor/src/core/components/shared/FileSidebar.css @@ -102,6 +102,18 @@ flex-shrink: 0; } +/* App switcher (portal builds only) sits at the far end of the header row. + The content-fade animation makes this span a stacking context, which would + trap the menu's z-index below later sidebar rows — elevate the span so the + open menu paints above them. */ +.file-sidebar-app-switch { + margin-inline-start: auto; + display: flex; + align-items: center; + position: relative; + z-index: var(--z-dropdown); +} + /* ---- Search row ---- */ .file-sidebar-search-row { display: flex; diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index 1eb51b759b..f6fb7f0ce9 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -7,6 +7,7 @@ import React, { forwardRef, } from "react"; import { Loader, Tooltip } from "@mantine/core"; +import { ActionIcon } from "@app/ui/ActionIcon"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { useFileState, useFileActions } from "@app/contexts/file/fileHooks"; @@ -28,6 +29,7 @@ import { import { accountService } from "@app/services/accountService"; import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons"; import { Wordmark } from "@app/components/shared/Wordmark"; +import { AppSwitcher } from "@app/components/shared/AppSwitcher"; import type { StirlingFileStub } from "@app/types/fileContext"; import MenuIcon from "@mui/icons-material/Menu"; import SearchIcon from "@mui/icons-material/Search"; @@ -678,6 +680,17 @@ const FileSidebar = forwardRef( className="file-sidebar-brand-text sidebar-content-fade" /> )} + {!collapsed && ( + // The header row itself toggles collapse; stop the switcher's + // clicks and key presses from reaching it. + e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + + + )}
    @@ -961,26 +974,31 @@ const FileSidebar = forwardRef( {t("fileSidebar.files", "Files")} - - +
    {!stubsLoaded ? ( @@ -989,35 +1007,6 @@ const FileSidebar = forwardRef(
    ) : filteredFileStubs.length > 0 ? (
    - {filteredFileStubs.map((stub) => { const workbenchFileId = state.files.ids.find( (id) => (id as string) === (stub.id as string), diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css index cab9f589f2..5763ab2648 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css @@ -36,32 +36,6 @@ background-color: rgba(59, 130, 246, 0.06); } -/* A policy-enforced file glows in that policy's accent colour so it's obvious - the file has had a policy applied: it pulses a few times to catch the eye and - then fades out (no lingering glow). */ -.file-sidebar-file-item.policy-enforced { - animation: policy-glow-fade 4.5s ease-in-out forwards; -} -@keyframes policy-glow-fade { - 0%, - 24%, - 48% { - box-shadow: - inset 0 0 0 1px color-mix(in srgb, var(--policy-glow) 30%, transparent), - 0 0 6px -2px var(--policy-glow); - } - 12%, - 36%, - 60% { - box-shadow: - inset 0 0 0 1px color-mix(in srgb, var(--policy-glow) 75%, transparent), - 0 0 16px 0 var(--policy-glow); - } - 100% { - box-shadow: 0 0 0 0 transparent; - } -} - .file-sidebar-file-item.selected { background-color: rgba(59, 130, 246, 0.12); } @@ -91,7 +65,7 @@ width: 20px; height: 20px; border-radius: 5px; - border: 1.5px solid var(--border-hover, #52525b); + border: 1.5px solid var(--border-hover, var(--border-strong)); } .file-sidebar-file-item:hover .file-sidebar-file-checkbox-hover { @@ -160,25 +134,6 @@ text-overflow: ellipsis; } -/* ---- Policy activity badges (a shield per policy that has run on the file) ---- */ -.file-sidebar-policy-badges { - display: inline-flex; - align-items: center; - gap: 3px; - flex-shrink: 0; -} -.file-sidebar-policy-badge { - display: inline-flex; - align-items: center; - justify-content: center; - width: 15px; - height: 15px; - border-radius: 4px; - /* `color` is set inline to the policy's accent; the tint follows it. */ - color: var(--text-secondary); - background: color-mix(in srgb, currentColor 16%, transparent); -} - /* ---- Cloud badge (file saved to the server) ---- */ .file-sidebar-cloud-badge { display: inline-flex; diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx index 58a318e0d8..3fb26afa5d 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx @@ -1,6 +1,7 @@ -import { useState, useCallback, useRef } from "react"; +import React, { useState, useCallback, useRef } from "react"; import { createPortal } from "react-dom"; -import { Menu, Tooltip } from "@mantine/core"; +import { Group, Loader, Menu, Stack, Text, Tooltip } from "@mantine/core"; +import { ActionIcon } from "@app/ui/ActionIcon"; import { useTranslation } from "react-i18next"; import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined"; @@ -12,6 +13,10 @@ import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; import HistoryIcon from "@mui/icons-material/History"; import type { FileId } from "@app/types/file"; import { FileDocIcon } from "@app/components/shared/FileDocIcon"; +import { + PolicyBadges, + type FileItemPolicyRef, +} from "@app/components/shared/PolicyBadges"; import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon"; import { useLazyThumbnail } from "@app/hooks/useLazyThumbnail"; import { IMAGE_EXTENSIONS } from "@app/utils/fileUtils"; @@ -131,17 +136,6 @@ export interface FileItemFolderRef { accentColor: string; } -/** A policy that has run on this file, used for the activity badges. */ -export interface FileItemPolicyRef { - id: string; - name: string; - /** CSS colour for the badge (matches the policy's accent). */ - accentColor: string; - /** True only just after the policy was applied — drives the one-off glow, so - * it doesn't replay on every reload of an already-enforced file. */ - recent: boolean; -} - export interface FileItemProps { fileId: FileId; name: string; @@ -177,7 +171,6 @@ export interface FileItemProps { } const MAX_VISIBLE_FOLDER_TAGS = 2; -const MAX_VISIBLE_POLICY_BADGES = 3; export function FileItem({ fileId, @@ -207,6 +200,23 @@ export function FileItem({ const dateLabel = lastModified ? formatFileDate(lastModified) : ""; const typeLabel = ext ? ext.toUpperCase() : "File"; + const policyEnforcing = policies.some((p) => p.enforcing); + const enforcingTooltip = (action: string): React.ReactNode => ( + + + + + {t( + "policy.blockingAction", + "{{action}} blocked while enforcing policy, please wait...", + { action }, + )} + + + + + ); + const visibleFolders = folders.slice(0, MAX_VISIBLE_FOLDER_TAGS); const overflowFolders = folders.slice(MAX_VISIBLE_FOLDER_TAGS); @@ -227,9 +237,6 @@ export function FileItem({ const handleMouseLeave = useCallback(() => setHoverRect(null), []); - // A just-applied policy (recent run) drives the one-off row glow. - const recentPolicy = policies.find((p) => p.recent); - // Reactive: tooltip appears as soon as both hover rect and thumbnail are ready const thumbPos = hoverRect && resolvedThumbnail @@ -243,14 +250,7 @@ export function FileItem({ <>
    onClick(fileId)} draggable={draggable} onDragStart={ @@ -300,25 +300,7 @@ export function FileItem({ )} - {policies.length > 0 && ( - - {policies.slice(0, MAX_VISIBLE_POLICY_BADGES).map((policy) => ( - - - - - - ))} - - )} + {folders.length > 0 && ( @@ -369,14 +351,15 @@ export function FileItem({ )}
    - - + {(onDelete || - onSaveToCloud || + (canSaveToCloud && onSaveToCloud) || (hasVersionHistory && onVersionHistory)) && ( - + e.stopPropagation()}> {hasVersionHistory && onVersionHistory && ( @@ -423,17 +406,10 @@ export function FileItem({ {t("fileSidebar.fileItem.versionHistory", "Version history")} )} - {canSaveToCloud && onSaveToCloud && ( - - } - onClick={(e) => { - e.stopPropagation(); - onSaveToCloud(fileId); - }} - > - {isUploadedToCloud + {canSaveToCloud && + onSaveToCloud && + (() => { + const uploadLabel = isUploadedToCloud ? t( "fileSidebar.fileItem.updateOnServer", "Update on server", @@ -441,21 +417,64 @@ export function FileItem({ : t( "fileSidebar.fileItem.uploadToServer", "Upload to server", - )} - - )} - {onDelete && ( - } - onClick={(e) => { - e.stopPropagation(); - onDelete(fileId); - }} - > - {t("fileSidebar.fileItem.delete", "Delete")} - - )} + ); + return ( + +
    + + } + onClick={(e) => { + e.stopPropagation(); + onSaveToCloud(fileId); + }} + > + {uploadLabel} + +
    +
    + ); + })()} + {onDelete && + (() => { + const deleteLabel = t( + "fileSidebar.fileItem.delete", + "Delete", + ); + return ( + +
    + + } + onClick={(e) => { + e.stopPropagation(); + onDelete(fileId); + }} + > + {deleteLabel} + +
    +
    + ); + })()}
    )} diff --git a/frontend/editor/src/core/components/shared/FileUploadButton.tsx b/frontend/editor/src/core/components/shared/FileUploadButton.tsx index 000639f09c..b07fbd0271 100644 --- a/frontend/editor/src/core/components/shared/FileUploadButton.tsx +++ b/frontend/editor/src/core/components/shared/FileUploadButton.tsx @@ -1,6 +1,32 @@ import { useRef } from "react"; -import { FileButton, Button } from "@mantine/core"; import { useTranslation } from "react-i18next"; +import { FilePicker } from "@app/ui/FilePicker"; +import type { ButtonVariant } from "@app/ui/Button"; + +// Accept both shared DS variants and the legacy Mantine variant names that +// existing callers still pass, mapping the latter onto the DS equivalents. +type LegacyVariant = + | "outline" + | "filled" + | "light" + | "default" + | "subtle" + | "gradient"; + +const VARIANT_MAP: Record = { + filled: "primary", + outline: "secondary", + default: "secondary", + light: "tertiary", + subtle: "tertiary", + gradient: "primary", +}; + +function resolveVariant(variant: ButtonVariant | LegacyVariant): ButtonVariant { + return variant in VARIANT_MAP + ? VARIANT_MAP[variant as LegacyVariant] + : (variant as ButtonVariant); +} interface FileUploadButtonProps { file?: File; @@ -8,7 +34,7 @@ interface FileUploadButtonProps { accept?: string; disabled?: boolean; placeholder?: string; - variant?: "outline" | "filled" | "light" | "default" | "subtle" | "gradient"; + variant?: ButtonVariant | LegacyVariant; fullWidth?: boolean; } @@ -18,7 +44,7 @@ const FileUploadButton = ({ accept, disabled = false, placeholder, - variant = "outline", + variant = "secondary", fullWidth = true, }: FileUploadButtonProps) => { const { t } = useTranslation(); @@ -27,18 +53,16 @@ const FileUploadButton = ({ const defaultPlaceholder = t("chooseFile", "Choose File"); return ( - - {(props) => ( - - )} - + {file ? file.name : placeholder || defaultPlaceholder} + ); }; diff --git a/frontend/editor/src/core/components/shared/FirstLoginModal.tsx b/frontend/editor/src/core/components/shared/FirstLoginModal.tsx index 4d7764f91d..be91ccc03d 100644 --- a/frontend/editor/src/core/components/shared/FirstLoginModal.tsx +++ b/frontend/editor/src/core/components/shared/FirstLoginModal.tsx @@ -1,12 +1,6 @@ import { useState } from "react"; -import { - Modal, - Stack, - Text, - PasswordInput, - Button, - Alert, -} from "@mantine/core"; +import { Modal, Stack, Text, PasswordInput, Alert } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import LocalIcon from "@app/components/shared/LocalIcon"; import { accountService } from "@app/services/accountService"; @@ -195,8 +189,8 @@ export default function FirstLoginModal({ /> diff --git a/frontend/editor/src/core/components/shared/Footer.tsx b/frontend/editor/src/core/components/shared/Footer.tsx index e73a48fd2a..2fb7312fba 100644 --- a/frontend/editor/src/core/components/shared/Footer.tsx +++ b/frontend/editor/src/core/components/shared/Footer.tsx @@ -2,6 +2,7 @@ import { Flex } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { useCookieConsent } from "@app/hooks/useCookieConsent"; import { useFooterInfo } from "@app/hooks/useFooterInfo"; +import { Button } from "@app/ui/Button"; interface FooterProps { privacyPolicy?: string; @@ -53,8 +54,8 @@ export default function Footer({
    )} {finalAnalyticsEnabled && ( - + )}
    diff --git a/frontend/editor/src/core/components/shared/HoverActionMenu.tsx b/frontend/editor/src/core/components/shared/HoverActionMenu.tsx index fafebdf8f2..d912aace48 100644 --- a/frontend/editor/src/core/components/shared/HoverActionMenu.tsx +++ b/frontend/editor/src/core/components/shared/HoverActionMenu.tsx @@ -1,5 +1,6 @@ import React from "react"; -import { ActionIcon, Tooltip } from "@mantine/core"; +import { Tooltip } from "@mantine/core"; +import { ActionIcon } from "@app/ui/ActionIcon"; import styles from "@app/components/shared/HoverActionMenu.module.css"; import { Z_INDEX_HOVER_ACTION_MENU } from "@app/styles/zIndex"; @@ -9,6 +10,8 @@ export interface HoverAction { label: string; onClick: (e: React.MouseEvent) => void; disabled?: boolean; + /** Overrides label in the tooltip — use for rich ReactNode content (e.g. enforcement messages). */ + tooltip?: React.ReactNode; color?: string; hidden?: boolean; dataTour?: string; @@ -57,19 +60,22 @@ const HoverActionMenu: React.FC = ({ onClick={(e) => e.stopPropagation()} > {visibleActions.map((action) => ( - - - {action.icon} - + + {/* Wrapper keeps the tooltip working when the button is disabled + (disabled buttons don't emit the pointer events Tooltip needs). */} +
    + + {action.icon} + +
    ))}
    diff --git a/frontend/editor/src/core/components/shared/InfoBanner.tsx b/frontend/editor/src/core/components/shared/InfoBanner.tsx index c46d5cd665..6572022299 100644 --- a/frontend/editor/src/core/components/shared/InfoBanner.tsx +++ b/frontend/editor/src/core/components/shared/InfoBanner.tsx @@ -1,5 +1,7 @@ import React, { ReactNode } from "react"; -import { Paper, Group, Text, Button, ActionIcon, Stack } from "@mantine/core"; +import { Paper, Group, Text, Stack } from "@mantine/core"; +import { Button, type ButtonVariant, type ButtonAccent } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; import { useTranslation } from "react-i18next"; import LocalIcon from "@app/components/shared/LocalIcon"; @@ -31,6 +33,40 @@ const toneStyles: Record< }, }; +function toSharedButtonVariant( + variant: "light" | "filled" | "white" | "outline" | "subtle", +): ButtonVariant { + switch (variant) { + case "filled": + return "primary"; + case "outline": + return "secondary"; + case "subtle": + return "tertiary"; + case "light": + case "white": + default: + return "secondary"; + } +} + +function toSharedButtonAccent(color: string | undefined): ButtonAccent { + // Mantine colours may carry a shade suffix (e.g. "orange.7"); use the hue. + const hue = (color ?? "").split(".")[0]; + switch (hue) { + case "red": + return "danger"; + case "green": + return "success"; + case "yellow": + case "orange": + return "warning"; + case "blue": + default: + return "default"; + } +} + interface InfoBannerProps { /** * Either a LocalIcon name (string) for the standard sized icon slot, or a @@ -98,7 +134,6 @@ export const InfoBanner: React.FC = ({ const iconSize = compact ? "1rem" : "1.2rem"; const textSize = compact ? "xs" : "sm"; - const buttonSize = compact ? "xs" : "xs"; return ( = ({ {buttonText && onButtonClick && ( )} {dismissible && ( diff --git a/frontend/editor/src/core/components/shared/LoginAgreementModal.tsx b/frontend/editor/src/core/components/shared/LoginAgreementModal.tsx index 8d419e7cc0..9fb7f7c925 100644 --- a/frontend/editor/src/core/components/shared/LoginAgreementModal.tsx +++ b/frontend/editor/src/core/components/shared/LoginAgreementModal.tsx @@ -2,7 +2,6 @@ import { useEffect, useRef, useState } from "react"; import { useLocation } from "react-router-dom"; import { Box, - Button, Divider, Group, Modal, @@ -10,6 +9,7 @@ import { Stack, Text, } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import Markdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; @@ -188,10 +188,10 @@ export default function LoginAgreementModal() { )} - - diff --git a/frontend/editor/src/core/components/shared/MultiSelectControls.tsx b/frontend/editor/src/core/components/shared/MultiSelectControls.tsx index 447662e9dd..160346dd1b 100644 --- a/frontend/editor/src/core/components/shared/MultiSelectControls.tsx +++ b/frontend/editor/src/core/components/shared/MultiSelectControls.tsx @@ -1,4 +1,5 @@ -import { Box, Group, Text, Button } from "@mantine/core"; +import { Box, Group, Text } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; interface MultiSelectControlsProps { @@ -36,20 +37,20 @@ const MultiSelectControls = ({ {selectedCount} {t("fileManager.filesSelected", "files")} - {onAddToUpload && ( - )} {onOpenInFileEditor && ( )} diff --git a/frontend/editor/src/core/components/shared/NavigationWarningModal.tsx b/frontend/editor/src/core/components/shared/NavigationWarningModal.tsx index 9917f37ac5..7e3c675f3b 100644 --- a/frontend/editor/src/core/components/shared/NavigationWarningModal.tsx +++ b/frontend/editor/src/core/components/shared/NavigationWarningModal.tsx @@ -1,5 +1,6 @@ import { useRef, useEffect } from "react"; -import { Modal, Text, Button, Group, Stack } from "@mantine/core"; +import { Modal, Text, Group, Stack } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useNavigationGuard } from "@app/contexts/NavigationContext"; import { useTranslation } from "react-i18next"; import ArrowBackIcon from "@mui/icons-material/ArrowBack"; @@ -103,10 +104,12 @@ const NavigationWarningModal = () => { {hasApply && ( {hasApply && ( + )}
    diff --git a/frontend/editor/src/core/components/shared/PolicyBadges.css b/frontend/editor/src/core/components/shared/PolicyBadges.css new file mode 100644 index 0000000000..d193cc752e --- /dev/null +++ b/frontend/editor/src/core/components/shared/PolicyBadges.css @@ -0,0 +1,62 @@ +/* Canonical policy badge styling — every per-file policy badge in the app + * (sidebar, thumbnails, files page, viewer indicator) uses these classes so + * colour and shape stay consistent. `color` is set inline to the policy's + * accent; the background tint follows it. */ + +.policy-badges { + display: inline-flex; + align-items: center; + gap: 3px; + flex-shrink: 0; +} + +.policy-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 15px; + height: 15px; + border-radius: 4px; + color: var(--text-secondary); + background: color-mix(in srgb, currentColor 16%, transparent); + pointer-events: auto; +} + +/* Larger variant for standalone indicators (e.g. the minimised viewer overlay). */ +.policy-badge--lg { + width: 28px; + height: 28px; + border-radius: 8px; + box-shadow: var(--shadow-md); +} + +.policy-badge--enforcing svg { + animation: policy-badge-spin 1s linear infinite; +} +@keyframes policy-badge-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.policy-badge--recent { + animation: policy-badge-pulse 4.5s ease-in-out forwards; +} +@keyframes policy-badge-pulse { + 0%, + 24%, + 48% { + box-shadow: 0 0 0 0 transparent; + } + 12%, + 36% { + box-shadow: 0 0 5px 2px currentColor; + } + 60%, + 100% { + box-shadow: 0 0 0 0 transparent; + } +} diff --git a/frontend/editor/src/core/components/shared/PolicyBadges.tsx b/frontend/editor/src/core/components/shared/PolicyBadges.tsx new file mode 100644 index 0000000000..f8661b9176 --- /dev/null +++ b/frontend/editor/src/core/components/shared/PolicyBadges.tsx @@ -0,0 +1,72 @@ +import { Tooltip } from "@mantine/core"; +import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; +import AutorenewIcon from "@mui/icons-material/Autorenew"; +import { useTranslation } from "react-i18next"; +import "@app/components/shared/PolicyBadges.css"; + +/** A policy that has run on this file, used for the activity badges. */ +export interface FileItemPolicyRef { + id: string; + name: string; + /** CSS colour for the badge (matches the policy's accent). */ + accentColor: string; + /** True only just after the policy was applied — drives the one-off glow, so + * it doesn't replay on every reload of an already-enforced file. */ + recent: boolean; + /** True while the policy run is actively in-flight on this file. */ + enforcing?: boolean; +} + +const MAX_VISIBLE = 3; + +/** + * The canonical policy badge row: one accent-tinted shield per policy that has + * run on a file, spinning while a run is in flight, glowing briefly after it + * lands. Every surface that shows per-file policy badges (file sidebar, file + * editor thumbnails, files page) renders this so they stay identical. + */ +export function PolicyBadges({ + policies, + className, +}: { + policies: FileItemPolicyRef[]; + /** Appended to the row for surface-specific layout (spacing only). */ + className?: string; +}) { + const { t } = useTranslation(); + if (policies.length === 0) return null; + return ( + + {policies.slice(0, MAX_VISIBLE).map((policy) => ( + + + {policy.enforcing ? ( + + ) : ( + + )} + + + ))} + + ); +} diff --git a/frontend/editor/src/core/components/shared/PolicyEnforcingOverlay.tsx b/frontend/editor/src/core/components/shared/PolicyEnforcingOverlay.tsx new file mode 100644 index 0000000000..5778d323aa --- /dev/null +++ b/frontend/editor/src/core/components/shared/PolicyEnforcingOverlay.tsx @@ -0,0 +1,7 @@ +export function PolicyEnforcingOverlay(_props: { + enforcing: boolean; + progress?: number; + zIndex?: number; +}) { + return null; +} diff --git a/frontend/editor/src/core/components/shared/ShareFileModal.tsx b/frontend/editor/src/core/components/shared/ShareFileModal.tsx index 2f205326e3..1430f9df20 100644 --- a/frontend/editor/src/core/components/shared/ShareFileModal.tsx +++ b/frontend/editor/src/core/components/shared/ShareFileModal.tsx @@ -3,13 +3,13 @@ import { Modal, Stack, Text, - Button, Group, Alert, TextInput, Paper, Select, } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import LinkIcon from "@mui/icons-material/Link"; import ContentCopyRoundedIcon from "@mui/icons-material/ContentCopyRounded"; import { useTranslation } from "react-i18next"; @@ -253,8 +253,8 @@ const ShareFileModal: React.FC = ({ label={t("storageShare.linkLabel", "Share link")} rightSection={ + )} ); diff --git a/frontend/editor/src/core/components/shared/ToolPanelHeader.tsx b/frontend/editor/src/core/components/shared/ToolPanelHeader.tsx index e34ca4c6c2..9d5f921bec 100644 --- a/frontend/editor/src/core/components/shared/ToolPanelHeader.tsx +++ b/frontend/editor/src/core/components/shared/ToolPanelHeader.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { ActionIcon } from "@mantine/core"; +import { ActionIcon } from "@app/ui/ActionIcon"; import CloseIcon from "@mui/icons-material/Close"; import "@app/components/shared/ToolPanelHeader.css"; @@ -38,12 +38,12 @@ export function ToolPanelHeader({ {onClose && ( diff --git a/frontend/editor/src/core/components/shared/Tooltip.tsx b/frontend/editor/src/core/components/shared/Tooltip.tsx index 12232effc1..9c94bc4726 100644 --- a/frontend/editor/src/core/components/shared/Tooltip.tsx +++ b/frontend/editor/src/core/components/shared/Tooltip.tsx @@ -7,6 +7,7 @@ import React, { } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; +import { ActionIcon } from "@app/ui/ActionIcon"; import LocalIcon from "@app/components/shared/LocalIcon"; import { addEventListenerWithCleanup } from "@app/utils/genericUtils"; import { useTooltipPosition } from "@app/hooks/useTooltipPosition"; @@ -396,7 +397,8 @@ export const Tooltip: React.FC = ({ } > {shouldShowCloseButton && ( - + )} {arrow && !sidebarTooltip && (
    diff --git a/frontend/editor/src/core/components/shared/UpdateModal.tsx b/frontend/editor/src/core/components/shared/UpdateModal.tsx index c18549360c..4a5e5b2404 100644 --- a/frontend/editor/src/core/components/shared/UpdateModal.tsx +++ b/frontend/editor/src/core/components/shared/UpdateModal.tsx @@ -4,7 +4,6 @@ import { Stack, Text, Badge, - Button, Group, Loader, Center, @@ -13,9 +12,11 @@ import { Progress, Alert, Divider, - CloseButton, Anchor, } from "@mantine/core"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; +import LocalIcon from "@app/components/shared/LocalIcon"; import { useTranslation } from "react-i18next"; import { updateService, @@ -271,12 +272,14 @@ const UpdateModal: React.FC = ({ {canClose && ( - + > + + )} @@ -520,12 +523,12 @@ const UpdateModal: React.FC = ({ )} +
    )} - {(hasFiles || isCustomView) && - viewOptions.map((opt) => ( - - ))} + {(hasFiles || isCustomView) && ( + + className="workbench-bar-views" + size="sm" + value={currentView} + onChange={setCurrentView} + variant="secondary" + options={viewOptions.map((opt) => ({ + value: opt.value, + label: ( + <> + {opt.icon} + {opt.label} + + ), + }))} + /> + )}
    {/* Tool buttons - second row, only rendered when buttons exist */} @@ -493,31 +560,40 @@ export default function WorkbenchBar({ {currentView === "viewer" && renderWithTooltip( , - t("workbenchBar.print", "Print PDF"), + policyEnforcing + ? makeEnforcingTooltip(t("workbenchBar.print", "Print PDF")) + : t("workbenchBar.print", "Print PDF"), )} {/* Download (file-level action — not relevant in custom views) */} {!isCustomView && renderWithTooltip( handleExportAll()} disabled={ - disableForFullscreen || totalItems === 0 || allButtonsDisabled + disableForFullscreen || + totalItems === 0 || + allButtonsDisabled || + policyEnforcing } + aria-label={downloadTooltip} > , - downloadTooltip, + policyEnforcing + ? makeEnforcingTooltip(downloadTooltip) + : downloadTooltip, )} {/* Save As */} @@ -533,13 +611,17 @@ export default function WorkbenchBar({ icons.saveAsIconName && renderWithTooltip( handleExportAll(true)} disabled={ - disableForFullscreen || totalItems === 0 || allButtonsDisabled + disableForFullscreen || + totalItems === 0 || + allButtonsDisabled || + policyEnforcing } + aria-label={t("workbenchBar.saveAs", "Save As")} > , - t("workbenchBar.saveAs", "Save As"), + policyEnforcing + ? makeEnforcingTooltip(t("workbenchBar.saveAs", "Save As")) + : t("workbenchBar.saveAs", "Save As"), )} {/* Separator: export group | close */} @@ -559,8 +643,8 @@ export default function WorkbenchBar({ {!isCustomView && renderWithTooltip( @@ -77,20 +80,22 @@ const ZipWarningModal = ({ {/* Mobile layout: vertical stack */} diff --git a/frontend/editor/src/core/components/shared/config/RestartConfirmationModal.tsx b/frontend/editor/src/core/components/shared/config/RestartConfirmationModal.tsx index af96918154..f4e9c47b0a 100644 --- a/frontend/editor/src/core/components/shared/config/RestartConfirmationModal.tsx +++ b/frontend/editor/src/core/components/shared/config/RestartConfirmationModal.tsx @@ -1,4 +1,5 @@ -import { Modal, Text, Group, Button, Stack } from "@mantine/core"; +import { Modal, Text, Group, Stack } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import RefreshIcon from "@mui/icons-material/Refresh"; import ScheduleIcon from "@mui/icons-material/Schedule"; @@ -48,14 +49,13 @@ export default function RestartConfirmationModal({ diff --git a/frontend/editor/src/core/components/shared/signing/steps/SelectDocumentStep.tsx b/frontend/editor/src/core/components/shared/signing/steps/SelectDocumentStep.tsx index aaedc076d3..14bd56f75c 100644 --- a/frontend/editor/src/core/components/shared/signing/steps/SelectDocumentStep.tsx +++ b/frontend/editor/src/core/components/shared/signing/steps/SelectDocumentStep.tsx @@ -1,4 +1,5 @@ -import { Button, Stack, Text } from "@mantine/core"; +import { Stack, Text } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; import type { FileState } from "@app/types/file"; diff --git a/frontend/editor/src/core/components/shared/signing/steps/SelectParticipantsStep.tsx b/frontend/editor/src/core/components/shared/signing/steps/SelectParticipantsStep.tsx index 3baa15b7ba..99c0801519 100644 --- a/frontend/editor/src/core/components/shared/signing/steps/SelectParticipantsStep.tsx +++ b/frontend/editor/src/core/components/shared/signing/steps/SelectParticipantsStep.tsx @@ -1,4 +1,5 @@ -import { Button, Stack, Text, Group } from "@mantine/core"; +import { Stack, Text, Group } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import UserSelector from "@app/components/shared/UserSelector"; @@ -53,7 +54,7 @@ export const SelectParticipantsStep: React.FC = ({ + )} - + × + {/* Progress bar - always show when present */} @@ -142,12 +146,12 @@ export default function ToastRenderer() { {/* Button - always show when present, positioned below body */} {t.buttonText && t.buttonCallback && (
    - +
    )} diff --git a/frontend/editor/src/core/components/tools/RightSidebar.tsx b/frontend/editor/src/core/components/tools/RightSidebar.tsx index 7e3aa04629..4333e263f1 100644 --- a/frontend/editor/src/core/components/tools/RightSidebar.tsx +++ b/frontend/editor/src/core/components/tools/RightSidebar.tsx @@ -1,5 +1,4 @@ import { useMemo, useState } from "react"; -import { ActionIcon } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { useSidebarContext } from "@app/contexts/SidebarContext"; @@ -21,6 +20,7 @@ import type { SubcategoryGroup } from "@app/hooks/useToolSections"; import { ToolIcon } from "@app/components/shared/ToolIcon"; import { ToolPanelHeader } from "@app/components/shared/ToolPanelHeader"; import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip"; +import { ActionIcon } from "@app/ui/ActionIcon"; import { withViewTransition } from "@app/utils/viewTransition"; import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; import ChevronRightIcon from "@mui/icons-material/ChevronRight"; @@ -224,13 +224,13 @@ export default function RightSidebar() {
    @@ -248,18 +248,18 @@ export default function RightSidebar() { arrow delay={300} > - + ))}
    @@ -322,10 +322,9 @@ export default function RightSidebar() { ) : null} {showCloseButton ? ( ) : ( diff --git a/frontend/editor/src/core/components/tools/ToolPanel.css b/frontend/editor/src/core/components/tools/ToolPanel.css index e5949e365a..a8ad41588f 100644 --- a/frontend/editor/src/core/components/tools/ToolPanel.css +++ b/frontend/editor/src/core/components/tools/ToolPanel.css @@ -130,6 +130,7 @@ width 0.3s ease, max-width 0.3s ease; view-transition-name: tool-rail; + user-select: none; } .tool-panel__collapsed-strip { diff --git a/frontend/editor/src/core/components/tools/ToolPanelModePrompt.tsx b/frontend/editor/src/core/components/tools/ToolPanelModePrompt.tsx index 3e3f072b69..f7f69ad2e7 100644 --- a/frontend/editor/src/core/components/tools/ToolPanelModePrompt.tsx +++ b/frontend/editor/src/core/components/tools/ToolPanelModePrompt.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; -import { Badge, Button, Card, Group, Modal, Stack, Text } from "@mantine/core"; +import { Badge, Card, Group, Modal, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui/Button"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { usePreferences } from "@app/contexts/PreferencesContext"; import "@app/components/tools/ToolPanelModePrompt.css"; @@ -122,9 +123,6 @@ const ToolPanelModePrompt = ({
    + {parameters.attachments?.length > 0 && ( @@ -120,10 +110,17 @@ const AddAttachmentsSettings = ({ ({(file.size / 1024).toFixed(1)} KB) - + } + aria-label={t( + "AddAttachmentsRequest.removeFile", + "Remove file", + )} size="sm" - variant="subtle" - color="red" + variant="tertiary" + accent="danger" style={{ flexShrink: 0 }} onClick={() => { const newAttachments = ( @@ -132,9 +129,7 @@ const AddAttachmentsSettings = ({ onParameterChange("attachments", newAttachments); }} disabled={disabled} - > - - + /> ))} diff --git a/frontend/editor/src/core/components/tools/addPageNumbers/AddPageNumbersAppearanceSettings.tsx b/frontend/editor/src/core/components/tools/addPageNumbers/AddPageNumbersAppearanceSettings.tsx index 627f7c1b9e..11ca767bca 100644 --- a/frontend/editor/src/core/components/tools/addPageNumbers/AddPageNumbersAppearanceSettings.tsx +++ b/frontend/editor/src/core/components/tools/addPageNumbers/AddPageNumbersAppearanceSettings.tsx @@ -27,6 +27,7 @@ const AddPageNumbersAppearanceSettings = ({ return ( 001). Set 0 to disable.", @@ -90,6 +93,7 @@ const AddPageNumbersAppearanceSettings = ({ onParameterChange( @@ -277,7 +278,7 @@ export default function PageNumberPreview({ }} > {idx} - + ); })} diff --git a/frontend/editor/src/core/components/tools/addPageNumbers/useAddPageNumbersOperation.ts b/frontend/editor/src/core/components/tools/addPageNumbers/useAddPageNumbersOperation.ts index f9bf73f4ce..ba8ca13b32 100644 --- a/frontend/editor/src/core/components/tools/addPageNumbers/useAddPageNumbersOperation.ts +++ b/frontend/editor/src/core/components/tools/addPageNumbers/useAddPageNumbersOperation.ts @@ -1,39 +1,86 @@ import { useTranslation } from "react-i18next"; import { - ToolType, useToolOperation, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + objectToFormData, + type ToolApiParams, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import { AddPageNumbersParameters, defaultParameters, } from "@app/components/tools/addPageNumbers/useAddPageNumbersParameters"; +const ENDPOINT = "/api/v1/misc/add-page-numbers" satisfies ToolEndpoint; +type AddPageNumbersApiParams = ToolApiParams[typeof ENDPOINT]; + +// The UI labels fonts capitalized while the backend model uses lowercase; these +// maps translate between them so both mappers type-check without casting. +const FONT_TYPE_TO_API = { + Times: "times", + Helvetica: "helvetica", + Courier: "courier", +} as const satisfies Record< + AddPageNumbersParameters["fontType"], + AddPageNumbersApiParams["fontType"] +>; +const FONT_TYPE_FROM_API = { + times: "Times", + helvetica: "Helvetica", + courier: "Courier", +} as const satisfies Record< + AddPageNumbersApiParams["fontType"], + AddPageNumbersParameters["fontType"] +>; + +// Convert the tool's UI parameters into the add-page-numbers request body. The +// return type is the generated backend model, so a spec change that renames or +// drops a field breaks the build here. +export const addPageNumbersToApiParams = ( + parameters: AddPageNumbersParameters, +): AddPageNumbersApiParams => ({ + customMargin: parameters.customMargin, + position: parameters.position, + fontSize: parameters.fontSize, + fontType: FONT_TYPE_TO_API[parameters.fontType], + startingNumber: parameters.startingNumber, + pagesToNumber: parameters.pagesToNumber, + customText: parameters.customText, + zeroPad: parameters.zeroPad, +}); + +// Reconstruct the tool's UI parameters from an add-page-numbers request body, +// so a stored or AI-authored step can be re-rendered in the settings UI. +export const addPageNumbersFromApiParams = ( + apiParams: AddPageNumbersApiParams, +): Partial => ({ + customMargin: apiParams.customMargin, + position: apiParams.position, + fontSize: apiParams.fontSize, + fontType: FONT_TYPE_FROM_API[apiParams.fontType], + startingNumber: apiParams.startingNumber, + pagesToNumber: apiParams.pagesToNumber, + customText: apiParams.customText, + zeroPad: apiParams.zeroPad, +}); + export const buildAddPageNumbersFormData = ( parameters: AddPageNumbersParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - formData.append("customMargin", parameters.customMargin); - formData.append("position", String(parameters.position)); - formData.append("fontSize", String(parameters.fontSize)); - formData.append("fontType", parameters.fontType); - formData.append("startingNumber", String(parameters.startingNumber)); - formData.append("pagesToNumber", parameters.pagesToNumber); - formData.append("customText", parameters.customText); - formData.append("zeroPad", String(parameters.zeroPad)); +): FormData => + objectToFormData(addPageNumbersToApiParams(parameters), { fileInput: file }); - return formData; -}; - -export const addPageNumbersOperationConfig = { - toolType: ToolType.singleFile, +export const addPageNumbersOperationConfig = defineSingleFileTool({ buildFormData: buildAddPageNumbersFormData, + toApiParams: addPageNumbersToApiParams, + fromApiParams: addPageNumbersFromApiParams, operationType: "addPageNumbers", - endpoint: "/api/v1/misc/add-page-numbers", + endpoint: ENDPOINT, defaultParameters, -} as const; +}); export const useAddPageNumbersOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/components/tools/addStamp/StampPositionFormattingSettings.tsx b/frontend/editor/src/core/components/tools/addStamp/StampPositionFormattingSettings.tsx index 7912cd0bcd..76f8be4109 100644 --- a/frontend/editor/src/core/components/tools/addStamp/StampPositionFormattingSettings.tsx +++ b/frontend/editor/src/core/components/tools/addStamp/StampPositionFormattingSettings.tsx @@ -4,7 +4,6 @@ import { Select, Stack, ColorInput, - Button, Slider, Text, NumberInput, @@ -13,6 +12,8 @@ import { AddStampParameters } from "@app/components/tools/addStamp/useAddStampPa import LocalIcon from "@app/components/shared/LocalIcon"; import styles from "@app/components/tools/addStamp/StampPreview.module.css"; import { Tooltip } from "@app/components/shared/Tooltip"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex"; interface StampPositionFormattingSettingsProps { @@ -55,7 +56,7 @@ const StampPositionFormattingSettings = ({ return ( + - + - + diff --git a/frontend/editor/src/core/components/tools/addStamp/StampPreview.tsx b/frontend/editor/src/core/components/tools/addStamp/StampPreview.tsx index 9659c4317a..88f2d72f6c 100644 --- a/frontend/editor/src/core/components/tools/addStamp/StampPreview.tsx +++ b/frontend/editor/src/core/components/tools/addStamp/StampPreview.tsx @@ -11,6 +11,7 @@ import { } from "@app/components/tools/addStamp/StampPreviewUtils"; import styles from "@app/components/tools/addStamp/StampPreview.module.css"; import { PrivateContent } from "@app/components/shared/PrivateContent"; +import { Button } from "@app/ui/Button"; type Props = { parameters: AddStampParameters; @@ -429,9 +430,9 @@ export default function StampPreview({ parameters.position === idx && (parameters.overrideX < 0 || parameters.overrideY < 0); return ( - + ); })} diff --git a/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.tsx b/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.tsx index 1625affa59..4c7ee96c5b 100644 --- a/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.tsx +++ b/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.tsx @@ -5,7 +5,6 @@ import { Textarea, TextInput, Select, - Button, Text, Divider, Accordion, @@ -15,6 +14,7 @@ import { Box, Paper, } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { AddStampParameters } from "@app/components/tools/addStamp/useAddStampParameters"; import ButtonSelector from "@app/components/shared/ButtonSelector"; import styles from "@app/components/tools/addStamp/StampPreview.module.css"; @@ -236,7 +236,6 @@ const StampSetupSettings = ({ { value: "image", label: t("watermark.type.2", "Image") }, ]} disabled={disabled} - buttonClassName={styles.modeToggleButton} textClassName={styles.modeToggleButtonText} /> @@ -673,8 +672,8 @@ const StampSetupSettings = ({ id="stamp-image-input" /> - diff --git a/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx b/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx index de8c82d449..322d2d87fc 100644 --- a/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx +++ b/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx @@ -1,6 +1,7 @@ import React, { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Group, Text, ActionIcon, Menu, Button, Box } from "@mantine/core"; +import { Group, Text, Menu, Box } from "@mantine/core"; +import { Button as SharedButton } from "@app/ui/Button"; import MoreVertIcon from "@mui/icons-material/MoreVert"; import EditIcon from "@mui/icons-material/Edit"; import DeleteIcon from "@mui/icons-material/Delete"; @@ -181,31 +182,24 @@ export default function AutomationEntry({ onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > - + {showMenu && ( setIsMenuOpen(false)} > - } + variant="tertiary" + accent="neutral" size="md" aria-label={t( "automate.entryMenu.label", @@ -234,9 +229,7 @@ export default function AutomationEntry({ transition: "opacity 0.2s ease", pointerEvents: shouldShowMenu ? "auto" : "none", }} - > - - + /> diff --git a/frontend/editor/src/core/components/tools/automate/AutomationImportModal.tsx b/frontend/editor/src/core/components/tools/automate/AutomationImportModal.tsx index 95e85e8026..c8c20cf607 100644 --- a/frontend/editor/src/core/components/tools/automate/AutomationImportModal.tsx +++ b/frontend/editor/src/core/components/tools/automate/AutomationImportModal.tsx @@ -3,13 +3,13 @@ import { useTranslation } from "react-i18next"; import { Alert, Badge, - Button, Group, Modal, Stack, Text, Textarea, } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { Dropzone } from "@mantine/dropzone"; import UploadFileIcon from "@mui/icons-material/UploadFile"; import { Z_INDEX_AUTOMATE_MODAL } from "@app/styles/zIndex"; @@ -218,7 +218,7 @@ export default function AutomationImportModal({ )} - )} diff --git a/frontend/editor/src/core/components/tools/automate/IconSelector.tsx b/frontend/editor/src/core/components/tools/automate/IconSelector.tsx index cd67d1487f..1b0909cbc0 100644 --- a/frontend/editor/src/core/components/tools/automate/IconSelector.tsx +++ b/frontend/editor/src/core/components/tools/automate/IconSelector.tsx @@ -1,14 +1,7 @@ import React, { useState } from "react"; import { useTranslation } from "react-i18next"; -import { - Box, - Text, - Stack, - Button, - SimpleGrid, - Tooltip, - Popover, -} from "@mantine/core"; +import { Box, Text, Stack, SimpleGrid, Tooltip, Popover } from "@mantine/core"; +import { ActionIcon } from "@app/ui/ActionIcon"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import { iconMap, iconOptions } from "@app/components/tools/automate/iconMap"; import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex"; @@ -58,12 +51,13 @@ export default function IconSelector({ zIndex={Z_INDEX_AUTOMATE_DROPDOWN} > - + diff --git a/frontend/editor/src/core/components/tools/automate/ToolConfigurationModal.tsx b/frontend/editor/src/core/components/tools/automate/ToolConfigurationModal.tsx index b70f6b925e..93631e087c 100644 --- a/frontend/editor/src/core/components/tools/automate/ToolConfigurationModal.tsx +++ b/frontend/editor/src/core/components/tools/automate/ToolConfigurationModal.tsx @@ -1,15 +1,7 @@ import { Suspense, useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { - Modal, - Title, - Button, - Group, - Stack, - Text, - Alert, - Loader, -} from "@mantine/core"; +import { Modal, Title, Group, Stack, Text, Alert, Loader } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { Z_INDEX_AUTOMATE_MODAL } from "@app/styles/zIndex"; import SettingsIcon from "@mui/icons-material/Settings"; import CheckIcon from "@mui/icons-material/Check"; @@ -125,7 +117,7 @@ export default function ToolConfigurationModal({ {isServerCertificateEnabled && ( )} {isHardwareAvailable && ( diff --git a/frontend/editor/src/core/components/tools/certSign/HardwareCertificateSettings.tsx b/frontend/editor/src/core/components/tools/certSign/HardwareCertificateSettings.tsx index 820636af35..b384ce8d5c 100644 --- a/frontend/editor/src/core/components/tools/certSign/HardwareCertificateSettings.tsx +++ b/frontend/editor/src/core/components/tools/certSign/HardwareCertificateSettings.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import { Alert, - Button, Group, Loader, NumberInput, @@ -10,6 +9,7 @@ import { Text, TextInput, } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import { CertSignParameters } from "@app/hooks/tools/certSign/useCertSignParameters"; import { @@ -284,22 +284,34 @@ const HardwareCertificateSettings = ({ {supported.windows && supported.pkcs11 && (
    @@ -332,7 +344,7 @@ const HardwareCertificateSettings = ({ )} />
    removeSignature(sig.id)} aria-label={t( @@ -177,7 +172,7 @@ export const SelectSignatureModal: React.FC = ({ - diff --git a/frontend/editor/src/core/components/tools/certSign/panels/SignControlsPanel.tsx b/frontend/editor/src/core/components/tools/certSign/panels/SignControlsPanel.tsx index 7945c697f4..15fe50eb90 100644 --- a/frontend/editor/src/core/components/tools/certSign/panels/SignControlsPanel.tsx +++ b/frontend/editor/src/core/components/tools/certSign/panels/SignControlsPanel.tsx @@ -1,15 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - ActionIcon, - Box, - Button, - Group, - Menu, - Modal, - SegmentedControl, - Stack, - Text, -} from "@mantine/core"; +import { Box, Group, Menu, Modal, Stack, Text } from "@mantine/core"; +import { ActionIcon } from "@app/ui/ActionIcon"; +import { Button } from "@app/ui/Button"; +import { SegmentedControl } from "@app/ui/SegmentedControl"; import { useTranslation } from "react-i18next"; import DrawIcon from "@mui/icons-material/Draw"; import OpenWithIcon from "@mui/icons-material/OpenWith"; @@ -363,11 +356,10 @@ export default function SignControlsPanel({ ) : ( ) : ( - setDropdownOpened(!dropdownOpened)} @@ -106,7 +108,7 @@ const GroupedFormatDropdown = ({ : "var(--dropdown-trigger-text)", }} > - + {selectedLabel} @@ -119,7 +121,7 @@ const GroupedFormatDropdown = ({ }} /> - + handleOptionSelect(option.value)} disabled={option.enabled === false} + rightSection={ + option.usesCloud ? ( + + ) : undefined + } style={{ fontSize: "0.75rem", height: "2rem", @@ -160,15 +173,6 @@ const GroupedFormatDropdown = ({ }} > {option.label} - {option.usesCloud && ( - - )} ))} diff --git a/frontend/editor/src/core/components/tools/crop/CropSettings.tsx b/frontend/editor/src/core/components/tools/crop/CropSettings.tsx index 65b0e8f0fe..710391dd6c 100644 --- a/frontend/editor/src/core/components/tools/crop/CropSettings.tsx +++ b/frontend/editor/src/core/components/tools/crop/CropSettings.tsx @@ -4,11 +4,11 @@ import { Text, Box, Group, - ActionIcon, Center, Alert, Checkbox, } from "@mantine/core"; +import { ActionIcon } from "@app/ui/ActionIcon"; import { useTranslation } from "react-i18next"; import RestartAltIcon from "@mui/icons-material/RestartAlt"; import { CropParametersHook } from "@app/hooks/tools/crop/useCropParameters"; @@ -193,7 +193,7 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => { {t("crop.preview.title", "Crop Area Selection")} { e.preventDefault(); if (hasChildren) handleToggle(bookmark.id); @@ -272,13 +271,17 @@ export default function BookmarkEditor({ )} > { e.preventDefault(); handleAddChild(bookmark.id); }} disabled={disabled} + aria-label={t( + "editTableOfContents.editor.actions.addChild", + "Add child bookmark", + )} > @@ -290,13 +293,16 @@ export default function BookmarkEditor({ )} > { e.preventDefault(); handleAddSibling(bookmark.id); }} disabled={disabled} + aria-label={t( + "editTableOfContents.editor.actions.addSibling", + "Add sibling bookmark", + )} > @@ -308,13 +314,17 @@ export default function BookmarkEditor({ )} > { e.preventDefault(); handleRemove(bookmark.id); }} disabled={disabled} + aria-label={t( + "editTableOfContents.editor.actions.remove", + "Remove bookmark", + )} > @@ -399,8 +409,7 @@ export default function BookmarkEditor({ - + - + - - file && onImportJson(file)} accept="application/json" disabled={disabled} + variant="secondary" + leftSection={} + fullWidth > - {(props) => ( - - )} - - + {t("editTableOfContents.actions.importJson", "Import JSON")} + - + @@ -184,16 +167,15 @@ export default function EditTableOfContentsSettings({ - - + - + diff --git a/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx b/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx index 43702d5c65..148917dba6 100644 --- a/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx +++ b/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx @@ -1,6 +1,7 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Box, Button, Card, Divider, Group, Stack, Text } from "@mantine/core"; +import { Box, Card, Divider, Group, Stack, Text } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import LocalIcon from "@app/components/shared/LocalIcon"; import { BookmarkNode } from "@app/utils/editTableOfContents"; import ErrorNotification from "@app/components/tools/shared/ErrorNotification"; @@ -150,7 +151,6 @@ const EditTableOfContentsWorkbenchView = ({ )} + ); const { key: disabledKey, fallback: disabledFallback } = diff --git a/frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.tsx b/frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.tsx index 32fa181a16..464403e286 100644 --- a/frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.tsx +++ b/frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.tsx @@ -14,6 +14,7 @@ import { useToolMeta, getDisabledLabel, } from "@app/components/tools/fullscreen/shared"; +import { Button } from "@app/ui/Button"; interface DetailedToolItemProps { id: string; @@ -56,8 +57,8 @@ const DetailedToolItem: React.FC = ({ const disabledMessage = t(disabledKey, disabledFallback); return ( - + ); }; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.tsx index 7c1c5fe423..487252ae81 100644 --- a/frontend/editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.tsx +++ b/frontend/editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo } from "react"; -import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core"; +import { Alert, Group, Loader, Stack, Text } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import type { GetPdfInfoOperationHook } from "@app/hooks/tools/getPdfInfo/useGetPdfInfoOperation"; import { downloadFile } from "@app/services/downloadService"; @@ -72,7 +73,6 @@ const GetPdfInfoResults = ({ {t("getPdfInfo.downloads", "Downloads")} - diff --git a/frontend/editor/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx b/frontend/editor/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx index cc6389845a..0cbd88e619 100644 --- a/frontend/editor/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx +++ b/frontend/editor/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx @@ -7,11 +7,9 @@ import React, { useState, } from "react"; import { - ActionIcon, Alert, Badge, Box, - Button, Card, Divider, Group, @@ -24,6 +22,8 @@ import { Text, Tooltip, } from "@mantine/core"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; import { Dropzone } from "@mantine/dropzone"; import { useTranslation } from "react-i18next"; import AutorenewIcon from "@mui/icons-material/Autorenew"; @@ -1553,10 +1553,10 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => { {resizeHandle} {activeGroupId === groupId && ( { + ) : ( )} diff --git a/frontend/editor/src/core/components/tools/sign/SavedSignaturesSection.tsx b/frontend/editor/src/core/components/tools/sign/SavedSignaturesSection.tsx index 8b40b8721a..50cc3d2fe2 100644 --- a/frontend/editor/src/core/components/tools/sign/SavedSignaturesSection.tsx +++ b/frontend/editor/src/core/components/tools/sign/SavedSignaturesSection.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { - ActionIcon, Alert, Badge, Box, @@ -13,6 +12,7 @@ import { Tooltip, } from "@mantine/core"; import { LocalIcon } from "@app/components/shared/LocalIcon"; +import { ActionIcon } from "@app/ui/ActionIcon"; import { SavedSignature, SavedSignatureType, @@ -301,7 +301,7 @@ export const SavedSignaturesSection = ({ setActivePersonalIndex((prev) => Math.max(0, prev - 1)) @@ -315,7 +315,7 @@ export const SavedSignaturesSection = ({ /> setActivePersonalIndex((prev) => @@ -351,8 +351,7 @@ export const SavedSignaturesSection = ({ onUseSignature(activePersonalSignature)} disabled={disabled} @@ -365,8 +364,8 @@ export const SavedSignaturesSection = ({ onDeleteSignature(activePersonalSignature) @@ -432,7 +431,7 @@ export const SavedSignaturesSection = ({ setActiveSharedIndex((prev) => Math.max(0, prev - 1)) @@ -446,7 +445,7 @@ export const SavedSignaturesSection = ({ /> setActiveSharedIndex((prev) => @@ -478,8 +477,7 @@ export const SavedSignaturesSection = ({ onUseSignature(activeSharedSignature)} disabled={disabled} @@ -493,8 +491,8 @@ export const SavedSignaturesSection = ({ {isAdmin && ( onDeleteSignature(activeSharedSignature) @@ -564,7 +562,7 @@ export const SavedSignaturesSection = ({ setActiveLocalStorageIndex((prev) => @@ -580,7 +578,7 @@ export const SavedSignaturesSection = ({ /> setActiveLocalStorageIndex((prev) => @@ -616,8 +614,7 @@ export const SavedSignaturesSection = ({ onUseSignature(activeLocalStorageSignature) @@ -632,8 +629,8 @@ export const SavedSignaturesSection = ({ onDeleteSignature(activeLocalStorageSignature) diff --git a/frontend/editor/src/core/components/tools/sign/SignSettings.tsx b/frontend/editor/src/core/components/tools/sign/SignSettings.tsx index de7a410b0d..648ada1c47 100644 --- a/frontend/editor/src/core/components/tools/sign/SignSettings.tsx +++ b/frontend/editor/src/core/components/tools/sign/SignSettings.tsx @@ -2,16 +2,15 @@ import { useTranslation } from "react-i18next"; import { Stack, - Button, Text, Alert, - SegmentedControl, Divider, - ActionIcon, Tooltip, Group, Box, } from "@mantine/core"; +import { Button } from "@app/ui/Button"; +import { SegmentedControl } from "@app/ui/SegmentedControl"; import { SignParameters } from "@app/hooks/tools/sign/useSignParameters"; import { useSignature } from "@app/contexts/SignatureContext"; import { useViewer } from "@app/contexts/ViewerContext"; @@ -437,9 +436,9 @@ const SignSettings = ({ const button = ( ) : ( - + } > - - - {translate("mode.resume", "Resume placement")} - - + {translate("mode.resume", "Resume placement")} + ) ) : null; @@ -1185,7 +1168,7 @@ const SignSettings = ({ onChange={(value) => handleSignatureSourceChange(value as SignatureSource) } - data={sourceOptions} + options={sourceOptions} /> )} {renderSignatureBuilder()} @@ -1228,7 +1211,7 @@ const SignSettings = ({ /> {onSave && ( - )} diff --git a/frontend/editor/src/core/components/tools/toolPicker/FavoriteStar.tsx b/frontend/editor/src/core/components/tools/toolPicker/FavoriteStar.tsx index d951951421..10bd556df9 100644 --- a/frontend/editor/src/core/components/tools/toolPicker/FavoriteStar.tsx +++ b/frontend/editor/src/core/components/tools/toolPicker/FavoriteStar.tsx @@ -1,31 +1,38 @@ import React from "react"; -import { ActionIcon } from "@mantine/core"; -import type { MantineSize } from "@mantine/core"; import { useTranslation } from "react-i18next"; import StarRoundedIcon from "@mui/icons-material/StarRounded"; import StarBorderRoundedIcon from "@mui/icons-material/StarBorderRounded"; - +import { ActionIcon } from "@app/ui/ActionIcon"; +import type { ActionIconSize } from "@app/ui/ActionIcon"; +type FavoriteStarSize = "xs" | ActionIconSize; +const SIZE_MAP: Record = { + xs: "sm", + sm: "sm", + md: "md", + lg: "lg", + xl: "xl", +}; interface FavoriteStarProps { isFavorite: boolean; onToggle: () => void; className?: string; - size?: MantineSize; + size?: FavoriteStarSize; } const FavoriteStar: React.FC = ({ isFavorite, onToggle, className, - size = "xs", + size = "sm", }) => { const { t } = useTranslation(); return ( { e.stopPropagation(); onToggle(); diff --git a/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx b/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx index e184ae6d58..efbe3bb2cb 100644 --- a/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx @@ -1,5 +1,6 @@ import React, { memo } from "react"; -import { Button, Badge } from "@mantine/core"; +import { Badge } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import { Tooltip } from "@app/components/shared/Tooltip"; import { ToolIcon } from "@app/components/shared/ToolIcon"; @@ -143,84 +144,83 @@ const ToolButton: React.FC = ({ ); - + const buttonIcon = ( + + ); const buttonContent = ( - <> - +
    -
    + {tool.versionStatus === "alpha" && ( + + {t("toolPanel.alpha", "Alpha")} + + )} + {typeof badgeCount === "number" && badgeCount > 0 && ( + + {badgeCount} + + )} + {usesCloud && !visuallyUnavailable && } +
    + {showDescription && tool.description && ( + + {tool.description} + + )} + {matchedSynonym && ( + - - {tool.versionStatus === "alpha" && ( - - {t("toolPanel.alpha", "Alpha")} - - )} - {typeof badgeCount === "number" && badgeCount > 0 && ( - - {badgeCount} - - )} - {usesCloud && !visuallyUnavailable && } -
    - {showDescription && tool.description && ( - - {tool.description} - - )} - {matchedSynonym && ( - - {matchedSynonym} - - )} -
    - + {matchedSynonym} +
    + )} + ); const handleExternalClick = (e: React.MouseEvent) => { @@ -234,24 +234,21 @@ const ToolButton: React.FC = ({ const buttonElement = navProps ? ( // For internal tools with URLs, render Button as an anchor for proper link behavior diff --git a/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx b/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx index 40bd9d3045..e9ae53183e 100644 --- a/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx +++ b/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx @@ -3,16 +3,15 @@ import { Box, ScrollArea, Text, - ActionIcon, Loader, Stack, TextInput, NumberInput, - Button, Group, - UnstyledButton, } from "@mantine/core"; import LocalIcon from "@app/components/shared/LocalIcon"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; import { useViewer } from "@app/contexts/ViewerContext"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { useFileContext } from "@app/contexts/FileContext"; @@ -20,6 +19,7 @@ import { isStirlingFile, type FileId } from "@app/types/fileContext"; import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers"; import apiClient from "@app/services/apiClient"; import { PdfBookmarkObject, PdfActionType } from "@embedpdf/models"; +import { useTranslation } from "react-i18next"; import BookmarksIcon from "@mui/icons-material/BookmarksRounded"; import "@app/components/viewer/SidebarBase.css"; import "@app/components/viewer/BookmarkSidebar.css"; @@ -93,6 +93,7 @@ export const BookmarkSidebar = ({ getScrollState, toggleBookmarkSidebar, } = useViewer(); + const { t } = useTranslation(); const { handleToolSelectForced } = useToolWorkflow(); const { selectors, actions: fileActions } = useFileContext(); const [expanded, setExpanded] = useState>({}); @@ -475,6 +476,34 @@ export const BookmarkSidebar = ({ })); }; + const expandAll = useCallback(() => { + const allExpanded: Record = {}; + const expandRecursive = (nodes: BookmarkNode[]) => { + nodes.forEach((node) => { + if (node.children && node.children.length > 0) { + allExpanded[node.id] = true; + expandRecursive(node.children as BookmarkNode[]); + } + }); + }; + expandRecursive(bookmarksWithIds); + setExpanded(allExpanded); + }, [bookmarksWithIds]); + + const collapseAll = useCallback(() => { + const allCollapsed: Record = {}; + const collapseRecursive = (nodes: BookmarkNode[]) => { + nodes.forEach((node) => { + if (node.children && node.children.length > 0) { + allCollapsed[node.id] = false; + collapseRecursive(node.children as BookmarkNode[]); + } + }); + }; + collapseRecursive(bookmarksWithIds); + setExpanded(allCollapsed); + }, [bookmarksWithIds]); + const handleBookmarkClick = ( bookmark: PdfBookmarkObject, event: React.MouseEvent, @@ -569,9 +598,10 @@ export const BookmarkSidebar = ({ > {hasChildren ? ( { event.stopPropagation(); toggleNode(node.id); @@ -654,12 +684,54 @@ export const BookmarkSidebar = ({ + {bookmarkSupport && bookmarksWithIds.length > 0 && ( + <> + {Object.values(expanded).some((val) => val === false) ? ( + + + + ) : ( + + + + )} + + )} @@ -706,7 +778,7 @@ export const BookmarkSidebar = ({ {currentError} - @@ -726,7 +798,6 @@ export const BookmarkSidebar = ({ )} - {showEmptyState && !isAddingBookmark && ( @@ -862,9 +930,12 @@ export const BookmarkSidebar = ({ flexShrink: 0, }} > - @@ -883,7 +954,7 @@ export const BookmarkSidebar = ({ Need to reorder or nest? Open the Bookmark Editor - + )} diff --git a/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx b/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx index cc470b3ea6..e25f9671d9 100644 --- a/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx +++ b/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx @@ -5,16 +5,15 @@ import { Text, Textarea, Stack, - ActionIcon, Group, Tooltip, TextInput, Menu, Modal, - Button, - UnstyledButton, } from "@mantine/core"; import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; import DeleteIcon from "@mui/icons-material/Delete"; import CheckIcon from "@mui/icons-material/CheckRounded"; import MoreHorizIcon from "@mui/icons-material/MoreHoriz"; @@ -120,7 +119,6 @@ function getCommentDisplayContent(entry: { /** Placeholder authors we never show; use current user's name from context instead. */ const PLACEHOLDER_AUTHORS = new Set(["Guest", "Digital Signature", ""]); - function getAuthorName( obj: Pick, currentDisplayName: string, @@ -188,7 +186,6 @@ function getIconByType(type: number | undefined): string { if (type === 15) return "edit"; return "comment"; } - function isCommentAnnotation(ann: PdfAnnotationObject): boolean { const customData = getStirlingAnnotationMetadata(ann).customData; const toolId = customData?.toolId ?? customData?.annotationToolId; @@ -252,7 +249,6 @@ function getAnnotationToolId(ann: PdfAnnotationObject): string { const customData = getStirlingAnnotationMetadata(ann).customData; return customData?.toolId ?? customData?.annotationToolId ?? ""; } - function getAnnotationTypeLabel( ann: PdfAnnotationObject, t: (key: string, fallback: string) => string, @@ -285,7 +281,6 @@ function getAnnotationTypeLabel( if (type === 1) return t("viewer.comments.typeComment", "Comment"); return t("viewer.comments.typeComment", "Comment"); } - function AnnotationTypeIcon({ ann }: { ann: PdfAnnotationObject }) { const toolId = getAnnotationToolId(ann); const iconName = TOOL_ICON_MAP[toolId] ?? getIconByType(ann?.type); @@ -714,9 +709,10 @@ export function CommentsSidebar({ @@ -727,7 +723,15 @@ export function CommentsSidebar({ - + @@ -746,11 +750,14 @@ export function CommentsSidebar({ )} {toggleCommentsSidebar && ( @@ -775,9 +782,9 @@ export function CommentsSidebar({ {isPlacingComment ? ( ) : ( @@ -943,9 +942,13 @@ export function CommentsSidebar({ )} > handleLocateAnnotation(pageIndex, ann) } @@ -962,9 +965,13 @@ export function CommentsSidebar({ )} > { handleSendMainComment( pageIndex, @@ -1043,9 +1053,7 @@ export function CommentsSidebar({ }} disabled={!(draft ?? "").trim()} > - + @@ -1112,7 +1120,9 @@ export function CommentsSidebar({ > {canEditReply && !isEditingReply ? ( - { setEditingReplyKey( @@ -1135,7 +1145,7 @@ export function CommentsSidebar({ "Edit", )} - + ) : null} {rTimestamp ? ( @@ -1178,9 +1188,12 @@ export function CommentsSidebar({ )} > handleSaveReplyEdit( replyEditKey, @@ -1196,7 +1209,6 @@ export function CommentsSidebar({ @@ -1252,13 +1264,12 @@ export function CommentsSidebar({ )} > handleSendReply( pageIndex, @@ -1268,9 +1279,7 @@ export function CommentsSidebar({ } disabled={!replyDraft.trim()} > - +
    @@ -1305,10 +1314,10 @@ export function CommentsSidebar({ )} - - - diff --git a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx index 24bc42b02e..1e21637849 100644 --- a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx @@ -6,7 +6,9 @@ import React, { useState, } from "react"; import { useTranslation } from "react-i18next"; -import { Box, Center, Text, ActionIcon, Button, Stack } from "@mantine/core"; +import { Box, Center, Text, Stack } from "@mantine/core"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; import CloseIcon from "@mui/icons-material/Close"; import LockIcon from "@mui/icons-material/Lock"; @@ -45,6 +47,7 @@ import { useWheelZoom } from "@app/hooks/useWheelZoom"; import { useFormFill } from "@app/tools/formFill/FormFillContext"; import { FormSaveBar } from "@app/tools/formFill/FormSaveBar"; import { useViewerKeyCommand } from "@app/hooks/useViewerKeyCommand"; +import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges"; import { alert } from "@app/components/toast"; // ─── Measure dictionary extraction ──────────────────────────────────────────── @@ -441,6 +444,15 @@ const EmbedPdfViewerContent = ({ const viewerKeyCommand = useViewerKeyCommand(); + const policyFileBadges = usePolicyFileBadges(); + const policyEnforcing = + !!activeFileId && + (policyFileBadges.get(activeFileId) ?? []).some((p) => p.enforcing); + // Use a ref so the keydown handler always reads the latest value without + // needing to be in the effect's dependency array. + const policyEnforcingRef = useRef(false); + policyEnforcingRef.current = policyEnforcing; + // Handle keyboard shortcuts useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -466,7 +478,9 @@ const EmbedPdfViewerContent = ({ case "p": case "P": event.preventDefault(); - printActions.print(); + if (!policyEnforcingRef.current) { + printActions.print(); + } return; case "a": case "A": @@ -1225,15 +1239,14 @@ const EmbedPdfViewerContent = ({ {/* Close Button - Only show in preview mode */} {onClose && previewFile && ( @@ -1261,7 +1274,6 @@ const EmbedPdfViewerContent = ({ )} - + {/* Navigate / Open */} - + ); }, diff --git a/frontend/editor/src/core/components/viewer/NonPdfViewer.tsx b/frontend/editor/src/core/components/viewer/NonPdfViewer.tsx index 1b54fa1680..ed4a2e2263 100644 --- a/frontend/editor/src/core/components/viewer/NonPdfViewer.tsx +++ b/frontend/editor/src/core/components/viewer/NonPdfViewer.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo } from "react"; -import { Box, Button, Center, Stack, Text } from "@mantine/core"; +import { Box, Center, Stack, Text } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import ArticleIcon from "@mui/icons-material/Article"; import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; @@ -81,8 +82,8 @@ export function NonPdfViewer({ file }: NonPdfViewerProps) { {isConvertAvailable && ( +
    )} {/* Previous Page Button */} - +
    {/* Page Input */} {/* Next Page Button */} - +
    {/* Last Page Button */} {!isPhone && ( - + )} {/* Dual Page Toggle */} @@ -261,21 +246,24 @@ export function PdfViewerToolbar({ position="top" arrow > - + )} @@ -292,11 +280,9 @@ export function PdfViewerToolbar({ position="top" arrow > - + )} @@ -323,9 +309,7 @@ export function PdfViewerToolbar({ style={{ marginLeft: 16, flexShrink: 0 }} > @@ -346,9 +330,7 @@ export function PdfViewerToolbar({ label={null} /> @@ -376,9 +358,7 @@ export function PdfViewerToolbar({ > @@ -172,14 +162,11 @@ function RedactionSelectionMenuInner({ position="top" > diff --git a/frontend/editor/src/core/components/viewer/SearchInterface.tsx b/frontend/editor/src/core/components/viewer/SearchInterface.tsx index f111837860..2c93aff17c 100644 --- a/frontend/editor/src/core/components/viewer/SearchInterface.tsx +++ b/frontend/editor/src/core/components/viewer/SearchInterface.tsx @@ -1,7 +1,8 @@ import React, { useState, useEffect, useRef } from "react"; -import { Box, TextInput, ActionIcon, Text, Group } from "@mantine/core"; +import { Box, TextInput, Text, Group } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { LocalIcon } from "@app/components/shared/LocalIcon"; +import { ActionIcon } from "@app/ui/ActionIcon"; import { ViewerContext } from "@app/contexts/ViewerContext"; interface SearchInterfaceProps { @@ -200,7 +201,7 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) { {t("search.title", "Search PDF")} @@ -275,7 +276,7 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) { diff --git a/frontend/editor/src/core/components/viewer/ThumbnailSidebar.tsx b/frontend/editor/src/core/components/viewer/ThumbnailSidebar.tsx index 94919eb4f2..3fb62df970 100644 --- a/frontend/editor/src/core/components/viewer/ThumbnailSidebar.tsx +++ b/frontend/editor/src/core/components/viewer/ThumbnailSidebar.tsx @@ -1,5 +1,7 @@ import { useState, useEffect, useRef } from "react"; -import { Box, ScrollArea, Text, ActionIcon } from "@mantine/core"; +import { Box, ScrollArea, Text } from "@mantine/core"; +import { ActionIcon } from "@app/ui/ActionIcon"; +import { useTranslation } from "react-i18next"; import { useViewer } from "@app/contexts/ViewerContext"; import { PrivateContent } from "@app/components/shared/PrivateContent"; import LocalIcon from "@app/components/shared/LocalIcon"; @@ -17,6 +19,7 @@ export function ThumbnailSidebar({ onToggle, activeFileId, }: ThumbnailSidebarProps) { + const { t } = useTranslation(); const { getScrollState, scrollActions, getThumbnailAPI } = useViewer(); const [thumbnails, setThumbnails] = useState<{ [key: number]: string }>({}); @@ -182,11 +185,14 @@ export function ThumbnailSidebar({ diff --git a/frontend/editor/src/core/components/viewer/ViewerAnnotationControls.tsx b/frontend/editor/src/core/components/viewer/ViewerAnnotationControls.tsx index 78888f4188..32b4b1ce11 100644 --- a/frontend/editor/src/core/components/viewer/ViewerAnnotationControls.tsx +++ b/frontend/editor/src/core/components/viewer/ViewerAnnotationControls.tsx @@ -1,7 +1,7 @@ import React, { useCallback } from "react"; -import { ActionIcon } from "@mantine/core"; import { useTranslation } from "react-i18next"; import LocalIcon from "@app/components/shared/LocalIcon"; +import { ActionIcon } from "@app/ui/ActionIcon"; import { Tooltip } from "@app/components/shared/Tooltip"; import { ViewerContext } from "@app/contexts/ViewerContext"; import { useSignature } from "@app/contexts/SignatureContext"; @@ -186,12 +186,15 @@ export default function ViewerAnnotationControls({ portalTarget={document.body} > s.id === activeFileId) : undefined; + const policyFileBadges = usePolicyFileBadges(); + const runs = usePolicyRuns(); + const enforcing = + !!activeFileId && + (policyFileBadges.get(activeFileId) ?? []).some((p) => p.enforcing); + const enforcingRun = enforcing + ? runs.find( + (r) => + r.fileId === activeFileId && + (POLICY_IN_FLIGHT_STATUSES as readonly string[]).includes(r.status), + ) + : undefined; + const enforcingProgress = + enforcingRun?.currentStep != null && enforcingRun.stepCount + ? Math.round((enforcingRun.currentStep / enforcingRun.stepCount) * 100) + : undefined; + const label = t("workbenchBar.share", "Share"); - const isDisabled = Boolean(disabled) || !stub; + const isDisabled = Boolean(disabled) || !stub || enforcing; + + const tooltipContent = enforcing ? ( + + + + + {t( + "policy.blockingAction", + "{{action}} blocked while enforcing policy, please wait", + { action: label }, + )} + + + {enforcingProgress != null ? ( + + ) : ( + + )} + + ) : ( + label + ); const openShare = (target: StirlingFileStub) => { setShareStub(target); @@ -123,7 +177,7 @@ export default function ViewerShareButton({ return ( <>
    + ); } diff --git a/frontend/editor/src/core/components/viewer/nonpdf/NonPdfBanner.tsx b/frontend/editor/src/core/components/viewer/nonpdf/NonPdfBanner.tsx index e1f169b85b..2d73a0f558 100644 --- a/frontend/editor/src/core/components/viewer/nonpdf/NonPdfBanner.tsx +++ b/frontend/editor/src/core/components/viewer/nonpdf/NonPdfBanner.tsx @@ -1,4 +1,4 @@ -import { Button } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; import { useTranslation } from "react-i18next"; @@ -13,9 +13,9 @@ export function NonPdfBanner({ onConvertToPdf }: NonPdfBannerProps) { return ( {!deletePromptIsServerOnly && ( {!deletePromptIsServerOnly && ( - diff --git a/frontend/editor/src/core/contexts/file/fileActions.ts b/frontend/editor/src/core/contexts/file/fileActions.ts index 749e820e41..86483baf32 100644 --- a/frontend/editor/src/core/contexts/file/fileActions.ts +++ b/frontend/editor/src/core/contexts/file/fileActions.ts @@ -254,6 +254,9 @@ interface AddFileOptions { ) => Promise; // Optional callback to confirm extraction of large ZIP files allowDuplicates?: boolean; skipUploadTracking?: boolean; + /** When true, marks every added stub as derivedFromTool so the policy + * auto-run skips it — used for policy outputs imported via addFiles. */ + derivedFromTool?: boolean; } /** @@ -368,6 +371,7 @@ export async function addFiles( // Create new filestub with minimal metadata; hydrate thumbnails/processedFile asynchronously const fileStub = createNewStirlingFileStub(file, fileId); + if (options.derivedFromTool) fileStub.derivedFromTool = true; // Early encryption detection for PDFs — set the flag before dispatch so the // viewer gate and modal queue pick it up immediately instead of after hydration diff --git a/frontend/editor/src/core/hooks/tools/addAttachments/useAddAttachmentsOperation.ts b/frontend/editor/src/core/hooks/tools/addAttachments/useAddAttachmentsOperation.ts index 2b66bbdc34..ddbf20f247 100644 --- a/frontend/editor/src/core/hooks/tools/addAttachments/useAddAttachmentsOperation.ts +++ b/frontend/editor/src/core/hooks/tools/addAttachments/useAddAttachmentsOperation.ts @@ -1,41 +1,60 @@ import { useTranslation } from "react-i18next"; import { useToolOperation, - ToolOperationConfig, - ToolType, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + objectToFormData, + type ToolApiParams, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; -import { AddAttachmentsParameters } from "@app/hooks/tools/addAttachments/useAddAttachmentsParameters"; +import { + AddAttachmentsParameters, + DEFAULT_ADD_ATTACHMENTS_PARAMETERS, +} from "@app/hooks/tools/addAttachments/useAddAttachmentsParameters"; + +const ENDPOINT = "/api/v1/misc/add-attachments" satisfies ToolEndpoint; +type AddAttachmentsApiParams = ToolApiParams[typeof ENDPOINT]; + +// Convert the tool's UI parameters into the add-attachments request body. The +// attachment files are uploaded via the named "attachments" field (see +// buildFormData); the model lists them but they are not scalar parameters. +export const addAttachmentsToApiParams = ( + parameters: AddAttachmentsParameters, +): AddAttachmentsApiParams => ({ + attachments: [], + convertToPdfA3b: parameters.convertToPdfA3b, +}); + +// Reconstruct the tool's UI parameters from an add-attachments request body (the +// attachment files themselves are not recoverable from stored parameters). +export const addAttachmentsFromApiParams = ( + apiParams: AddAttachmentsApiParams, +): Partial => ({ + convertToPdfA3b: + apiParams.convertToPdfA3b ?? + DEFAULT_ADD_ATTACHMENTS_PARAMETERS.convertToPdfA3b, +}); const buildFormData = ( parameters: AddAttachmentsParameters, file: File, -): FormData => { - const formData = new FormData(); - - // Add the main PDF file (single file per request in singleFile mode) - if (file) { - formData.append("fileInput", file); - } - - // Add attachment files - (parameters.attachments || []).forEach((attachment) => { - if (attachment) formData.append("attachments", attachment); +): FormData => + objectToFormData(addAttachmentsToApiParams(parameters), { + fileInput: file, + attachments: (parameters.attachments || []).filter(Boolean), }); - formData.append("convertToPdfA3b", String(parameters.convertToPdfA3b)); - - return formData; -}; - // Operation configuration for automation -export const addAttachmentsOperationConfig: ToolOperationConfig = - { - toolType: ToolType.singleFile, - buildFormData, - operationType: "addAttachments", - endpoint: "/api/v1/misc/add-attachments", - }; +export const addAttachmentsOperationConfig = defineSingleFileTool({ + buildFormData, + toApiParams: addAttachmentsToApiParams, + fromApiParams: addAttachmentsFromApiParams, + operationType: "addAttachments", + endpoint: ENDPOINT, + defaultParameters: DEFAULT_ADD_ATTACHMENTS_PARAMETERS, +}); export const useAddAttachmentsOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.test.ts b/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.test.ts index 3045c69450..77958629b0 100644 --- a/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.test.ts +++ b/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test, vi, beforeEach } from "vitest"; import { renderHook } from "@testing-library/react"; -import { useAddPasswordOperation } from "@app/hooks/tools/addPassword/useAddPasswordOperation"; +import { + addPasswordFromApiParams, + addPasswordToApiParams, + useAddPasswordOperation, +} from "@app/hooks/tools/addPassword/useAddPasswordOperation"; import type { AddPasswordFullParameters } from "@app/hooks/tools/addPassword/useAddPasswordParameters"; // Mock the useToolOperation hook @@ -141,3 +145,48 @@ describe("useAddPasswordOperation", () => { expect(callArgs[property]).toBe(expectedValue); }); }); + +describe("addPassword mappers", () => { + test("round-trips backend params, including the flattened permissions", () => { + // Baseline differs from the configured values so the round trip fails if + // fromApiParams drops a field instead of reconstructing it. + const baseline: AddPasswordFullParameters = { + password: "", + ownerPassword: "", + keyLength: 40, + permissions: { + preventAssembly: false, + preventExtractContent: false, + preventExtractForAccessibility: false, + preventFillInForm: false, + preventModify: false, + preventModifyAnnotations: false, + preventPrinting: false, + preventPrintingFaithful: false, + }, + }; + const configured: AddPasswordFullParameters = { + password: "user-pw", + ownerPassword: "owner-pw", + keyLength: 128, + permissions: { + preventAssembly: true, + preventExtractContent: false, + preventExtractForAccessibility: true, + preventFillInForm: false, + preventModify: true, + preventModifyAnnotations: false, + preventPrinting: true, + preventPrintingFaithful: false, + }, + }; + + const api = addPasswordToApiParams(configured); + const roundTripped = addPasswordToApiParams({ + ...baseline, + ...addPasswordFromApiParams(api), + }); + + expect(roundTripped).toEqual(api); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts b/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts index c09a740250..f6eca6ffbd 100644 --- a/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts +++ b/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts @@ -1,31 +1,82 @@ import { useTranslation } from "react-i18next"; import { - ToolType, useToolOperation, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + objectToFormData, + type ToolApiParams, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import { AddPasswordFullParameters, defaultParameters, } from "@app/hooks/tools/addPassword/useAddPasswordParameters"; import { defaultParameters as permissionsDefaults } from "@app/hooks/tools/changePermissions/useChangePermissionsParameters"; -import { getFormData } from "@app/hooks/tools/changePermissions/useChangePermissionsOperation"; + +const ENDPOINT = "/api/v1/security/add-password" satisfies ToolEndpoint; +type AddPasswordApiParams = ToolApiParams[typeof ENDPOINT]; + +// Convert the tool's UI parameters into the add-password request body. The +// permissions sub-object is flattened into the request's prevent* fields. +export const addPasswordToApiParams = ( + parameters: AddPasswordFullParameters, +): AddPasswordApiParams => ({ + password: parameters.password, + ownerPassword: parameters.ownerPassword, + // The UI stores keyLength as a number; narrow it to the model's allowed sizes. + keyLength: parameters.keyLength as AddPasswordApiParams["keyLength"], + preventAssembly: parameters.permissions.preventAssembly ?? false, + preventExtractContent: parameters.permissions.preventExtractContent ?? false, + preventExtractForAccessibility: + parameters.permissions.preventExtractForAccessibility ?? false, + preventFillInForm: parameters.permissions.preventFillInForm ?? false, + preventModify: parameters.permissions.preventModify ?? false, + preventModifyAnnotations: + parameters.permissions.preventModifyAnnotations ?? false, + preventPrinting: parameters.permissions.preventPrinting ?? false, + preventPrintingFaithful: + parameters.permissions.preventPrintingFaithful ?? false, +}); + +// Reconstruct the tool's UI parameters from an add-password request body, so a +// stored or AI-authored step can be re-rendered in the settings UI. +export const addPasswordFromApiParams = ( + apiParams: AddPasswordApiParams, +): Partial => ({ + password: apiParams.password ?? defaultParameters.password, + ownerPassword: apiParams.ownerPassword ?? defaultParameters.ownerPassword, + keyLength: apiParams.keyLength, + permissions: { + preventAssembly: + apiParams.preventAssembly ?? permissionsDefaults.preventAssembly, + preventExtractContent: + apiParams.preventExtractContent ?? + permissionsDefaults.preventExtractContent, + preventExtractForAccessibility: + apiParams.preventExtractForAccessibility ?? + permissionsDefaults.preventExtractForAccessibility, + preventFillInForm: + apiParams.preventFillInForm ?? permissionsDefaults.preventFillInForm, + preventModify: apiParams.preventModify ?? permissionsDefaults.preventModify, + preventModifyAnnotations: + apiParams.preventModifyAnnotations ?? + permissionsDefaults.preventModifyAnnotations, + preventPrinting: + apiParams.preventPrinting ?? permissionsDefaults.preventPrinting, + preventPrintingFaithful: + apiParams.preventPrintingFaithful ?? + permissionsDefaults.preventPrintingFaithful, + }, +}); // Static function that can be used by both the hook and automation executor export const buildAddPasswordFormData = ( parameters: AddPasswordFullParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - formData.append("password", parameters.password); - formData.append("ownerPassword", parameters.ownerPassword); - formData.append("keyLength", parameters.keyLength.toString()); - getFormData(parameters.permissions).forEach(([key, value]) => { - formData.append(key, value); - }); - return formData; -}; +): FormData => + objectToFormData(addPasswordToApiParams(parameters), { fileInput: file }); // Full default parameters including permissions for automation const fullDefaultParameters: AddPasswordFullParameters = { @@ -34,13 +85,14 @@ const fullDefaultParameters: AddPasswordFullParameters = { }; // Static configuration object -export const addPasswordOperationConfig = { - toolType: ToolType.singleFile, +export const addPasswordOperationConfig = defineSingleFileTool({ buildFormData: buildAddPasswordFormData, + toApiParams: addPasswordToApiParams, + fromApiParams: addPasswordFromApiParams, operationType: "addPassword", - endpoint: "/api/v1/security/add-password", + endpoint: ENDPOINT, defaultParameters: fullDefaultParameters, -} as const; +}); export const useAddPasswordOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/addWatermark/useAddWatermarkOperation.test.ts b/frontend/editor/src/core/hooks/tools/addWatermark/useAddWatermarkOperation.test.ts new file mode 100644 index 0000000000..e00753e41c --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/addWatermark/useAddWatermarkOperation.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "vitest"; +import { + addWatermarkFromApiParams, + addWatermarkToApiParams, +} from "@app/hooks/tools/addWatermark/useAddWatermarkOperation"; +import { + AddWatermarkParameters, + defaultParameters, +} from "@app/hooks/tools/addWatermark/useAddWatermarkParameters"; + +describe("addWatermark mappers", () => { + // opacity 33 exercises the percentage <-> fraction conversion (/100, *100), + // which must survive the round trip without drifting on floating point. + test.each>([ + { watermarkType: "text", watermarkText: "DRAFT", opacity: 33 }, + { watermarkType: "image", opacity: 33 }, + ])("round-trips backend params for %o", (overrides) => { + const api = addWatermarkToApiParams({ ...defaultParameters, ...overrides }); + const roundTripped = addWatermarkToApiParams({ + ...defaultParameters, + ...addWatermarkFromApiParams(api), + }); + + expect(roundTripped).toEqual(api); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/addWatermark/useAddWatermarkOperation.ts b/frontend/editor/src/core/hooks/tools/addWatermark/useAddWatermarkOperation.ts index 19700f43e7..423110a2d2 100644 --- a/frontend/editor/src/core/hooks/tools/addWatermark/useAddWatermarkOperation.ts +++ b/frontend/editor/src/core/hooks/tools/addWatermark/useAddWatermarkOperation.ts @@ -1,61 +1,100 @@ import { useTranslation } from "react-i18next"; import { - ToolType, useToolOperation, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + objectToFormData, + type ToolApiParams, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import { AddWatermarkParameters, defaultParameters, } from "@app/hooks/tools/addWatermark/useAddWatermarkParameters"; +const ENDPOINT = "/api/v1/security/add-watermark" satisfies ToolEndpoint; +type AddWatermarkApiParams = ToolApiParams[typeof ENDPOINT]; + +// Convert the tool's UI parameters into the add-watermark request body. The +// watermark image itself is a File and is passed via the `files` argument. +export const addWatermarkToApiParams = ( + parameters: AddWatermarkParameters, +): AddWatermarkApiParams => { + const watermarkType = parameters.watermarkType || "text"; + const apiParams: AddWatermarkApiParams = { + watermarkType, + fontSize: parameters.fontSize, + rotation: parameters.rotation, + // The UI stores opacity as a 0-100 percentage; the backend expects 0.0-1.0. + opacity: parameters.opacity / 100, + widthSpacer: parameters.widthSpacer, + heightSpacer: parameters.heightSpacer, + // The UI types alphabet as a free string; the wire always sends it (empty + // string when unset) so the value is passed through and cast to the model + // enum to preserve existing behaviour. + alphabet: (parameters.alphabet || "") as AddWatermarkApiParams["alphabet"], + customColor: parameters.customColor || "", + convertPDFToImage: parameters.convertPDFToImage ?? false, + }; + + if (watermarkType === "text") { + apiParams.watermarkText = parameters.watermarkText; + } + + return apiParams; +}; + +// Reconstruct the tool's UI parameters from an add-watermark request body, so a +// stored or AI-authored step can be re-rendered in the settings UI. The +// watermark image File cannot be recovered from the request model. +export const addWatermarkFromApiParams = ( + apiParams: AddWatermarkApiParams, +): Partial => { + const result: Partial = { + watermarkType: apiParams.watermarkType, + fontSize: apiParams.fontSize, + rotation: apiParams.rotation, + widthSpacer: apiParams.widthSpacer, + heightSpacer: apiParams.heightSpacer, + alphabet: apiParams.alphabet ?? defaultParameters.alphabet, + customColor: apiParams.customColor ?? defaultParameters.customColor, + convertPDFToImage: + apiParams.convertPDFToImage ?? defaultParameters.convertPDFToImage, + }; + + if (apiParams.opacity !== undefined) { + result.opacity = apiParams.opacity * 100; + } + if (apiParams.watermarkText !== undefined) { + result.watermarkText = apiParams.watermarkText; + } + + return result; +}; + // Static function that can be used by both the hook and automation executor export const buildAddWatermarkFormData = ( parameters: AddWatermarkParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - - // Required: watermarkType as string - formData.append("watermarkType", parameters.watermarkType || "text"); - - // Add watermark content based on type - if (parameters.watermarkType === "text") { - formData.append("watermarkText", parameters.watermarkText); - } else if ( - parameters.watermarkType === "image" && - parameters.watermarkImage - ) { - formData.append("watermarkImage", parameters.watermarkImage); - } - - // Required parameters with correct formatting (defaults merged in automationExecutor) - formData.append("fontSize", parameters.fontSize.toString()); - formData.append("rotation", parameters.rotation.toString()); - formData.append("opacity", (parameters.opacity / 100).toString()); // Convert percentage to decimal - formData.append("widthSpacer", parameters.widthSpacer.toString()); - formData.append("heightSpacer", parameters.heightSpacer.toString()); - - // Backend-expected parameters from user input - formData.append("alphabet", parameters.alphabet || ""); - formData.append("customColor", parameters.customColor || ""); - formData.append( - "convertPDFToImage", - (parameters.convertPDFToImage ?? false).toString(), +): FormData => + objectToFormData( + addWatermarkToApiParams(parameters), + parameters.watermarkType === "image" && parameters.watermarkImage + ? { fileInput: file, watermarkImage: parameters.watermarkImage } + : { fileInput: file }, ); - return formData; -}; - // Static configuration object -export const addWatermarkOperationConfig = { - toolType: ToolType.singleFile, +export const addWatermarkOperationConfig = defineSingleFileTool({ buildFormData: buildAddWatermarkFormData, + toApiParams: addWatermarkToApiParams, + fromApiParams: addWatermarkFromApiParams, operationType: "watermark", - endpoint: "/api/v1/security/add-watermark", + endpoint: ENDPOINT, defaultParameters, -} as const; +}); export const useAddWatermarkOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/adjustContrast/useAdjustContrastOperation.ts b/frontend/editor/src/core/hooks/tools/adjustContrast/useAdjustContrastOperation.ts index 4a26de244d..18981b58d0 100644 --- a/frontend/editor/src/core/hooks/tools/adjustContrast/useAdjustContrastOperation.ts +++ b/frontend/editor/src/core/hooks/tools/adjustContrast/useAdjustContrastOperation.ts @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; import { - ToolType, + defineCustomTool, useToolOperation, CustomProcessorResult, } from "@app/hooks/tools/shared/useToolOperation"; @@ -195,14 +195,11 @@ async function processPdfClientSide( }; } -export const adjustContrastOperationConfig = { - toolType: ToolType.custom, +export const adjustContrastOperationConfig = defineCustomTool({ customProcessor: processPdfClientSide, operationType: "adjustContrast", defaultParameters, - settingsComponentPath: - "components/tools/adjustContrast/AdjustContrastSingleStepSettings", -} as const; +}); export const useAdjustContrastOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/adjustPageScale/adjustPageScaleFormData.ts b/frontend/editor/src/core/hooks/tools/adjustPageScale/adjustPageScaleFormData.ts index 95ee6f3293..e278097508 100644 --- a/frontend/editor/src/core/hooks/tools/adjustPageScale/adjustPageScaleFormData.ts +++ b/frontend/editor/src/core/hooks/tools/adjustPageScale/adjustPageScaleFormData.ts @@ -1,13 +1,38 @@ -import { AdjustPageScaleParameters } from "@app/hooks/tools/adjustPageScale/useAdjustPageScaleParameters"; +import { + AdjustPageScaleParameters, + PageSize, +} from "@app/hooks/tools/adjustPageScale/useAdjustPageScaleParameters"; +import { + objectToFormData, + type ToolApiParams, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; + +export const ADJUST_PAGE_SCALE_ENDPOINT = + "/api/v1/general/scale-pages" satisfies ToolEndpoint; +type AdjustPageScaleApiParams = + ToolApiParams[typeof ADJUST_PAGE_SCALE_ENDPOINT]; + +export const adjustPageScaleToApiParams = ( + parameters: AdjustPageScaleParameters, +): AdjustPageScaleApiParams => ({ + scaleFactor: parameters.scaleFactor, + pageSize: parameters.pageSize, + orientation: parameters.orientation, +}); + +export const adjustPageScaleFromApiParams = ( + apiParams: AdjustPageScaleApiParams, +): Partial => ({ + scaleFactor: apiParams.scaleFactor, + pageSize: apiParams.pageSize as PageSize, + orientation: apiParams.orientation, +}); export const buildAdjustPageScaleFormData = ( parameters: AdjustPageScaleParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - formData.append("scaleFactor", parameters.scaleFactor.toString()); - formData.append("pageSize", parameters.pageSize); - formData.append("orientation", parameters.orientation); - return formData; -}; +): FormData => + objectToFormData(adjustPageScaleToApiParams(parameters), { + fileInput: file, + }); diff --git a/frontend/editor/src/core/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.test.ts b/frontend/editor/src/core/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.test.ts index 264c71c940..7453ecfb3e 100644 --- a/frontend/editor/src/core/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.test.ts +++ b/frontend/editor/src/core/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, it } from "vitest"; -import { buildAdjustPageScaleFormData } from "@app/hooks/tools/adjustPageScale/adjustPageScaleFormData"; +import { describe, expect, it, test } from "vitest"; +import { + adjustPageScaleFromApiParams, + adjustPageScaleToApiParams, + buildAdjustPageScaleFormData, +} from "@app/hooks/tools/adjustPageScale/adjustPageScaleFormData"; import { defaultParameters, PageSize, @@ -49,3 +53,20 @@ describe("buildAdjustPageScaleFormData", () => { expect(formData.get("fileInput")).toBe(file); }); }); + +describe("adjustPageScale mappers", () => { + test("round-trips backend params", () => { + const api = adjustPageScaleToApiParams({ + ...defaultParameters, + scaleFactor: 1.5, + pageSize: PageSize.A4, + orientation: "LANDSCAPE", + }); + const roundTripped = adjustPageScaleToApiParams({ + ...defaultParameters, + ...adjustPageScaleFromApiParams(api), + }); + + expect(roundTripped).toEqual(api); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.ts b/frontend/editor/src/core/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.ts index c710737722..db0ce22da2 100644 --- a/frontend/editor/src/core/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.ts +++ b/frontend/editor/src/core/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.ts @@ -1,24 +1,34 @@ import { useTranslation } from "react-i18next"; import { useToolOperation, - ToolType, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import { AdjustPageScaleParameters, defaultParameters, } from "@app/hooks/tools/adjustPageScale/useAdjustPageScaleParameters"; -import { buildAdjustPageScaleFormData } from "@app/hooks/tools/adjustPageScale/adjustPageScaleFormData"; +import { + buildAdjustPageScaleFormData, + adjustPageScaleToApiParams, + adjustPageScaleFromApiParams, + ADJUST_PAGE_SCALE_ENDPOINT, +} from "@app/hooks/tools/adjustPageScale/adjustPageScaleFormData"; -export { buildAdjustPageScaleFormData }; +export { + buildAdjustPageScaleFormData, + adjustPageScaleToApiParams, + adjustPageScaleFromApiParams, +}; -export const adjustPageScaleOperationConfig = { - toolType: ToolType.singleFile, +export const adjustPageScaleOperationConfig = defineSingleFileTool({ buildFormData: buildAdjustPageScaleFormData, + toApiParams: adjustPageScaleToApiParams, + fromApiParams: adjustPageScaleFromApiParams, operationType: "scalePages", - endpoint: "/api/v1/general/scale-pages", + endpoint: ADJUST_PAGE_SCALE_ENDPOINT, defaultParameters, -} as const; +}); export const useAdjustPageScaleOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/autoRename/useAutoRenameOperation.ts b/frontend/editor/src/core/hooks/tools/autoRename/useAutoRenameOperation.ts index 26c900df66..15466998e5 100644 --- a/frontend/editor/src/core/hooks/tools/autoRename/useAutoRenameOperation.ts +++ b/frontend/editor/src/core/hooks/tools/autoRename/useAutoRenameOperation.ts @@ -1,45 +1,58 @@ import { useTranslation } from "react-i18next"; import { - ToolType, useToolOperation, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + objectToFormData, + type ToolApiParams, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import { AutoRenameParameters, defaultParameters, } from "@app/hooks/tools/autoRename/useAutoRenameParameters"; -export const getFormData = (parameters: AutoRenameParameters) => - Object.entries(parameters).map(([key, value]) => [ - key, - value.toString(), - ]) as string[][]; +const ENDPOINT = "/api/v1/misc/auto-rename" satisfies ToolEndpoint; +type AutoRenameApiParams = ToolApiParams[typeof ENDPOINT]; + +// Convert the tool's UI parameters into the auto-rename request body. The return +// type is the generated backend model, so a spec change that renames or drops a +// field breaks the build here. +export const autoRenameToApiParams = ( + parameters: AutoRenameParameters, +): AutoRenameApiParams => ({ + useFirstTextAsFallback: parameters.useFirstTextAsFallback, +}); + +// Reconstruct the tool's UI parameters from an auto-rename request body, so a +// stored or AI-authored step can be re-rendered in the settings UI. +export const autoRenameFromApiParams = ( + apiParams: AutoRenameApiParams, +): Partial => ({ + useFirstTextAsFallback: + apiParams.useFirstTextAsFallback ?? + defaultParameters.useFirstTextAsFallback, +}); // Static function that can be used by both the hook and automation executor export const buildAutoRenameFormData = ( parameters: AutoRenameParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - - // Add all permission parameters - getFormData(parameters).forEach(([key, value]) => { - formData.append(key, value); - }); - - return formData; -}; +): FormData => + objectToFormData(autoRenameToApiParams(parameters), { fileInput: file }); // Static configuration object -export const autoRenameOperationConfig = { - toolType: ToolType.singleFile, +export const autoRenameOperationConfig = defineSingleFileTool({ buildFormData: buildAutoRenameFormData, + toApiParams: autoRenameToApiParams, + fromApiParams: autoRenameFromApiParams, operationType: "autoRename", - endpoint: "/api/v1/misc/auto-rename", + endpoint: ENDPOINT, preserveBackendFilename: true, // Use filename from backend response headers defaultParameters, -} as const; +}); export const useAutoRenameOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/automate/useAutomateOperation.ts b/frontend/editor/src/core/hooks/tools/automate/useAutomateOperation.ts index 2ef3d33d96..d1d6436fa9 100644 --- a/frontend/editor/src/core/hooks/tools/automate/useAutomateOperation.ts +++ b/frontend/editor/src/core/hooks/tools/automate/useAutomateOperation.ts @@ -1,5 +1,5 @@ import { - ToolType, + defineCustomTool, useToolOperation, } from "@app/hooks/tools/shared/useToolOperation"; import { useCallback } from "react"; @@ -55,10 +55,11 @@ export function useAutomateOperation() { [toolRegistry], ); - return useToolOperation({ - toolType: ToolType.custom, - operationType: "automate", - customProcessor, - consumesAllInputs: true, - }); + return useToolOperation( + defineCustomTool({ + operationType: "automate", + customProcessor, + consumesAllInputs: true, + }), + ); } diff --git a/frontend/editor/src/core/hooks/tools/bookletImposition/useBookletImpositionOperation.ts b/frontend/editor/src/core/hooks/tools/bookletImposition/useBookletImpositionOperation.ts index 6da2b66320..7add5d126c 100644 --- a/frontend/editor/src/core/hooks/tools/bookletImposition/useBookletImpositionOperation.ts +++ b/frontend/editor/src/core/hooks/tools/bookletImposition/useBookletImpositionOperation.ts @@ -1,40 +1,72 @@ import { useTranslation } from "react-i18next"; import { useToolOperation, - ToolType, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + objectToFormData, + type ToolApiParams, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import { BookletImpositionParameters, defaultParameters, } from "@app/hooks/tools/bookletImposition/useBookletImpositionParameters"; +const ENDPOINT = "/api/v1/general/booklet-imposition" satisfies ToolEndpoint; +type BookletImpositionApiParams = ToolApiParams[typeof ENDPOINT]; + +// Convert the tool's UI parameters into the booklet-imposition request body. The +// return type is the generated backend model, so a spec change that renames or +// drops a field breaks the build here. +export const bookletImpositionToApiParams = ( + parameters: BookletImpositionParameters, +): BookletImpositionApiParams => ({ + pagesPerSheet: parameters.pagesPerSheet, + addBorder: parameters.addBorder, + spineLocation: parameters.spineLocation, + addGutter: parameters.addGutter, + gutterSize: parameters.gutterSize, + doubleSided: parameters.doubleSided, + duplexPass: parameters.duplexPass, + flipOnShortEdge: parameters.flipOnShortEdge, +}); + +// Reconstruct the tool's UI parameters from a booklet-imposition request body, +// so a stored or AI-authored step can be re-rendered in the settings UI. +export const bookletImpositionFromApiParams = ( + apiParams: BookletImpositionApiParams, +): Partial => ({ + pagesPerSheet: apiParams.pagesPerSheet ?? defaultParameters.pagesPerSheet, + addBorder: apiParams.addBorder ?? defaultParameters.addBorder, + spineLocation: apiParams.spineLocation ?? defaultParameters.spineLocation, + addGutter: apiParams.addGutter ?? defaultParameters.addGutter, + gutterSize: apiParams.gutterSize ?? defaultParameters.gutterSize, + doubleSided: apiParams.doubleSided ?? defaultParameters.doubleSided, + duplexPass: apiParams.duplexPass ?? defaultParameters.duplexPass, + flipOnShortEdge: + apiParams.flipOnShortEdge ?? defaultParameters.flipOnShortEdge, +}); + // Static configuration that can be used by both the hook and automation executor export const buildBookletImpositionFormData = ( parameters: BookletImpositionParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - formData.append("pagesPerSheet", parameters.pagesPerSheet.toString()); - formData.append("addBorder", parameters.addBorder.toString()); - formData.append("spineLocation", parameters.spineLocation); - formData.append("addGutter", parameters.addGutter.toString()); - formData.append("gutterSize", parameters.gutterSize.toString()); - formData.append("doubleSided", parameters.doubleSided.toString()); - formData.append("duplexPass", parameters.duplexPass); - formData.append("flipOnShortEdge", parameters.flipOnShortEdge.toString()); - return formData; -}; +): FormData => + objectToFormData(bookletImpositionToApiParams(parameters), { + fileInput: file, + }); // Static configuration object -export const bookletImpositionOperationConfig = { - toolType: ToolType.singleFile, +export const bookletImpositionOperationConfig = defineSingleFileTool({ buildFormData: buildBookletImpositionFormData, + toApiParams: bookletImpositionToApiParams, + fromApiParams: bookletImpositionFromApiParams, operationType: "bookletImposition", - endpoint: "/api/v1/general/booklet-imposition", + endpoint: ENDPOINT, defaultParameters, -} as const; +}); export const useBookletImpositionOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/certSign/useCertSignOperation.ts b/frontend/editor/src/core/hooks/tools/certSign/useCertSignOperation.ts index 8488dec77a..01cfa32b1a 100644 --- a/frontend/editor/src/core/hooks/tools/certSign/useCertSignOperation.ts +++ b/frontend/editor/src/core/hooks/tools/certSign/useCertSignOperation.ts @@ -1,91 +1,149 @@ import { useTranslation } from "react-i18next"; import { - ToolType, useToolOperation, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + objectToFormData, + type FormDataFiles, + type ToolApiParams, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import { CertSignParameters, defaultParameters, } from "@app/hooks/tools/certSign/useCertSignParameters"; +const ENDPOINT = "/api/v1/security/cert-sign" satisfies ToolEndpoint; +type CertSignApiParams = ToolApiParams[typeof ENDPOINT]; + +// Convert the tool's UI parameters into the cert-sign request body. The keystore +// uploads (privateKeyFile, certFile, p12File, jksFile) are actual File uploads +// and are appended separately (see buildCertSignFormData); only the scalar +// fields are serialized here. +export const certSignToApiParams = ( + parameters: CertSignParameters, +): CertSignApiParams => { + // AUTO mode signs with the server certificate; no keystore/password is sent. + if (parameters.signMode === "AUTO") { + return withSignatureAppearance({ certType: "SERVER" }, parameters); + } + + const apiParams: CertSignApiParams = { + certType: parameters.certType as CertSignApiParams["certType"], + password: parameters.password, + }; + + // Non-file identifiers depend on the chosen certificate type. + switch (parameters.certType) { + case "WINDOWS_STORE": + if (parameters.alias) apiParams.alias = parameters.alias; + break; + case "PKCS11": + if (parameters.pkcs11LibraryPath) { + apiParams.pkcs11LibraryPath = parameters.pkcs11LibraryPath; + } + if (parameters.pkcs11Slot != null) { + apiParams.pkcs11Slot = parameters.pkcs11Slot; + } + if (parameters.alias) apiParams.alias = parameters.alias; + break; + } + + return withSignatureAppearance(apiParams, parameters); +}; + +// Signature appearance fields are only sent when the visible signature is +// enabled, matching the original form behaviour. +const withSignatureAppearance = ( + apiParams: CertSignApiParams, + parameters: CertSignParameters, +): CertSignApiParams => { + if (parameters.showSignature) { + apiParams.showSignature = true; + apiParams.reason = parameters.reason; + apiParams.location = parameters.location; + apiParams.name = parameters.name; + apiParams.pageNumber = parameters.pageNumber; + apiParams.showLogo = parameters.showLogo; + } + return apiParams; +}; + +// Select the keystore File uploads for the chosen certificate type. AUTO mode +// (server certificate) uploads no keystore. +const certSignFiles = (parameters: CertSignParameters): FormDataFiles => { + if (parameters.signMode === "AUTO") return {}; + + switch (parameters.certType) { + case "PEM": + return { + privateKeyFile: parameters.privateKeyFile, + certFile: parameters.certFile, + }; + case "PKCS12": + case "PFX": + return { p12File: parameters.p12File }; + case "JKS": + return { jksFile: parameters.jksFile }; + default: + return {}; + } +}; + +// Reconstruct the tool's UI parameters from a cert-sign request body, so a stored +// or AI-authored step can be re-rendered in the settings UI. Uploaded keystore +// files cannot be recovered from the request model. +export const certSignFromApiParams = ( + apiParams: CertSignApiParams, +): Partial => { + const result: Partial = { + signMode: apiParams.certType === "SERVER" ? "AUTO" : "MANUAL", + showSignature: apiParams.showSignature ?? defaultParameters.showSignature, + }; + + if (apiParams.certType !== "SERVER") { + result.certType = apiParams.certType; + result.password = apiParams.password ?? defaultParameters.password; + } + if (apiParams.alias !== undefined) result.alias = apiParams.alias; + if (apiParams.pkcs11LibraryPath !== undefined) { + result.pkcs11LibraryPath = apiParams.pkcs11LibraryPath; + } + if (apiParams.pkcs11Slot !== undefined) { + result.pkcs11Slot = apiParams.pkcs11Slot; + } + if (apiParams.reason !== undefined) result.reason = apiParams.reason; + if (apiParams.location !== undefined) result.location = apiParams.location; + if (apiParams.name !== undefined) result.name = apiParams.name; + if (apiParams.pageNumber !== undefined) { + result.pageNumber = apiParams.pageNumber; + } + if (apiParams.showLogo !== undefined) result.showLogo = apiParams.showLogo; + + return result; +}; + // Build form data for signing export const buildCertSignFormData = ( parameters: CertSignParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - - // Handle sign mode - if (parameters.signMode === "AUTO") { - formData.append("certType", "SERVER"); - } else { - formData.append("certType", parameters.certType); - formData.append("password", parameters.password); - - // Add certificate files based on type (only for manual mode) - switch (parameters.certType) { - case "PEM": - if (parameters.privateKeyFile) { - formData.append("privateKeyFile", parameters.privateKeyFile); - } - if (parameters.certFile) { - formData.append("certFile", parameters.certFile); - } - break; - case "PKCS12": - case "PFX": - if (parameters.p12File) { - formData.append("p12File", parameters.p12File); - } - break; - case "JKS": - if (parameters.jksFile) { - formData.append("jksFile", parameters.jksFile); - } - break; - case "WINDOWS_STORE": - if (parameters.alias) { - formData.append("alias", parameters.alias); - } - break; - case "PKCS11": - if (parameters.pkcs11LibraryPath) { - formData.append("pkcs11LibraryPath", parameters.pkcs11LibraryPath); - } - if (parameters.pkcs11Slot != null) { - formData.append("pkcs11Slot", parameters.pkcs11Slot.toString()); - } - if (parameters.alias) { - formData.append("alias", parameters.alias); - } - break; - } - } - - // Add signature appearance options if enabled - if (parameters.showSignature) { - formData.append("showSignature", "true"); - formData.append("reason", parameters.reason); - formData.append("location", parameters.location); - formData.append("name", parameters.name); - formData.append("pageNumber", parameters.pageNumber.toString()); - formData.append("showLogo", parameters.showLogo.toString()); - } - - return formData; -}; +): FormData => + objectToFormData(certSignToApiParams(parameters), { + fileInput: file, + ...certSignFiles(parameters), + }); // Static configuration object -export const certSignOperationConfig = { - toolType: ToolType.singleFile, +export const certSignOperationConfig = defineSingleFileTool({ buildFormData: buildCertSignFormData, + toApiParams: certSignToApiParams, + fromApiParams: certSignFromApiParams, operationType: "certSign", - endpoint: "/api/v1/security/cert-sign", - multiFileEndpoint: false, + endpoint: ENDPOINT, defaultParameters, -} as const; +}); export const useCertSignOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/changeMetadata/useChangeMetadataOperation.test.ts b/frontend/editor/src/core/hooks/tools/changeMetadata/useChangeMetadataOperation.test.ts index 6444f5f05d..0a3bf0e78a 100644 --- a/frontend/editor/src/core/hooks/tools/changeMetadata/useChangeMetadataOperation.test.ts +++ b/frontend/editor/src/core/hooks/tools/changeMetadata/useChangeMetadataOperation.test.ts @@ -1,5 +1,12 @@ -import { buildChangeMetadataFormData } from "@app/hooks/tools/changeMetadata/useChangeMetadataOperation"; -import { ChangeMetadataParameters } from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters"; +import { + buildChangeMetadataFormData, + changeMetadataToApiParams, + changeMetadataFromApiParams, +} from "@app/hooks/tools/changeMetadata/useChangeMetadataOperation"; +import { + ChangeMetadataParameters, + defaultParameters, +} from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters"; import { TrappedStatus } from "@app/types/metadata"; import { describe, expect, test } from "vitest"; @@ -142,3 +149,78 @@ describe("buildChangeMetadataFormData", () => { expect(formData.get("allRequestParams[customValue1]")).toBe("Engineering"); }); }); + +describe("changeMetadata mappers", () => { + const fullApi: Parameters[0] = { + title: "Title", + author: "Author", + subject: "Subject", + keywords: "a, b", + creator: "Creator", + producer: "Producer", + creationDate: "2024/01/15 10:30:00", + modificationDate: "2024/02/20 14:05:09", + trapped: "True", + deleteAll: false, + allRequestParams: { + customKey1: "Department", + customValue1: "Engineering", + customKey2: "Project", + customValue2: "Falcon", + }, + }; + + test("round-trips a full request through fromApiParams and back", () => { + const roundTripped = changeMetadataToApiParams({ + ...defaultParameters, + ...changeMetadataFromApiParams(fullApi), + }); + + expect(roundTripped).toEqual(fullApi); + }); + + test("reconstructs dates in local time and clears absent ones", () => { + const params = changeMetadataFromApiParams(fullApi); + expect(params.creationDate).toEqual(new Date(2024, 0, 15, 10, 30, 0)); + expect(params.modificationDate).toEqual(new Date(2024, 1, 20, 14, 5, 9)); + + const cleared = changeMetadataFromApiParams({ + creationDate: "", + modificationDate: undefined, + }); + expect(cleared.creationDate).toBeNull(); + expect(cleared.modificationDate).toBeNull(); + }); + + test.each([TrappedStatus.TRUE, TrappedStatus.FALSE, TrappedStatus.UNKNOWN])( + "round-trips trapped=%s", + (trapped) => { + const api = changeMetadataToApiParams({ ...defaultParameters, trapped }); + expect(api.trapped).toBe(trapped); + expect(changeMetadataFromApiParams(api).trapped).toBe(trapped); + }, + ); + + test("falls back to the default for an unrecognised trapped value", () => { + const params = changeMetadataFromApiParams({ + trapped: "Bogus", + } as unknown as Parameters[0]); + expect(params.trapped).toBe(defaultParameters.trapped); + }); + + test("reconstructs custom metadata, tolerating non-contiguous indices", () => { + const params = changeMetadataFromApiParams({ + allRequestParams: { + customKey1: "Department", + customValue1: "Engineering", + customKey3: "Project", + customValue3: "Falcon", + }, + }); + + expect(params.customMetadata).toEqual([ + { key: "Department", value: "Engineering", id: "custom1" }, + { key: "Project", value: "Falcon", id: "custom3" }, + ]); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/changeMetadata/useChangeMetadataOperation.ts b/frontend/editor/src/core/hooks/tools/changeMetadata/useChangeMetadataOperation.ts index 045b029522..97a8285194 100644 --- a/frontend/editor/src/core/hooks/tools/changeMetadata/useChangeMetadataOperation.ts +++ b/frontend/editor/src/core/hooks/tools/changeMetadata/useChangeMetadataOperation.ts @@ -1,15 +1,24 @@ import { useTranslation } from "react-i18next"; import { useToolOperation, - ToolType, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + objectToFormData, + type ToolApiParams, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; +import { TrappedStatus, CustomMetadataEntry } from "@app/types/metadata"; import { ChangeMetadataParameters, defaultParameters, } from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters"; -// Helper function to format Date object to string +const ENDPOINT = "/api/v1/misc/update-metadata" satisfies ToolEndpoint; +type ChangeMetadataApiParams = ToolApiParams[typeof ENDPOINT]; + +// Backend date format (yyyy/MM/dd HH:mm:ss), in local time to mirror the parser. const formatDateForBackend = (date: Date | null): string => { if (!date) return ""; const year = date.getFullYear(); @@ -21,67 +30,137 @@ const formatDateForBackend = (date: Date | null): string => { return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`; }; +// Inverse of formatDateForBackend; returns null for empty or unparseable input. +const parseDateFromBackend = (value: string | undefined): Date | null => { + if (!value) return null; + const match = /^(\d{4})\/(\d{2})\/(\d{2}) (\d{2}):(\d{2}):(\d{2})$/.exec( + value, + ); + if (!match) return null; + const [, year, month, day, hours, minutes, seconds] = match; + return new Date( + Number(year), + Number(month) - 1, + Number(day), + Number(hours), + Number(minutes), + Number(seconds), + ); +}; + +// Custom metadata is carried in the request's allRequestParams map as paired +// customKey/customValue entries; the backend rejoins them by the shared +// index N (see MetadataController). Only entries with a non-blank key and value +// are sent, and they are re-numbered from 1 so the indices stay contiguous. +const buildCustomMetadataMap = ( + customMetadata: CustomMetadataEntry[], +): Record | undefined => { + const validEntries = customMetadata.filter( + (entry) => entry.key.trim() && entry.value.trim(), + ); + if (validEntries.length === 0) return undefined; + + const map: Record = {}; + validEntries.forEach((entry, index) => { + const n = index + 1; + map[`customKey${n}`] = entry.key.trim(); + map[`customValue${n}`] = entry.value.trim(); + }); + return map; +}; + +// Rebuild the UI's custom metadata list from the allRequestParams map by pairing +// customKey/customValue on their shared index N. Mirrors the backend +// (MetadataController), which pairs by index across all entries and does not +// assume the indices are contiguous, so a non-contiguous stored map round-trips. +const parseCustomMetadataMap = ( + allRequestParams: ChangeMetadataApiParams["allRequestParams"], +): CustomMetadataEntry[] => { + if (!allRequestParams) return []; + return Object.keys(allRequestParams) + .map((key) => /^customKey(\d+)$/.exec(key)?.[1]) + .filter((n): n is string => n !== undefined) + .map(Number) + .sort((a, b) => a - b) + .map((n) => ({ + key: allRequestParams[`customKey${n}`] ?? "", + value: allRequestParams[`customValue${n}`] ?? "", + id: `custom${n}`, + })); +}; + +// Map the backend's trapped string onto the UI enum, validating against the +// actual enum values so an unrecognised value falls back to the default instead +// of being force-cast. +const parseTrapped = (value: string | undefined): TrappedStatus => + Object.values(TrappedStatus).find((status) => status === value) ?? + defaultParameters.trapped; + +// Convert the tool's UI parameters into the update-metadata request body. The +// return type is the generated backend model, so a spec change that renames or +// drops a field breaks the build here. +export const changeMetadataToApiParams = ( + parameters: ChangeMetadataParameters, +): ChangeMetadataApiParams => ({ + title: parameters.title, + author: parameters.author, + subject: parameters.subject, + keywords: parameters.keywords, + creator: parameters.creator, + producer: parameters.producer, + creationDate: formatDateForBackend(parameters.creationDate), + modificationDate: formatDateForBackend(parameters.modificationDate), + trapped: parameters.trapped, + deleteAll: parameters.deleteAll, + allRequestParams: buildCustomMetadataMap(parameters.customMetadata), +}); + +// Reconstruct the tool's UI parameters from an update-metadata request body, so +// a stored or AI-authored step can be re-rendered in the settings UI. +export const changeMetadataFromApiParams = ( + apiParams: ChangeMetadataApiParams, +): Partial => ({ + title: apiParams.title ?? defaultParameters.title, + author: apiParams.author ?? defaultParameters.author, + subject: apiParams.subject ?? defaultParameters.subject, + keywords: apiParams.keywords ?? defaultParameters.keywords, + creator: apiParams.creator ?? defaultParameters.creator, + producer: apiParams.producer ?? defaultParameters.producer, + creationDate: parseDateFromBackend(apiParams.creationDate), + modificationDate: parseDateFromBackend(apiParams.modificationDate), + trapped: parseTrapped(apiParams.trapped), + deleteAll: apiParams.deleteAll ?? defaultParameters.deleteAll, + customMetadata: parseCustomMetadataMap(apiParams.allRequestParams), +}); + // Static function that can be used by both the hook and automation executor export const buildChangeMetadataFormData = ( parameters: ChangeMetadataParameters, file: File, ): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - - // Standard metadata fields - formData.append("title", parameters.title || ""); - formData.append("author", parameters.author || ""); - formData.append("subject", parameters.subject || ""); - formData.append("keywords", parameters.keywords || ""); - formData.append("creator", parameters.creator || ""); - formData.append("producer", parameters.producer || ""); - - // Date fields - convert Date objects to strings - formData.append( - "creationDate", - formatDateForBackend(parameters.creationDate), - ); - formData.append( - "modificationDate", - formatDateForBackend(parameters.modificationDate), - ); - - // Trapped status - formData.append("trapped", parameters.trapped || ""); - - // Delete all metadata flag - formData.append("deleteAll", parameters.deleteAll.toString()); - - // Custom metadata - backend expects them as values to 'allRequestParams[customKeyX/customValueX]' - let keyNumber = 0; - if (parameters.customMetadata && Array.isArray(parameters.customMetadata)) { - parameters.customMetadata.forEach((entry) => { - if (entry.key.trim() && entry.value.trim()) { - keyNumber += 1; - formData.append( - `allRequestParams[customKey${keyNumber}]`, - entry.key.trim(), - ); - formData.append( - `allRequestParams[customValue${keyNumber}]`, - entry.value.trim(), - ); - } - }); + // allRequestParams is a Spring-bound map: objectToFormData only serializes + // primitives, so the scalar fields go through it and the map is flattened into + // allRequestParams[] form fields separately. + const { allRequestParams, ...scalarParams } = + changeMetadataToApiParams(parameters); + const formData = objectToFormData(scalarParams, { fileInput: file }); + for (const [key, value] of Object.entries(allRequestParams ?? {})) { + if (value !== undefined) { + formData.append(`allRequestParams[${key}]`, value); + } } - return formData; }; // Static configuration object -export const changeMetadataOperationConfig = { - toolType: ToolType.singleFile, +export const changeMetadataOperationConfig = defineSingleFileTool({ buildFormData: buildChangeMetadataFormData, + toApiParams: changeMetadataToApiParams, + fromApiParams: changeMetadataFromApiParams, operationType: "changeMetadata", - endpoint: "/api/v1/misc/update-metadata", + endpoint: ENDPOINT, defaultParameters, -} as const; +}); export const useChangeMetadataOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.test.ts b/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.test.ts index 2be13c3466..bad85097e2 100644 --- a/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.test.ts +++ b/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.test.ts @@ -1,7 +1,14 @@ import { describe, expect, test, vi, beforeEach } from "vitest"; import { renderHook } from "@testing-library/react"; -import { useChangePermissionsOperation } from "@app/hooks/tools/changePermissions/useChangePermissionsOperation"; -import type { ChangePermissionsParameters } from "@app/hooks/tools/changePermissions/useChangePermissionsParameters"; +import { + changePermissionsFromApiParams, + changePermissionsToApiParams, + useChangePermissionsOperation, +} from "@app/hooks/tools/changePermissions/useChangePermissionsOperation"; +import { + type ChangePermissionsParameters, + defaultParameters, +} from "@app/hooks/tools/changePermissions/useChangePermissionsParameters"; // Mock the useToolOperation hook vi.mock("../shared/useToolOperation", async () => { @@ -141,3 +148,26 @@ describe("useChangePermissionsOperation", () => { expect(callArgs[property]).toBe(expectedValue); }); }); + +describe("changePermissions mappers", () => { + test("round-trips backend params", () => { + const configured: ChangePermissionsParameters = { + preventAssembly: true, + preventExtractContent: false, + preventExtractForAccessibility: true, + preventFillInForm: false, + preventModify: true, + preventModifyAnnotations: false, + preventPrinting: true, + preventPrintingFaithful: false, + }; + + const api = changePermissionsToApiParams(configured); + const roundTripped = changePermissionsToApiParams({ + ...defaultParameters, + ...changePermissionsFromApiParams(api), + }); + + expect(roundTripped).toEqual(api); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.ts b/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.ts index 0500d86417..a2e3eb3d1d 100644 --- a/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.ts +++ b/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.ts @@ -1,46 +1,84 @@ import { useTranslation } from "react-i18next"; import { - ToolType, useToolOperation, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + objectToFormData, + type ToolApiParams, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import { ChangePermissionsParameters, defaultParameters, } from "@app/hooks/tools/changePermissions/useChangePermissionsParameters"; -export const getFormData = (parameters: ChangePermissionsParameters) => { - if (!parameters) return []; - return Object.entries(parameters).map(([key, value]) => [ - key, - (value ?? false).toString(), - ]) as string[][]; -}; +// Change Permissions reuses the Add Password endpoint but sends only the +// prevent* subset of the request model (no password or keyLength). +const ENDPOINT = "/api/v1/security/add-password" satisfies ToolEndpoint; +type AddPasswordApiParams = ToolApiParams[typeof ENDPOINT]; + +// Convert the tool's UI parameters into the add-password request body. Only the +// prevent* permission flags are sent; password and keyLength are optional on the +// model and left unset, so the endpoint changes permissions without encrypting. +export const changePermissionsToApiParams = ( + parameters: ChangePermissionsParameters, +): AddPasswordApiParams => ({ + preventAssembly: parameters.preventAssembly ?? false, + preventExtractContent: parameters.preventExtractContent ?? false, + preventExtractForAccessibility: + parameters.preventExtractForAccessibility ?? false, + preventFillInForm: parameters.preventFillInForm ?? false, + preventModify: parameters.preventModify ?? false, + preventModifyAnnotations: parameters.preventModifyAnnotations ?? false, + preventPrinting: parameters.preventPrinting ?? false, + preventPrintingFaithful: parameters.preventPrintingFaithful ?? false, +}); + +// Reconstruct the tool's UI parameters from an add-password request body, so a +// stored or AI-authored step can be re-rendered in the settings UI. +export const changePermissionsFromApiParams = ( + apiParams: AddPasswordApiParams, +): Partial => ({ + preventAssembly: + apiParams.preventAssembly ?? defaultParameters.preventAssembly, + preventExtractContent: + apiParams.preventExtractContent ?? defaultParameters.preventExtractContent, + preventExtractForAccessibility: + apiParams.preventExtractForAccessibility ?? + defaultParameters.preventExtractForAccessibility, + preventFillInForm: + apiParams.preventFillInForm ?? defaultParameters.preventFillInForm, + preventModify: apiParams.preventModify ?? defaultParameters.preventModify, + preventModifyAnnotations: + apiParams.preventModifyAnnotations ?? + defaultParameters.preventModifyAnnotations, + preventPrinting: + apiParams.preventPrinting ?? defaultParameters.preventPrinting, + preventPrintingFaithful: + apiParams.preventPrintingFaithful ?? + defaultParameters.preventPrintingFaithful, +}); // Static function that can be used by both the hook and automation executor export const buildChangePermissionsFormData = ( parameters: ChangePermissionsParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - - // Add all permission parameters - getFormData(parameters).forEach(([key, value]) => { - formData.append(key, value); +): FormData => + objectToFormData(changePermissionsToApiParams(parameters), { + fileInput: file, }); - return formData; -}; - // Static configuration object -export const changePermissionsOperationConfig = { - toolType: ToolType.singleFile, +export const changePermissionsOperationConfig = defineSingleFileTool({ buildFormData: buildChangePermissionsFormData, + toApiParams: changePermissionsToApiParams, + fromApiParams: changePermissionsFromApiParams, operationType: "changePermissions", - endpoint: "/api/v1/security/add-password", // Change Permissions is a fake endpoint for the Add Password tool + endpoint: ENDPOINT, // Change Permissions is a fake endpoint for the Add Password tool defaultParameters, -} as const; +}); export const useChangePermissionsOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/compress/useCompressOperation.test.ts b/frontend/editor/src/core/hooks/tools/compress/useCompressOperation.test.ts new file mode 100644 index 0000000000..4118eaf7d6 --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/compress/useCompressOperation.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from "vitest"; +import { + buildCompressFormData, + compressFromApiParams, + compressToApiParams, +} from "@app/hooks/tools/compress/useCompressOperation"; +import { + CompressParameters, + defaultParameters, +} from "@app/hooks/tools/compress/useCompressParameters"; + +const params = ( + overrides: Partial, +): CompressParameters => ({ + ...defaultParameters, + ...overrides, +}); + +describe("compressToApiParams", () => { + test("quality mode sends optimizeLevel and no expectedOutputSize", () => { + const api = compressToApiParams( + params({ compressionMethod: "quality", compressionLevel: 7 }), + ); + + expect(api.optimizeLevel).toBe(7); + expect(api.expectedOutputSize).toBeUndefined(); + }); + + test("file-size mode sends expectedOutputSize (level still present for the spec)", () => { + const api = compressToApiParams( + params({ + compressionMethod: "filesize", + fileSizeValue: "100", + fileSizeUnit: "MB", + }), + ); + + // optimizeLevel is required by the backend model; the backend recomputes it + // from the target size, so its presence is harmless. + expect(api.optimizeLevel).toBeDefined(); + expect(api.expectedOutputSize).toBe("100MB"); + }); + + test("omits expectedOutputSize when file-size value is empty", () => { + const api = compressToApiParams( + params({ compressionMethod: "filesize", fileSizeValue: "" }), + ); + + expect(api.expectedOutputSize).toBeUndefined(); + }); + + test("line-art thresholds only included when line art is enabled", () => { + const off = compressToApiParams(params({ lineArt: false })); + expect(off.lineArtThreshold).toBeUndefined(); + expect(off.lineArtEdgeLevel).toBeUndefined(); + + const on = compressToApiParams( + params({ lineArt: true, lineArtThreshold: 40, lineArtEdgeLevel: 2 }), + ); + expect(on.lineArtThreshold).toBe(40); + expect(on.lineArtEdgeLevel).toBe(2); + }); + + test("defaults produce the required optimizeLevel field", () => { + const api = compressToApiParams(defaultParameters); + expect(api.optimizeLevel).toBe(defaultParameters.compressionLevel); + }); +}); + +describe("compressFromApiParams", () => { + test("expectedOutputSize maps back to file-size mode and its value/unit", () => { + const ui = compressFromApiParams({ + optimizeLevel: 5, + expectedOutputSize: "25KB", + }); + + expect(ui.compressionMethod).toBe("filesize"); + expect(ui.fileSizeValue).toBe("25"); + expect(ui.fileSizeUnit).toBe("KB"); + }); + + test("no expectedOutputSize maps back to quality mode", () => { + const ui = compressFromApiParams({ optimizeLevel: 8 }); + + expect(ui.compressionMethod).toBe("quality"); + expect(ui.compressionLevel).toBe(8); + }); +}); + +describe("compress round-trip", () => { + test.each>([ + { compressionMethod: "quality", compressionLevel: 3, grayscale: true }, + { + compressionMethod: "filesize", + fileSizeValue: "10", + fileSizeUnit: "MB", + linearize: true, + }, + { + compressionMethod: "quality", + lineArt: true, + lineArtThreshold: 60, + lineArtEdgeLevel: 3, + }, + ])("toApiParams(fromApiParams(x)) reproduces x %o", (overrides) => { + const api = compressToApiParams(params(overrides)); + const roundTripped = compressToApiParams( + params(compressFromApiParams(api)), + ); + + expect(roundTripped).toEqual(api); + }); +}); + +describe("buildCompressFormData", () => { + test("appends the file and serialized parameters", () => { + const file = new File(["x"], "test.pdf", { type: "application/pdf" }); + const formData = buildCompressFormData( + params({ compressionMethod: "quality", compressionLevel: 6 }), + file, + ); + + expect(formData.get("fileInput")).toBe(file); + expect(formData.get("optimizeLevel")).toBe("6"); + expect(formData.get("grayscale")).toBe("false"); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/compress/useCompressOperation.ts b/frontend/editor/src/core/hooks/tools/compress/useCompressOperation.ts index 6efa24e5d3..b24bb8c6ee 100644 --- a/frontend/editor/src/core/hooks/tools/compress/useCompressOperation.ts +++ b/frontend/editor/src/core/hooks/tools/compress/useCompressOperation.ts @@ -1,52 +1,103 @@ import { useTranslation } from "react-i18next"; import { useToolOperation, - ToolType, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + objectToFormData, + type ToolApiParams, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import { CompressParameters, defaultParameters, } from "@app/hooks/tools/compress/useCompressParameters"; +const ENDPOINT = "/api/v1/misc/compress-pdf" satisfies ToolEndpoint; +type CompressApiParams = ToolApiParams[typeof ENDPOINT]; + +// Convert the tool's UI parameters into the compress-pdf request body. The +// return type is the generated backend model, so a spec change that renames or +// drops a field breaks the build here. +export const compressToApiParams = ( + parameters: CompressParameters, +): CompressApiParams => { + const apiParams: CompressApiParams = { + // compressionLevel is validated to 1-9 by the parameters hook. It is always + // sent: in file-size mode the backend recomputes the level from the target + // size (autoMode in CompressController), so this value only takes effect in + // quality mode. + optimizeLevel: + parameters.compressionLevel as CompressApiParams["optimizeLevel"], + grayscale: parameters.grayscale ?? false, + lineArt: parameters.lineArt, + linearize: parameters.linearize, + }; + + if (parameters.compressionMethod === "filesize" && parameters.fileSizeValue) { + apiParams.expectedOutputSize = `${parameters.fileSizeValue}${parameters.fileSizeUnit}`; + } + + if (parameters.lineArt) { + apiParams.lineArtThreshold = parameters.lineArtThreshold; + apiParams.lineArtEdgeLevel = parameters.lineArtEdgeLevel; + } + + return apiParams; +}; + +// Reconstruct the tool's UI parameters from a compress-pdf request body, so a +// stored or AI-authored step can be re-rendered in the settings UI. +export const compressFromApiParams = ( + apiParams: CompressApiParams, +): Partial => { + const result: Partial = { + compressionLevel: apiParams.optimizeLevel, + grayscale: apiParams.grayscale ?? defaultParameters.grayscale, + lineArt: apiParams.lineArt ?? defaultParameters.lineArt, + linearize: apiParams.linearize ?? defaultParameters.linearize, + }; + + if (apiParams.lineArtThreshold !== undefined) { + result.lineArtThreshold = apiParams.lineArtThreshold; + } + if (apiParams.lineArtEdgeLevel !== undefined) { + result.lineArtEdgeLevel = apiParams.lineArtEdgeLevel; + } + + if (apiParams.expectedOutputSize) { + result.compressionMethod = "filesize"; + const match = /^(\d+(?:\.\d+)?)(KB|MB)$/i.exec( + apiParams.expectedOutputSize, + ); + if (match) { + result.fileSizeValue = match[1]; + result.fileSizeUnit = match[2].toUpperCase() as "KB" | "MB"; + } + } else { + result.compressionMethod = "quality"; + } + + return result; +}; + // Static configuration that can be used by both the hook and automation executor export const buildCompressFormData = ( parameters: CompressParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - - if (parameters.compressionMethod === "quality") { - formData.append("optimizeLevel", parameters.compressionLevel.toString()); - } else { - // File size method - const fileSize = parameters.fileSizeValue - ? `${parameters.fileSizeValue}${parameters.fileSizeUnit}` - : ""; - if (fileSize) { - formData.append("expectedOutputSize", fileSize); - } - } - - formData.append("grayscale", (parameters.grayscale ?? false).toString()); - formData.append("lineArt", parameters.lineArt.toString()); - formData.append("linearize", parameters.linearize.toString()); - if (parameters.lineArt) { - formData.append("lineArtThreshold", parameters.lineArtThreshold.toString()); - formData.append("lineArtEdgeLevel", parameters.lineArtEdgeLevel.toString()); - } - return formData; -}; +): FormData => + objectToFormData(compressToApiParams(parameters), { fileInput: file }); // Static configuration object -export const compressOperationConfig = { - toolType: ToolType.singleFile, +export const compressOperationConfig = defineSingleFileTool({ buildFormData: buildCompressFormData, + toApiParams: compressToApiParams, + fromApiParams: compressFromApiParams, operationType: "compress", - endpoint: "/api/v1/misc/compress-pdf", + endpoint: ENDPOINT, defaultParameters, -} as const; +}); export const useCompressOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/compress/useCompressParameters.test.ts b/frontend/editor/src/core/hooks/tools/compress/useCompressParameters.test.ts new file mode 100644 index 0000000000..eb03607d54 --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/compress/useCompressParameters.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useCompressParameters } from "@app/hooks/tools/compress/useCompressParameters"; + +describe("useCompressParameters", () => { + test("defaults (quality mode) validate", () => { + const { result } = renderHook(() => useCompressParameters()); + + expect(result.current.validateParameters()).toBe(true); + }); + + test("compressionLevel outside 1-9 is invalid", () => { + const { result } = renderHook(() => useCompressParameters()); + + act(() => { + result.current.updateParameter("compressionLevel", 0); + }); + expect(result.current.validateParameters()).toBe(false); + + act(() => { + result.current.updateParameter("compressionLevel", 10); + }); + expect(result.current.validateParameters()).toBe(false); + }); + + test("filesize mode requires a target size", () => { + const { result } = renderHook(() => useCompressParameters()); + + // Filesize mode with no size entered must not validate: otherwise the + // request omits expectedOutputSize and the backend silently falls back to a + // quality compression. + act(() => { + result.current.updateParameter("compressionMethod", "filesize"); + }); + expect(result.current.validateParameters()).toBe(false); + + act(() => { + result.current.updateParameter("fileSizeValue", "5"); + }); + expect(result.current.validateParameters()).toBe(true); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/compress/useCompressParameters.ts b/frontend/editor/src/core/hooks/tools/compress/useCompressParameters.ts index a6e9bfe631..0500b8c77c 100644 --- a/frontend/editor/src/core/hooks/tools/compress/useCompressParameters.ts +++ b/frontend/editor/src/core/hooks/tools/compress/useCompressParameters.ts @@ -37,8 +37,15 @@ export const useCompressParameters = (): CompressParametersHook => { defaultParameters, endpointName: "compress-pdf", validateFn: (params) => { - // For compression, we only need to validate that compression level is within range - return params.compressionLevel >= 1 && params.compressionLevel <= 9; + if (params.compressionLevel < 1 || params.compressionLevel > 9) { + return false; + } + // Filesize mode needs a target size; without one the request omits + // expectedOutputSize and the backend silently does a quality compression. + if (params.compressionMethod === "filesize") { + return params.fileSizeValue.trim() !== ""; + } + return true; }, }); }; diff --git a/frontend/editor/src/core/hooks/tools/convert/useConvertOperation.ts b/frontend/editor/src/core/hooks/tools/convert/useConvertOperation.ts index 1da1b96b31..a9488e373d 100644 --- a/frontend/editor/src/core/hooks/tools/convert/useConvertOperation.ts +++ b/frontend/editor/src/core/hooks/tools/convert/useConvertOperation.ts @@ -8,7 +8,7 @@ import { import { createFileFromApiResponse } from "@app/utils/fileResponseUtils"; import { useToolOperation, - ToolType, + defineCustomTool, CustomProcessorResult, } from "@app/hooks/tools/shared/useToolOperation"; import { @@ -300,8 +300,7 @@ export const convertProcessor = async ( }; // Static configuration object -export const convertOperationConfig = { - toolType: ToolType.custom, +export const convertOperationConfig = defineCustomTool({ customProcessor: convertProcessor, // Can't use callback version here operationType: "convert", defaultParameters, @@ -311,7 +310,7 @@ export const convertOperationConfig = { params.toExtension === "pdfx" ? "pdfa" : params.toExtension; return getEndpointUrl(params.fromExtension, actualToExtension) ?? undefined; }, -} as const; +}); export const useConvertOperation = (parameters?: ConvertParameters) => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/crop/useCropOperation.test.ts b/frontend/editor/src/core/hooks/tools/crop/useCropOperation.test.ts new file mode 100644 index 0000000000..806ad40af0 --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/crop/useCropOperation.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "vitest"; +import { + cropFromApiParams, + cropToApiParams, +} from "@app/hooks/tools/crop/useCropOperation"; +import { + CropParameters, + defaultParameters, +} from "@app/hooks/tools/crop/useCropParameters"; + +describe("crop mappers", () => { + // With autoCrop on the coordinates aren't sent, so they must not resurface on + // the round trip; with autoCrop off the rectangle must survive intact. + test.each<{ label: string; overrides: Partial }>([ + { label: "autoCrop on", overrides: { autoCrop: true } }, + { + label: "autoCrop off with a rectangle", + overrides: { + autoCrop: false, + cropArea: { x: 10, y: 20, width: 300, height: 400 }, + }, + }, + ])("round-trips backend params ($label)", ({ overrides }) => { + const api = cropToApiParams({ ...defaultParameters, ...overrides }); + const roundTripped = cropToApiParams({ + ...defaultParameters, + ...cropFromApiParams(api), + }); + + expect(roundTripped).toEqual(api); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/crop/useCropOperation.ts b/frontend/editor/src/core/hooks/tools/crop/useCropOperation.ts index 67d72c8df0..b3a69f9dbe 100644 --- a/frontend/editor/src/core/hooks/tools/crop/useCropOperation.ts +++ b/frontend/editor/src/core/hooks/tools/crop/useCropOperation.ts @@ -1,44 +1,72 @@ import { useTranslation } from "react-i18next"; import { useToolOperation, - ToolType, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + objectToFormData, + type ToolApiParams, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import { CropParameters, defaultParameters, } from "@app/hooks/tools/crop/useCropParameters"; +import { DEFAULT_CROP_AREA } from "@app/constants/cropConstants"; + +const ENDPOINT = "/api/v1/general/crop" satisfies ToolEndpoint; +type CropApiParams = ToolApiParams[typeof ENDPOINT]; + +// Convert the tool's UI parameters into the crop request body. The return type +// is the generated backend model, so a spec change that renames or drops a +// field breaks the build here. +export const cropToApiParams = (parameters: CropParameters): CropApiParams => { + const apiParams: CropApiParams = { + autoCrop: parameters.autoCrop, + }; + + if (!parameters.autoCrop) { + const cropArea = parameters.cropArea; + apiParams.x = cropArea.x; + apiParams.y = cropArea.y; + apiParams.width = cropArea.width; + apiParams.height = cropArea.height; + } + + return apiParams; +}; + +// Reconstruct the tool's UI parameters from a crop request body, so a stored or +// AI-authored step can be re-rendered in the settings UI. +export const cropFromApiParams = ( + apiParams: CropApiParams, +): Partial => ({ + autoCrop: apiParams.autoCrop ?? defaultParameters.autoCrop, + cropArea: { + x: apiParams.x ?? DEFAULT_CROP_AREA.x, + y: apiParams.y ?? DEFAULT_CROP_AREA.y, + width: apiParams.width ?? DEFAULT_CROP_AREA.width, + height: apiParams.height ?? DEFAULT_CROP_AREA.height, + }, +}); // Static configuration that can be used by both the hook and automation executor export const buildCropFormData = ( parameters: CropParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - - if (!parameters.autoCrop) { - const cropArea = parameters.cropArea; - - formData.append("x", cropArea.x.toString()); - formData.append("y", cropArea.y.toString()); - formData.append("width", cropArea.width.toString()); - formData.append("height", cropArea.height.toString()); - } - - formData.append("autoCrop", parameters.autoCrop.toString()); - - return formData; -}; +): FormData => + objectToFormData(cropToApiParams(parameters), { fileInput: file }); // Static configuration object -export const cropOperationConfig = { - toolType: ToolType.singleFile, +export const cropOperationConfig = defineSingleFileTool({ buildFormData: buildCropFormData, + toApiParams: cropToApiParams, + fromApiParams: cropFromApiParams, operationType: "crop", - endpoint: "/api/v1/general/crop", + endpoint: ENDPOINT, defaultParameters, -} as const; +}); export const useCropOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsOperation.test.ts b/frontend/editor/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsOperation.test.ts new file mode 100644 index 0000000000..288beed847 --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsOperation.test.ts @@ -0,0 +1,46 @@ +import { describe, test, expect } from "vitest"; +import { expectConsole } from "@app/tests/failOnConsole"; +import { editTableOfContentsFromApiParams } from "@app/hooks/tools/editTableOfContents/useEditTableOfContentsOperation"; + +describe("editTableOfContentsFromApiParams", () => { + test("passes replaceExisting through", () => { + expect(editTableOfContentsFromApiParams({ replaceExisting: true })).toEqual( + { + replaceExisting: true, + }, + ); + }); + + test("hydrates a valid (empty) bookmark array", () => { + expect( + editTableOfContentsFromApiParams({ + replaceExisting: false, + bookmarkData: "[]", + }), + ).toEqual({ replaceExisting: false, bookmarks: [] }); + }); + + test.each(["", "not json", "{truncated"])( + "does not throw on malformed bookmarkData (%j); leaves bookmarks unset", + (bookmarkData) => { + expectConsole.warn(/could not parse bookmarkData/); + const result = editTableOfContentsFromApiParams({ + replaceExisting: true, + bookmarkData, + }); + expect(result).toEqual({ replaceExisting: true }); + expect(result).not.toHaveProperty("bookmarks"); + }, + ); + + test.each(["{}", "null", "42"])( + "ignores non-array bookmarkData (%j) without throwing", + (bookmarkData) => { + const result = editTableOfContentsFromApiParams({ + replaceExisting: false, + bookmarkData, + }); + expect(result).not.toHaveProperty("bookmarks"); + }, + ); +}); diff --git a/frontend/editor/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsOperation.ts b/frontend/editor/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsOperation.ts index 7f94008503..8473ea972c 100644 --- a/frontend/editor/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsOperation.ts +++ b/frontend/editor/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsOperation.ts @@ -1,34 +1,75 @@ import { useTranslation } from "react-i18next"; import { - ToolType, - type ToolOperationConfig, + defineSingleFileTool, useToolOperation, } from "@app/hooks/tools/shared/useToolOperation"; +import { + objectToFormData, + type ToolApiParams, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import { EditTableOfContentsParameters } from "@app/hooks/tools/editTableOfContents/useEditTableOfContentsParameters"; -import { serializeBookmarkNodes } from "@app/utils/editTableOfContents"; +import { + hydrateBookmarkPayload, + serializeBookmarkNodes, + type BookmarkPayload, +} from "@app/utils/editTableOfContents"; + +const ENDPOINT = + "/api/v1/general/edit-table-of-contents" satisfies ToolEndpoint; +type EditTableOfContentsApiParams = ToolApiParams[typeof ENDPOINT]; + +// bookmarkData is a string in the backend model even though it carries JSON, so +// the serialized bookmark tree is JSON-encoded into that string here. +export const editTableOfContentsToApiParams = ( + parameters: EditTableOfContentsParameters, +): EditTableOfContentsApiParams => ({ + replaceExisting: parameters.replaceExisting, + bookmarkData: JSON.stringify(serializeBookmarkNodes(parameters.bookmarks)), +}); + +export const editTableOfContentsFromApiParams = ( + apiParams: EditTableOfContentsApiParams, +): Partial => { + const result: Partial = { + replaceExisting: apiParams.replaceExisting, + }; + + // bookmarkData carries JSON in a string field, so a stored step + // could hold malformed or non-array content. Degrade to leaving bookmarks unset. + if (apiParams.bookmarkData !== undefined) { + try { + const payload = JSON.parse(apiParams.bookmarkData) as BookmarkPayload[]; + if (Array.isArray(payload)) { + result.bookmarks = hydrateBookmarkPayload(payload); + } + } catch (error) { + console.warn( + `editTableOfContents: could not parse bookmarkData; ` + + `leaving bookmarks unset. Error: ${error}`, + ); + } + } + + return result; +}; const buildFormData = ( parameters: EditTableOfContentsParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - formData.append("replaceExisting", String(parameters.replaceExisting)); - formData.append( - "bookmarkData", - JSON.stringify(serializeBookmarkNodes(parameters.bookmarks)), - ); - return formData; -}; +): FormData => + objectToFormData(editTableOfContentsToApiParams(parameters), { + fileInput: file, + }); -export const editTableOfContentsOperationConfig: ToolOperationConfig = - { - toolType: ToolType.singleFile, - operationType: "editTableOfContents", - endpoint: "/api/v1/general/edit-table-of-contents", - buildFormData, - }; +export const editTableOfContentsOperationConfig = defineSingleFileTool({ + operationType: "editTableOfContents", + endpoint: ENDPOINT, + buildFormData, + toApiParams: editTableOfContentsToApiParams, + fromApiParams: editTableOfContentsFromApiParams, +}); export const useEditTableOfContentsOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/extractImages/useExtractImagesOperation.ts b/frontend/editor/src/core/hooks/tools/extractImages/useExtractImagesOperation.ts index e52a9d6f2e..0a70f43424 100644 --- a/frontend/editor/src/core/hooks/tools/extractImages/useExtractImagesOperation.ts +++ b/frontend/editor/src/core/hooks/tools/extractImages/useExtractImagesOperation.ts @@ -2,8 +2,13 @@ import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { useToolOperation, - ToolType, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + objectToFormData, + type ToolApiParams, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import { ExtractImagesParameters, @@ -11,26 +16,39 @@ import { } from "@app/hooks/tools/extractImages/useExtractImagesParameters"; import { useToolResources } from "@app/hooks/tools/shared/useToolResources"; +const ENDPOINT = "/api/v1/misc/extract-images" satisfies ToolEndpoint; +type ExtractImagesApiParams = ToolApiParams[typeof ENDPOINT]; + +// The frontend param type uses "jpg" while the backend model uses "jpeg"; the +// wire value is preserved verbatim (as the pre-mapper code did) via the cast. +export const extractImagesToApiParams = ( + parameters: ExtractImagesParameters, +): ExtractImagesApiParams => ({ + format: parameters.format as ExtractImagesApiParams["format"], +}); + +export const extractImagesFromApiParams = ( + apiParams: ExtractImagesApiParams, +): Partial => ({ + format: apiParams.format as ExtractImagesParameters["format"], +}); + // Static configuration that can be used by both the hook and automation executor export const buildExtractImagesFormData = ( parameters: ExtractImagesParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - formData.append("format", parameters.format); - // formData.append("allowDuplicates", parameters.allowDuplicates.toString()); - return formData; -}; +): FormData => + objectToFormData(extractImagesToApiParams(parameters), { fileInput: file }); // Static configuration object (without response handler - will be added in hook) -export const extractImagesOperationConfig = { - toolType: ToolType.singleFile, +export const extractImagesOperationConfig = defineSingleFileTool({ buildFormData: buildExtractImagesFormData, + toApiParams: extractImagesToApiParams, + fromApiParams: extractImagesFromApiParams, operationType: "extractImages", - endpoint: "/api/v1/misc/extract-images", + endpoint: ENDPOINT, defaultParameters, -} as const; +}); export const useExtractImagesOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/extractPages/useExtractPagesOperation.ts b/frontend/editor/src/core/hooks/tools/extractPages/useExtractPagesOperation.ts index 4119ed191b..0cd5273f0b 100644 --- a/frontend/editor/src/core/hooks/tools/extractPages/useExtractPagesOperation.ts +++ b/frontend/editor/src/core/hooks/tools/extractPages/useExtractPagesOperation.ts @@ -1,7 +1,7 @@ import apiClient from "@app/services/apiClient"; import { useTranslation } from "react-i18next"; import { - ToolType, + defineCustomTool, useToolOperation, CustomProcessorResult, } from "@app/hooks/tools/shared/useToolOperation"; @@ -33,8 +33,7 @@ async function resolveSelectionToCsv( } } -export const extractPagesOperationConfig = { - toolType: ToolType.custom, +export const extractPagesOperationConfig = defineCustomTool({ operationType: "extractPages", customProcessor: async ( parameters: ExtractPagesParameters, @@ -71,7 +70,7 @@ export const extractPagesOperationConfig = { }; }, defaultParameters, -} as const; +}); export const useExtractPagesOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/flatten/useFlattenOperation.ts b/frontend/editor/src/core/hooks/tools/flatten/useFlattenOperation.ts index 9138b824ed..4027b7048e 100644 --- a/frontend/editor/src/core/hooks/tools/flatten/useFlattenOperation.ts +++ b/frontend/editor/src/core/hooks/tools/flatten/useFlattenOperation.ts @@ -1,37 +1,72 @@ import { useTranslation } from "react-i18next"; import { - ToolType, useToolOperation, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + objectToFormData, + type ToolApiParams, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import { FlattenParameters, defaultParameters, } from "@app/hooks/tools/flatten/useFlattenParameters"; +const ENDPOINT = "/api/v1/misc/flatten" satisfies ToolEndpoint; +type FlattenApiParams = ToolApiParams[typeof ENDPOINT]; + +// Convert the tool's UI parameters into the flatten request body. The return +// type is the generated backend model, so a spec change that renames or drops a +// field breaks the build here. +export const flattenToApiParams = ( + parameters: FlattenParameters, +): FlattenApiParams => { + const apiParams: FlattenApiParams = { + flattenOnlyForms: parameters.flattenOnlyForms, + }; + + if (parameters.renderDpi != null) { + apiParams.renderDpi = parameters.renderDpi; + } + + return apiParams; +}; + +// Reconstruct the tool's UI parameters from a flatten request body, so a stored +// or AI-authored step can be re-rendered in the settings UI. +export const flattenFromApiParams = ( + apiParams: FlattenApiParams, +): Partial => { + const result: Partial = { + flattenOnlyForms: + apiParams.flattenOnlyForms ?? defaultParameters.flattenOnlyForms, + }; + + if (apiParams.renderDpi != null) { + result.renderDpi = apiParams.renderDpi; + } + + return result; +}; + // Static function that can be used by both the hook and automation executor export const buildFlattenFormData = ( parameters: FlattenParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - formData.append("flattenOnlyForms", parameters.flattenOnlyForms.toString()); - if (parameters.renderDpi != null) { - formData.append("renderDpi", parameters.renderDpi.toString()); - } - return formData; -}; +): FormData => + objectToFormData(flattenToApiParams(parameters), { fileInput: file }); // Static configuration object -export const flattenOperationConfig = { - toolType: ToolType.singleFile, +export const flattenOperationConfig = defineSingleFileTool({ buildFormData: buildFlattenFormData, + toApiParams: flattenToApiParams, + fromApiParams: flattenFromApiParams, operationType: "flatten", - endpoint: "/api/v1/misc/flatten", - multiFileEndpoint: false, + endpoint: ENDPOINT, defaultParameters, -} as const; +}); export const useFlattenOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/merge/useMergeOperation.test.ts b/frontend/editor/src/core/hooks/tools/merge/useMergeOperation.test.ts index c637f2fe53..21817ddfcd 100644 --- a/frontend/editor/src/core/hooks/tools/merge/useMergeOperation.test.ts +++ b/frontend/editor/src/core/hooks/tools/merge/useMergeOperation.test.ts @@ -29,6 +29,10 @@ import { ToolOperationHook, useToolOperation, } from "@app/hooks/tools/shared/useToolOperation"; +import { + mergeFromApiParams, + mergeToApiParams, +} from "@app/hooks/tools/merge/useMergeOperation"; describe("useMergeOperation", () => { const mockUseToolOperation = vi.mocked(useToolOperation); @@ -128,4 +132,60 @@ describe("useMergeOperation", () => { expect(formData2.get("removeCertSign")).toBe("true"); expect(formData2.get("generateToc")).toBe("true"); }); + + test("should include client file IDs derived from the files", () => { + renderHook(() => useMergeOperation()); + + const config = getToolConfig(); + const mockFiles = [ + new File(["a"], "a.pdf", { type: "application/pdf" }), + new File(["b"], "b.pdf", { type: "application/pdf" }), + ]; + const formData = config.buildFormData( + { removeDigitalSignature: false, generateTableOfContents: false }, + mockFiles, + ); + + expect(formData.get("clientFileIds")).toBe( + JSON.stringify(["a.pdf", "b.pdf"]), + ); + }); +}); + +describe("merge mappers", () => { + test("toApiParams renames UI fields to the backend request model", () => { + expect( + mergeToApiParams({ + removeDigitalSignature: true, + generateTableOfContents: false, + }), + ).toEqual({ + sortType: "orderProvided", + removeCertSign: true, + generateToc: false, + }); + }); + + test("fromApiParams maps the backend request model back to UI fields", () => { + expect( + mergeFromApiParams({ removeCertSign: false, generateToc: true }), + ).toEqual({ + removeDigitalSignature: false, + generateTableOfContents: true, + }); + }); + + test("round-trips backend params", () => { + const api = mergeToApiParams({ + removeDigitalSignature: true, + generateTableOfContents: true, + }); + const roundTripped = mergeToApiParams({ + removeDigitalSignature: false, + generateTableOfContents: false, + ...mergeFromApiParams(api), + }); + + expect(roundTripped).toEqual(api); + }); }); diff --git a/frontend/editor/src/core/hooks/tools/merge/useMergeOperation.ts b/frontend/editor/src/core/hooks/tools/merge/useMergeOperation.ts index 44cee9e134..fc742ff410 100644 --- a/frontend/editor/src/core/hooks/tools/merge/useMergeOperation.ts +++ b/frontend/editor/src/core/hooks/tools/merge/useMergeOperation.ts @@ -1,51 +1,69 @@ import { useTranslation } from "react-i18next"; import { useToolOperation, - ToolOperationConfig, - ToolType, + defineMultiFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + objectToFormData, + type ToolApiParams, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import { MergeParameters, defaultParameters, } from "@app/hooks/tools/merge/useMergeParameters"; +const ENDPOINT = "/api/v1/general/merge-pdfs" satisfies ToolEndpoint; +type MergeApiParams = ToolApiParams[typeof ENDPOINT]; + +// Convert the tool's UI parameters into the merge-pdfs request body. File-derived +// fields (clientFileIds) are appended by buildFormData, not here. +export const mergeToApiParams = ( + parameters: MergeParameters, +): MergeApiParams => ({ + // The UI owns file ordering, so the backend is always told to keep it. + sortType: "orderProvided", + removeCertSign: parameters.removeDigitalSignature ?? false, + generateToc: parameters.generateTableOfContents ?? false, +}); + +// Reconstruct the tool's UI parameters from a merge-pdfs request body. +export const mergeFromApiParams = ( + apiParams: MergeApiParams, +): Partial => ({ + removeDigitalSignature: + apiParams.removeCertSign ?? defaultParameters.removeDigitalSignature, + generateTableOfContents: + apiParams.generateToc ?? defaultParameters.generateTableOfContents, +}); + const buildFormData = ( parameters: MergeParameters, files: File[], ): FormData => { - const formData = new FormData(); - - files.forEach((file) => { - formData.append("fileInput", file); + const formData = objectToFormData(mergeToApiParams(parameters), { + fileInput: files, }); - // Provide stable client file IDs (align with files order) + // Stable client file IDs, aligned with the fileInput order. Derived from the + // files themselves, so it belongs to the file-appending step. const clientIds: string[] = files.map((f) => String((f as { fileId?: string }).fileId || f.name), ); formData.append("clientFileIds", JSON.stringify(clientIds)); - formData.append("sortType", "orderProvided"); // Always use orderProvided since UI handles sorting - formData.append( - "removeCertSign", - (parameters.removeDigitalSignature ?? false).toString(), - ); - formData.append( - "generateToc", - (parameters.generateTableOfContents ?? false).toString(), - ); - return formData; }; // Operation configuration for automation -export const mergeOperationConfig: ToolOperationConfig = { - toolType: ToolType.multiFile, +export const mergeOperationConfig = defineMultiFileTool({ buildFormData, + toApiParams: mergeToApiParams, + fromApiParams: mergeFromApiParams, operationType: "merge", - endpoint: "/api/v1/general/merge-pdfs", + endpoint: ENDPOINT, filePrefix: "merged_", defaultParameters, -}; +}); export const useMergeOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/ocr/useOCROperation.ts b/frontend/editor/src/core/hooks/tools/ocr/useOCROperation.ts index b7d6d56564..f995ef06fc 100644 --- a/frontend/editor/src/core/hooks/tools/ocr/useOCROperation.ts +++ b/frontend/editor/src/core/hooks/tools/ocr/useOCROperation.ts @@ -7,11 +7,19 @@ import { import { useToolOperation, ToolOperationConfig, - ToolType, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + objectToFormData, + type ToolApiParams, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import { useToolResources } from "@app/hooks/tools/shared/useToolResources"; +const ENDPOINT = "/api/v1/misc/ocr-pdf" satisfies ToolEndpoint; +type OCRApiParams = ToolApiParams[typeof ENDPOINT]; + // Helper: get MIME type based on file extension function getMimeType(filename: string): string { const ext = filename.toLowerCase().split(".").pop(); @@ -48,28 +56,49 @@ function stripExt(name: string): string { return i > 0 ? name.slice(0, i) : name; } +// Convert the tool's UI parameters into the ocr-pdf request body. The return +// type is the generated backend model, so a spec change that renames or drops a +// field breaks the build here. +export const ocrToApiParams = (parameters: OCRParameters): OCRApiParams => { + const options = parameters.additionalOptions || []; + return { + languages: parameters.languages, + ocrType: parameters.ocrType as OCRApiParams["ocrType"], + ocrRenderType: parameters.ocrRenderType as OCRApiParams["ocrRenderType"], + sidecar: options.includes("sidecar"), + deskew: options.includes("deskew"), + clean: options.includes("clean"), + cleanFinal: options.includes("cleanFinal"), + removeImagesAfter: options.includes("removeImagesAfter"), + }; +}; + +// Reconstruct the tool's UI parameters from an ocr-pdf request body, so a stored +// or AI-authored step can be re-rendered in the settings UI. +export const ocrFromApiParams = ( + apiParams: OCRApiParams, +): Partial => { + const additionalOptions: string[] = []; + if (apiParams.sidecar) additionalOptions.push("sidecar"); + if (apiParams.deskew) additionalOptions.push("deskew"); + if (apiParams.clean) additionalOptions.push("clean"); + if (apiParams.cleanFinal) additionalOptions.push("cleanFinal"); + if (apiParams.removeImagesAfter) additionalOptions.push("removeImagesAfter"); + + return { + languages: apiParams.languages ?? defaultParameters.languages, + ocrType: apiParams.ocrType, + ocrRenderType: apiParams.ocrRenderType ?? defaultParameters.ocrRenderType, + additionalOptions, + }; +}; + // Static function that can be used by both the hook and automation executor export const buildOCRFormData = ( parameters: OCRParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - parameters.languages.forEach((lang) => formData.append("languages", lang)); - formData.append("ocrType", parameters.ocrType); - formData.append("ocrRenderType", parameters.ocrRenderType); - - const options = parameters.additionalOptions || []; - formData.append("sidecar", options.includes("sidecar").toString()); - formData.append("deskew", options.includes("deskew").toString()); - formData.append("clean", options.includes("clean").toString()); - formData.append("cleanFinal", options.includes("cleanFinal").toString()); - formData.append( - "removeImagesAfter", - options.includes("removeImagesAfter").toString(), - ); - return formData; -}; +): FormData => + objectToFormData(ocrToApiParams(parameters), { fileInput: file }); // Static response handler for OCR - can be used by automation executor export const ocrResponseHandler = async ( @@ -122,13 +151,14 @@ export const ocrResponseHandler = async ( }; // Static configuration object (without t function dependencies) -export const ocrOperationConfig = { - toolType: ToolType.singleFile, +export const ocrOperationConfig = defineSingleFileTool({ buildFormData: buildOCRFormData, + toApiParams: ocrToApiParams, + fromApiParams: ocrFromApiParams, operationType: "ocr", - endpoint: "/api/v1/misc/ocr-pdf", + endpoint: ENDPOINT, defaultParameters, -} as const; +}); export const useOCROperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/overlayPdfs/useOverlayPdfsOperation.ts b/frontend/editor/src/core/hooks/tools/overlayPdfs/useOverlayPdfsOperation.ts index 02b72e9e1d..f71c237e9d 100644 --- a/frontend/editor/src/core/hooks/tools/overlayPdfs/useOverlayPdfsOperation.ts +++ b/frontend/editor/src/core/hooks/tools/overlayPdfs/useOverlayPdfsOperation.ts @@ -1,45 +1,72 @@ import { useTranslation } from "react-i18next"; import { useToolOperation, - ToolType, type ToolOperationConfig, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + objectToFormData, + type ToolApiParams, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; -import { type OverlayPdfsParameters } from "@app/hooks/tools/overlayPdfs/useOverlayPdfsParameters"; +import { + type OverlayPdfsParameters, + defaultParameters, +} from "@app/hooks/tools/overlayPdfs/useOverlayPdfsParameters"; + +const ENDPOINT = "/api/v1/general/overlay-pdfs" satisfies ToolEndpoint; +type OverlayPdfsApiParams = ToolApiParams[typeof ENDPOINT]; + +// Convert the tool's UI parameters into the overlay-pdfs request body. The +// overlay documents are actual File uploads sent as repeated `overlayFiles` +// fields (see buildFormData), so `overlayFiles` here is an empty array: the real +// uploads are appended separately and an empty array serializes to no fields. +export const overlayPdfsToApiParams = ( + parameters: OverlayPdfsParameters, +): OverlayPdfsApiParams => { + const apiParams: OverlayPdfsApiParams = { + overlayFiles: [], + overlayMode: parameters.overlayMode, + overlayPosition: parameters.overlayPosition, + }; + + // Counts are only relevant for FixedRepeatOverlay; the server accepts repeated + // 'counts' fields. + if (parameters.overlayMode === "FixedRepeatOverlay") { + apiParams.counts = parameters.counts || []; + } + + return apiParams; +}; + +// Reconstruct the tool's UI parameters from an overlay-pdfs request body. The +// overlay File uploads cannot be recovered from the request model. +export const overlayPdfsFromApiParams = ( + apiParams: OverlayPdfsApiParams, +): Partial => ({ + overlayMode: apiParams.overlayMode, + overlayPosition: apiParams.overlayPosition, + counts: apiParams.counts ?? defaultParameters.counts, +}); const buildFormData = ( parameters: OverlayPdfsParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - - // Overlay files - for (const overlay of parameters.overlayFiles || []) { - formData.append("overlayFiles", overlay); - } - - // Mode and position - formData.append("overlayMode", parameters.overlayMode); - formData.append("overlayPosition", String(parameters.overlayPosition)); - - // Counts (only relevant for FixedRepeatOverlay, server accepts repeated 'counts' fields) - if (parameters.overlayMode === "FixedRepeatOverlay") { - for (const count of parameters.counts || []) { - formData.append("counts", String(count)); - } - } - - return formData; -}; +): FormData => + objectToFormData(overlayPdfsToApiParams(parameters), { + fileInput: file, + overlayFiles: parameters.overlayFiles || [], + }); export const overlayPdfsOperationConfig: ToolOperationConfig = - { - toolType: ToolType.singleFile, + defineSingleFileTool({ buildFormData, + toApiParams: overlayPdfsToApiParams, + fromApiParams: overlayPdfsFromApiParams, operationType: "overlayPdfs", - endpoint: "/api/v1/general/overlay-pdfs", - }; + endpoint: ENDPOINT, + }); export const useOverlayPdfsOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/pageLayout/usePageLayoutOperation.test.ts b/frontend/editor/src/core/hooks/tools/pageLayout/usePageLayoutOperation.test.ts new file mode 100644 index 0000000000..874fdff811 --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/pageLayout/usePageLayoutOperation.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "vitest"; +import { + pageLayoutFromApiParams, + pageLayoutToApiParams, +} from "@app/hooks/tools/pageLayout/usePageLayoutOperation"; +import { + PageLayoutParameters, + defaultParameters, +} from "@app/hooks/tools/pageLayout/usePageLayoutParameters"; + +describe("pageLayout mappers", () => { + test.each>([ + {}, + { addBorder: true, borderWidth: 3, innerMargin: 5, topMargin: 2 }, + ])("round-trips backend params for %o", (overrides) => { + const api = pageLayoutToApiParams({ ...defaultParameters, ...overrides }); + const roundTripped = pageLayoutToApiParams({ + ...defaultParameters, + ...pageLayoutFromApiParams(api), + }); + + expect(roundTripped).toEqual(api); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/pageLayout/usePageLayoutOperation.ts b/frontend/editor/src/core/hooks/tools/pageLayout/usePageLayoutOperation.ts index b168f15f6f..5de635f965 100644 --- a/frontend/editor/src/core/hooks/tools/pageLayout/usePageLayoutOperation.ts +++ b/frontend/editor/src/core/hooks/tools/pageLayout/usePageLayoutOperation.ts @@ -1,44 +1,80 @@ import { useTranslation } from "react-i18next"; import { - ToolType, useToolOperation, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + objectToFormData, + type ToolApiParams, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import { PageLayoutParameters, defaultParameters, } from "@app/hooks/tools/pageLayout/usePageLayoutParameters"; +const ENDPOINT = "/api/v1/general/multi-page-layout" satisfies ToolEndpoint; +type PageLayoutApiParams = ToolApiParams[typeof ENDPOINT]; + +// Convert the tool's UI parameters into the multi-page-layout request body. The +// return type is the generated backend model, so a spec change that renames or +// drops a field breaks the build here. +export const pageLayoutToApiParams = ( + parameters: PageLayoutParameters, +): PageLayoutApiParams => ({ + mode: parameters.mode, + pagesPerSheet: + parameters.pagesPerSheet as PageLayoutApiParams["pagesPerSheet"], + rows: parameters.rows, + cols: parameters.cols, + orientation: parameters.orientation, + arrangement: parameters.arrangement, + readingDirection: parameters.readingDirection, + innerMargin: parameters.innerMargin ?? 0, + topMargin: parameters.topMargin ?? 0, + bottomMargin: parameters.bottomMargin ?? 0, + leftMargin: parameters.leftMargin ?? 0, + rightMargin: parameters.rightMargin ?? 0, + addBorder: parameters.addBorder, + borderWidth: parameters.borderWidth ?? 1, +}); + +// Reconstruct the tool's UI parameters from a multi-page-layout request body, so +// a stored or AI-authored step can be re-rendered in the settings UI. +export const pageLayoutFromApiParams = ( + apiParams: PageLayoutApiParams, +): Partial => ({ + mode: apiParams.mode, + pagesPerSheet: apiParams.pagesPerSheet, + rows: apiParams.rows, + cols: apiParams.cols, + orientation: apiParams.orientation, + arrangement: apiParams.arrangement, + readingDirection: apiParams.readingDirection, + innerMargin: apiParams.innerMargin, + topMargin: apiParams.topMargin, + bottomMargin: apiParams.bottomMargin, + leftMargin: apiParams.leftMargin, + rightMargin: apiParams.rightMargin, + addBorder: apiParams.addBorder, + borderWidth: apiParams.borderWidth, +}); + export const buildPageLayoutFormData = ( parameters: PageLayoutParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - formData.append("mode", String(parameters.mode)); - formData.append("pagesPerSheet", String(parameters.pagesPerSheet)); - formData.append("rows", String(parameters.rows)); - formData.append("cols", String(parameters.cols)); - formData.append("orientation", String(parameters.orientation)); - formData.append("arrangement", String(parameters.arrangement)); - formData.append("readingDirection", String(parameters.readingDirection)); - formData.append("innerMargin", String(parameters.innerMargin ?? 0)); - formData.append("topMargin", String(parameters.topMargin ?? 0)); - formData.append("bottomMargin", String(parameters.bottomMargin ?? 0)); - formData.append("leftMargin", String(parameters.leftMargin ?? 0)); - formData.append("rightMargin", String(parameters.rightMargin ?? 0)); - formData.append("addBorder", String(parameters.addBorder)); - formData.append("borderWidth", String(parameters.borderWidth ?? 1)); - return formData; -}; +): FormData => + objectToFormData(pageLayoutToApiParams(parameters), { fileInput: file }); -export const pageLayoutOperationConfig = { - toolType: ToolType.singleFile, +export const pageLayoutOperationConfig = defineSingleFileTool({ buildFormData: buildPageLayoutFormData, + toApiParams: pageLayoutToApiParams, + fromApiParams: pageLayoutFromApiParams, operationType: "pageLayout", - endpoint: "/api/v1/general/multi-page-layout", + endpoint: ENDPOINT, defaultParameters, -} as const; +}); export const usePageLayoutOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/redact/useRedactOperation.ts b/frontend/editor/src/core/hooks/tools/redact/useRedactOperation.ts index bc4db5b6c3..82d45e9e55 100644 --- a/frontend/editor/src/core/hooks/tools/redact/useRedactOperation.ts +++ b/frontend/editor/src/core/hooks/tools/redact/useRedactOperation.ts @@ -1,56 +1,80 @@ import { useTranslation } from "react-i18next"; import { useToolOperation, - ToolType, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + objectToFormData, + type ToolApiParams, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import { RedactParameters, defaultParameters, } from "@app/hooks/tools/redact/useRedactParameters"; +// Automatic redaction is the only mode that calls the backend; manual redaction +// is handled client-side by EmbedPDF in the viewer. +const AUTO_ENDPOINT = "/api/v1/security/auto-redact" satisfies ToolEndpoint; +type RedactApiParams = ToolApiParams[typeof AUTO_ENDPOINT]; + +// Convert the tool's UI parameters into the auto-redact request body. +export const redactToApiParams = ( + parameters: RedactParameters, +): RedactApiParams => ({ + // The backend takes the search terms as a single newline-separated string. + listOfText: parameters.wordsToRedact.join("\n"), + useRegex: parameters.useRegex, + wholeWordSearch: parameters.wholeWordSearch, + // The backend expects the hex colour without the leading '#'. + redactColor: parameters.redactColor.replace("#", ""), + customPadding: parameters.customPadding, + convertPDFToImage: parameters.convertPDFToImage, +}); + +// Reconstruct the tool's UI parameters from an auto-redact request body. +export const redactFromApiParams = ( + apiParams: RedactApiParams, +): Partial => ({ + mode: "automatic", + wordsToRedact: apiParams.listOfText ? apiParams.listOfText.split("\n") : [], + useRegex: apiParams.useRegex ?? defaultParameters.useRegex, + wholeWordSearch: + apiParams.wholeWordSearch ?? defaultParameters.wholeWordSearch, + redactColor: apiParams.redactColor + ? `#${apiParams.redactColor}` + : defaultParameters.redactColor, + customPadding: apiParams.customPadding, + convertPDFToImage: + apiParams.convertPDFToImage ?? defaultParameters.convertPDFToImage, +}); + // Static configuration that can be used by both the hook and automation executor export const buildRedactFormData = ( parameters: RedactParameters, file: File, ): FormData => { - const formData = new FormData(); - - // For automatic mode we hit the backend and need full payload - if (parameters.mode === "automatic") { - formData.append("fileInput", file); - // Convert array to newline-separated string as expected by backend - formData.append("listOfText", parameters.wordsToRedact.join("\n")); - formData.append("useRegex", parameters.useRegex.toString()); - formData.append("wholeWordSearch", parameters.wholeWordSearch.toString()); - formData.append("redactColor", parameters.redactColor.replace("#", "")); - formData.append("customPadding", parameters.customPadding.toString()); - formData.append( - "convertPDFToImage", - parameters.convertPDFToImage.toString(), - ); - } else { - // Manual redaction uses EmbedPDF in-viewer; we don't call the API. - // Return an empty formData to satisfy shared interfaces without throwing. + // Manual redaction uses EmbedPDF in-viewer and makes no API call; return an + // empty payload to satisfy the shared interface without throwing. + if (parameters.mode !== "automatic") { + return new FormData(); } - - return formData; + return objectToFormData(redactToApiParams(parameters), { fileInput: file }); }; // Static configuration object -export const redactOperationConfig = { - toolType: ToolType.singleFile, +export const redactOperationConfig = defineSingleFileTool({ buildFormData: buildRedactFormData, + toApiParams: redactToApiParams, + fromApiParams: redactFromApiParams, operationType: "redact", - endpoint: (parameters: RedactParameters) => { - if (parameters.mode === "automatic") { - return "/api/v1/security/auto-redact"; - } - // Manual redaction is handled by EmbedPDF in the viewer; no endpoint call. - return ""; - }, + endpoint: (parameters: RedactParameters) => + parameters.mode === "automatic" ? AUTO_ENDPOINT : null, + // Routing set: `mode` is frontend-only, so a stored step matches by this rather than by replay. + endpoints: [AUTO_ENDPOINT], defaultParameters, -} as const; +}); export const useRedactOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation.ts b/frontend/editor/src/core/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation.ts index 4f9d3fbc7d..de4f384b9e 100644 --- a/frontend/editor/src/core/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation.ts +++ b/frontend/editor/src/core/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation.ts @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; import { useToolOperation, - ToolType, + defineCustomTool, CustomProcessorResult, } from "@app/hooks/tools/shared/useToolOperation"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; @@ -78,12 +78,11 @@ const removeAnnotationsProcessor = async ( }; // Static configuration object -export const removeAnnotationsOperationConfig = { - toolType: ToolType.custom, +export const removeAnnotationsOperationConfig = defineCustomTool({ operationType: "removeAnnotations", customProcessor: removeAnnotationsProcessor, defaultParameters, -} as const; +}); export const useRemoveAnnotationsOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/removeBlanks/useRemoveBlanksOperation.ts b/frontend/editor/src/core/hooks/tools/removeBlanks/useRemoveBlanksOperation.ts index 5f24a28dd8..54e45d43fe 100644 --- a/frontend/editor/src/core/hooks/tools/removeBlanks/useRemoveBlanksOperation.ts +++ b/frontend/editor/src/core/hooks/tools/removeBlanks/useRemoveBlanksOperation.ts @@ -1,10 +1,14 @@ import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { - ToolType, useToolOperation, - ToolOperationConfig, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + objectToFormData, + type ToolApiParams, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import { RemoveBlanksParameters, @@ -12,25 +16,38 @@ import { } from "@app/hooks/tools/removeBlanks/useRemoveBlanksParameters"; import { useToolResources } from "@app/hooks/tools/shared/useToolResources"; +const ENDPOINT = "/api/v1/misc/remove-blanks" satisfies ToolEndpoint; +type RemoveBlanksApiParams = ToolApiParams[typeof ENDPOINT]; + +// Note: includeBlankPages is not sent to backend as it always returns both files in a ZIP +export const removeBlanksToApiParams = ( + parameters: RemoveBlanksParameters, +): RemoveBlanksApiParams => ({ + threshold: parameters.threshold, + whitePercent: parameters.whitePercent, +}); + +export const removeBlanksFromApiParams = ( + apiParams: RemoveBlanksApiParams, +): Partial => ({ + threshold: apiParams.threshold, + whitePercent: apiParams.whitePercent, +}); + export const buildRemoveBlanksFormData = ( parameters: RemoveBlanksParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - formData.append("threshold", String(parameters.threshold)); - formData.append("whitePercent", String(parameters.whitePercent)); - // Note: includeBlankPages is not sent to backend as it always returns both files in a ZIP - return formData; -}; +): FormData => + objectToFormData(removeBlanksToApiParams(parameters), { fileInput: file }); -export const removeBlanksOperationConfig = { - toolType: ToolType.singleFile, +export const removeBlanksOperationConfig = defineSingleFileTool({ buildFormData: buildRemoveBlanksFormData, + toApiParams: removeBlanksToApiParams, + fromApiParams: removeBlanksFromApiParams, operationType: "removeBlanks", - endpoint: "/api/v1/misc/remove-blanks", + endpoint: ENDPOINT, defaultParameters, -} as const satisfies ToolOperationConfig; +}); export const useRemoveBlanksOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation.ts b/frontend/editor/src/core/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation.ts index 1d6604f0ef..fd2b5200c3 100644 --- a/frontend/editor/src/core/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation.ts +++ b/frontend/editor/src/core/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation.ts @@ -1,32 +1,38 @@ import { useTranslation } from "react-i18next"; import { - ToolType, useToolOperation, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + fileOnlyMapping, + objectToFormData, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import { RemoveCertificateSignParameters, defaultParameters, } from "@app/hooks/tools/removeCertificateSign/useRemoveCertificateSignParameters"; -// Static function that can be used by both the hook and automation executor +const ENDPOINT = "/api/v1/security/remove-cert-sign" satisfies ToolEndpoint; + +// Removing certificate signatures takes only a file; no parameters to map. +const { toApiParams, fromApiParams } = fileOnlyMapping(); + export const buildRemoveCertificateSignFormData = ( _parameters: RemoveCertificateSignParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - return formData; -}; +): FormData => objectToFormData(toApiParams(), { fileInput: file }); // Static configuration object -export const removeCertificateSignOperationConfig = { - toolType: ToolType.singleFile, +export const removeCertificateSignOperationConfig = defineSingleFileTool({ buildFormData: buildRemoveCertificateSignFormData, + toApiParams, + fromApiParams, operationType: "removeCertSign", - endpoint: "/api/v1/security/remove-cert-sign", + endpoint: ENDPOINT, defaultParameters, -} as const; +}); export const useRemoveCertificateSignOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/removeImage/useRemoveImageOperation.ts b/frontend/editor/src/core/hooks/tools/removeImage/useRemoveImageOperation.ts index 3441623cf6..96de39321c 100644 --- a/frontend/editor/src/core/hooks/tools/removeImage/useRemoveImageOperation.ts +++ b/frontend/editor/src/core/hooks/tools/removeImage/useRemoveImageOperation.ts @@ -1,28 +1,33 @@ import { useTranslation } from "react-i18next"; import { useToolOperation, - ToolOperationConfig, - ToolType, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + fileOnlyMapping, + objectToFormData, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import type { RemoveImageParameters } from "@app/hooks/tools/removeImage/useRemoveImageParameters"; +const ENDPOINT = "/api/v1/general/remove-image-pdf" satisfies ToolEndpoint; + +// Remove-image takes only a file; there are no request parameters to map. +const { toApiParams, fromApiParams } = fileOnlyMapping(); + export const buildRemoveImageFormData = ( _params: RemoveImageParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - return formData; -}; +): FormData => objectToFormData(toApiParams(), { fileInput: file }); -export const removeImageOperationConfig: ToolOperationConfig = - { - toolType: ToolType.singleFile, - buildFormData: buildRemoveImageFormData, - operationType: "removeImage", - endpoint: "/api/v1/general/remove-image-pdf", - }; +export const removeImageOperationConfig = defineSingleFileTool({ + buildFormData: buildRemoveImageFormData, + toApiParams, + fromApiParams, + operationType: "removeImage", + endpoint: ENDPOINT, +}); export const useRemoveImageOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/removePages/useRemovePagesOperation.ts b/frontend/editor/src/core/hooks/tools/removePages/useRemovePagesOperation.ts index 9ff5e6fb62..c8a8aca2d0 100644 --- a/frontend/editor/src/core/hooks/tools/removePages/useRemovePagesOperation.ts +++ b/frontend/editor/src/core/hooks/tools/removePages/useRemovePagesOperation.ts @@ -1,9 +1,13 @@ import { useTranslation } from "react-i18next"; import { - ToolType, useToolOperation, - ToolOperationConfig, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + objectToFormData, + type ToolApiParams, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import { RemovePagesParameters, @@ -11,24 +15,40 @@ import { } from "@app/hooks/tools/removePages/useRemovePagesParameters"; // import { useToolResources } from '@app/hooks/tools/shared/useToolResources'; +const ENDPOINT = "/api/v1/general/remove-pages" satisfies ToolEndpoint; +type RemovePagesApiParams = ToolApiParams[typeof ENDPOINT]; + +// Convert the tool's UI parameters into the remove-pages request body. The +// return type is the generated backend model, so a spec change that renames or +// drops a field breaks the build here. +export const removePagesToApiParams = ( + parameters: RemovePagesParameters, +): RemovePagesApiParams => ({ + pageNumbers: parameters.pageNumbers.replace(/\s+/g, ""), +}); + +// Reconstruct the tool's UI parameters from a remove-pages request body, so a +// stored or AI-authored step can be re-rendered in the settings UI. +export const removePagesFromApiParams = ( + apiParams: RemovePagesApiParams, +): Partial => ({ + pageNumbers: apiParams.pageNumbers ?? defaultParameters.pageNumbers, +}); + export const buildRemovePagesFormData = ( parameters: RemovePagesParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - const cleaned = parameters.pageNumbers.replace(/\s+/g, ""); - formData.append("pageNumbers", cleaned); - return formData; -}; +): FormData => + objectToFormData(removePagesToApiParams(parameters), { fileInput: file }); -export const removePagesOperationConfig = { - toolType: ToolType.singleFile, +export const removePagesOperationConfig = defineSingleFileTool({ buildFormData: buildRemovePagesFormData, + toApiParams: removePagesToApiParams, + fromApiParams: removePagesFromApiParams, operationType: "removePages", - endpoint: "/api/v1/general/remove-pages", + endpoint: ENDPOINT, defaultParameters, -} as const satisfies ToolOperationConfig; +}); export const useRemovePagesOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/removePassword/buildRemovePasswordFormData.ts b/frontend/editor/src/core/hooks/tools/removePassword/buildRemovePasswordFormData.ts index 8f04c4806f..c55040c7f2 100644 --- a/frontend/editor/src/core/hooks/tools/removePassword/buildRemovePasswordFormData.ts +++ b/frontend/editor/src/core/hooks/tools/removePassword/buildRemovePasswordFormData.ts @@ -1,4 +1,35 @@ -import { RemovePasswordParameters } from "@app/hooks/tools/removePassword/useRemovePasswordParameters"; +import { + RemovePasswordParameters, + defaultParameters, +} from "@app/hooks/tools/removePassword/useRemovePasswordParameters"; +import { + objectToFormData, + type ToolApiParams, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; + +// Defined here (not in the operation config) so both the mappers and the config +// share one endpoint constant without a circular import via FileContext. +export const REMOVE_PASSWORD_ENDPOINT = + "/api/v1/security/remove-password" satisfies ToolEndpoint; +type RemovePasswordApiParams = ToolApiParams[typeof REMOVE_PASSWORD_ENDPOINT]; + +// Convert the tool's UI parameters into the remove-password request body. The +// return type is the generated backend model, so a spec change that renames or +// drops a field breaks the build here. +export const removePasswordToApiParams = ( + parameters: RemovePasswordParameters, +): RemovePasswordApiParams => ({ + password: parameters.password, +}); + +// Reconstruct the tool's UI parameters from a remove-password request body, so a +// stored or AI-authored step can be re-rendered in the settings UI. +export const removePasswordFromApiParams = ( + apiParams: RemovePasswordApiParams, +): Partial => ({ + password: apiParams.password ?? defaultParameters.password, +}); /** * Builds FormData for remove password API request. @@ -7,9 +38,5 @@ import { RemovePasswordParameters } from "@app/hooks/tools/removePassword/useRem export const buildRemovePasswordFormData = ( parameters: RemovePasswordParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - formData.append("password", parameters.password); - return formData; -}; +): FormData => + objectToFormData(removePasswordToApiParams(parameters), { fileInput: file }); diff --git a/frontend/editor/src/core/hooks/tools/removePassword/useRemovePasswordOperation.ts b/frontend/editor/src/core/hooks/tools/removePassword/useRemovePasswordOperation.ts index aa71a5befb..644bbe3b1a 100644 --- a/frontend/editor/src/core/hooks/tools/removePassword/useRemovePasswordOperation.ts +++ b/frontend/editor/src/core/hooks/tools/removePassword/useRemovePasswordOperation.ts @@ -1,26 +1,32 @@ import { useTranslation } from "react-i18next"; import { - ToolType, useToolOperation, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import { RemovePasswordParameters, defaultParameters, } from "@app/hooks/tools/removePassword/useRemovePasswordParameters"; -import { buildRemovePasswordFormData } from "@app/hooks/tools/removePassword/buildRemovePasswordFormData"; +import { + buildRemovePasswordFormData, + removePasswordToApiParams, + removePasswordFromApiParams, + REMOVE_PASSWORD_ENDPOINT, +} from "@app/hooks/tools/removePassword/buildRemovePasswordFormData"; // Re-export for backwards compatibility with any other imports export { buildRemovePasswordFormData }; // Static configuration object -export const removePasswordOperationConfig = { - toolType: ToolType.singleFile, +export const removePasswordOperationConfig = defineSingleFileTool({ buildFormData: buildRemovePasswordFormData, + toApiParams: removePasswordToApiParams, + fromApiParams: removePasswordFromApiParams, operationType: "removePassword", - endpoint: "/api/v1/security/remove-password", + endpoint: REMOVE_PASSWORD_ENDPOINT, defaultParameters, -} as const; +}); export const useRemovePasswordOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/reorganizePages/useReorganizePagesOperation.ts b/frontend/editor/src/core/hooks/tools/reorganizePages/useReorganizePagesOperation.ts index de5aaf5c53..457132887b 100644 --- a/frontend/editor/src/core/hooks/tools/reorganizePages/useReorganizePagesOperation.ts +++ b/frontend/editor/src/core/hooks/tools/reorganizePages/useReorganizePagesOperation.ts @@ -1,35 +1,65 @@ import { useTranslation } from "react-i18next"; import { - ToolOperationConfig, - ToolType, + defineSingleFileTool, useToolOperation, } from "@app/hooks/tools/shared/useToolOperation"; +import { + objectToFormData, + type ToolApiParams, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; -import { ReorganizePagesParameters } from "@app/hooks/tools/reorganizePages/useReorganizePagesParameters"; +import { + ReorganizePagesParameters, + defaultReorganizePagesParameters, +} from "@app/hooks/tools/reorganizePages/useReorganizePagesParameters"; + +const ENDPOINT = "/api/v1/general/rearrange-pages" satisfies ToolEndpoint; +type ReorganizePagesApiParams = ToolApiParams[typeof ENDPOINT]; + +// Convert the tool's UI parameters into the rearrange-pages request body. The +// return type is the generated backend model, so a spec change that renames or +// drops a field breaks the build here. +export const reorganizePagesToApiParams = ( + parameters: ReorganizePagesParameters, +): ReorganizePagesApiParams => { + const apiParams: ReorganizePagesApiParams = {}; + if (parameters.customMode) { + apiParams.customMode = + parameters.customMode as ReorganizePagesApiParams["customMode"]; + } + if (parameters.pageNumbers) { + apiParams.pageNumbers = parameters.pageNumbers.replace(/\s+/g, ""); + } + return apiParams; +}; + +// Reconstruct the tool's UI parameters from a rearrange-pages request body, so a +// stored or AI-authored step can be re-rendered in the settings UI. +export const reorganizePagesFromApiParams = ( + apiParams: ReorganizePagesApiParams, +): Partial => ({ + customMode: + apiParams.customMode ?? defaultReorganizePagesParameters.customMode, + pageNumbers: + apiParams.pageNumbers ?? defaultReorganizePagesParameters.pageNumbers, +}); const buildFormData = ( parameters: ReorganizePagesParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - if (parameters.customMode) { - formData.append("customMode", parameters.customMode); - } - if (parameters.pageNumbers) { - const cleaned = parameters.pageNumbers.replace(/\s+/g, ""); - formData.append("pageNumbers", cleaned); - } - return formData; -}; +): FormData => + objectToFormData(reorganizePagesToApiParams(parameters), { + fileInput: file, + }); -export const reorganizePagesOperationConfig: ToolOperationConfig = - { - toolType: ToolType.singleFile, - buildFormData, - operationType: "reorganizePages", - endpoint: "/api/v1/general/rearrange-pages", - }; +export const reorganizePagesOperationConfig = defineSingleFileTool({ + buildFormData, + toApiParams: reorganizePagesToApiParams, + fromApiParams: reorganizePagesFromApiParams, + operationType: "reorganizePages", + endpoint: ENDPOINT, +}); export const useReorganizePagesOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/repair/useRepairOperation.ts b/frontend/editor/src/core/hooks/tools/repair/useRepairOperation.ts index a62f448bac..af05cf59b9 100644 --- a/frontend/editor/src/core/hooks/tools/repair/useRepairOperation.ts +++ b/frontend/editor/src/core/hooks/tools/repair/useRepairOperation.ts @@ -1,32 +1,38 @@ import { useTranslation } from "react-i18next"; import { - ToolType, useToolOperation, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + fileOnlyMapping, + objectToFormData, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import { RepairParameters, defaultParameters, } from "@app/hooks/tools/repair/useRepairParameters"; -// Static function that can be used by both the hook and automation executor +const ENDPOINT = "/api/v1/misc/repair" satisfies ToolEndpoint; + +// Repair takes only a file; there are no request parameters to map. +const { toApiParams, fromApiParams } = fileOnlyMapping(); + export const buildRepairFormData = ( _parameters: RepairParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - return formData; -}; +): FormData => objectToFormData(toApiParams(), { fileInput: file }); // Static configuration object -export const repairOperationConfig = { - toolType: ToolType.singleFile, +export const repairOperationConfig = defineSingleFileTool({ buildFormData: buildRepairFormData, + toApiParams, + fromApiParams, operationType: "repair", - endpoint: "/api/v1/misc/repair", + endpoint: ENDPOINT, defaultParameters, -} as const; +}); export const useRepairOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/replaceColor/useReplaceColorOperation.ts b/frontend/editor/src/core/hooks/tools/replaceColor/useReplaceColorOperation.ts index dd24c10e22..33223afa43 100644 --- a/frontend/editor/src/core/hooks/tools/replaceColor/useReplaceColorOperation.ts +++ b/frontend/editor/src/core/hooks/tools/replaceColor/useReplaceColorOperation.ts @@ -1,44 +1,75 @@ import { useTranslation } from "react-i18next"; import { - ToolType, useToolOperation, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + objectToFormData, + type ToolApiParams, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import { ReplaceColorParameters, defaultParameters, } from "@app/hooks/tools/replaceColor/useReplaceColorParameters"; +const ENDPOINT = "/api/v1/misc/replace-invert-pdf" satisfies ToolEndpoint; +type ReplaceColorApiParams = ToolApiParams[typeof ENDPOINT]; + +export const replaceColorToApiParams = ( + parameters: ReplaceColorParameters, +): ReplaceColorApiParams => { + const apiParams: ReplaceColorApiParams = { + replaceAndInvertOption: parameters.replaceAndInvertOption, + }; + + if (parameters.replaceAndInvertOption === "HIGH_CONTRAST_COLOR") { + apiParams.highContrastColorCombination = + parameters.highContrastColorCombination; + } else if (parameters.replaceAndInvertOption === "CUSTOM_COLOR") { + apiParams.textColor = parameters.textColor; + apiParams.backGroundColor = parameters.backGroundColor; + } + + return apiParams; +}; + +export const replaceColorFromApiParams = ( + apiParams: ReplaceColorApiParams, +): Partial => { + const result: Partial = { + replaceAndInvertOption: apiParams.replaceAndInvertOption, + }; + + if (apiParams.highContrastColorCombination !== undefined) { + result.highContrastColorCombination = + apiParams.highContrastColorCombination; + } + if (apiParams.textColor !== undefined) { + result.textColor = apiParams.textColor; + } + if (apiParams.backGroundColor !== undefined) { + result.backGroundColor = apiParams.backGroundColor; + } + + return result; +}; + export const buildReplaceColorFormData = ( parameters: ReplaceColorParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); +): FormData => + objectToFormData(replaceColorToApiParams(parameters), { fileInput: file }); - formData.append("replaceAndInvertOption", parameters.replaceAndInvertOption); - - if (parameters.replaceAndInvertOption === "HIGH_CONTRAST_COLOR") { - formData.append( - "highContrastColorCombination", - parameters.highContrastColorCombination, - ); - } else if (parameters.replaceAndInvertOption === "CUSTOM_COLOR") { - formData.append("textColor", parameters.textColor); - formData.append("backGroundColor", parameters.backGroundColor); - } - - return formData; -}; - -export const replaceColorOperationConfig = { - toolType: ToolType.singleFile, +export const replaceColorOperationConfig = defineSingleFileTool({ buildFormData: buildReplaceColorFormData, + toApiParams: replaceColorToApiParams, + fromApiParams: replaceColorFromApiParams, operationType: "replaceColor", - endpoint: "/api/v1/misc/replace-invert-pdf", - multiFileEndpoint: false, + endpoint: ENDPOINT, defaultParameters, -} as const; +}); export const useReplaceColorOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/rotate/useRotateOperation.test.ts b/frontend/editor/src/core/hooks/tools/rotate/useRotateOperation.test.ts index 5db2b30788..96973db125 100644 --- a/frontend/editor/src/core/hooks/tools/rotate/useRotateOperation.test.ts +++ b/frontend/editor/src/core/hooks/tools/rotate/useRotateOperation.test.ts @@ -30,6 +30,10 @@ import { ToolType, useToolOperation, } from "@app/hooks/tools/shared/useToolOperation"; +import { + rotateFromApiParams, + rotateToApiParams, +} from "@app/hooks/tools/rotate/useRotateOperation"; describe("useRotateOperation", () => { const mockUseToolOperation = vi.mocked(useToolOperation); @@ -114,3 +118,30 @@ describe("useRotateOperation", () => { expect(callArgs[property]).toBe(expectedValue); }); }); + +describe("rotate mappers", () => { + test.each([ + { angle: 0, expected: 0 }, + { angle: 90, expected: 90 }, + { angle: -90, expected: 270 }, + { angle: 450, expected: 90 }, + ])( + "toApiParams normalizes angle $angle to $expected", + ({ angle, expected }) => { + expect(rotateToApiParams({ angle }).angle).toBe(expected); + }, + ); + + test("fromApiParams maps the backend angle back to the UI parameter", () => { + expect(rotateFromApiParams({ angle: 180 })).toEqual({ angle: 180 }); + }); + + test.each([0, 90, 180, 270] as const)( + "round-trips a normalized angle %i", + (angle) => { + const ui = rotateFromApiParams({ angle }); + const api = rotateToApiParams({ angle: ui.angle ?? 0 }); + expect(api).toEqual({ angle }); + }, + ); +}); diff --git a/frontend/editor/src/core/hooks/tools/rotate/useRotateOperation.ts b/frontend/editor/src/core/hooks/tools/rotate/useRotateOperation.ts index e675610eb9..f8c6d32216 100644 --- a/frontend/editor/src/core/hooks/tools/rotate/useRotateOperation.ts +++ b/frontend/editor/src/core/hooks/tools/rotate/useRotateOperation.ts @@ -1,8 +1,13 @@ import { useTranslation } from "react-i18next"; import { useToolOperation, - ToolType, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + objectToFormData, + type ToolApiParams, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import { RotateParameters, @@ -10,26 +15,42 @@ import { normalizeAngle, } from "@app/hooks/tools/rotate/useRotateParameters"; +const ENDPOINT = "/api/v1/general/rotate-pdf" satisfies ToolEndpoint; +type RotateApiParams = ToolApiParams[typeof ENDPOINT]; + +// Convert the tool's UI parameters into the rotate-pdf request body. The return +// type is the generated backend model, so a spec change breaks the build here. +export const rotateToApiParams = ( + parameters: RotateParameters, +): RotateApiParams => ({ + // The UI angle can be any multiple of 90 (including negatives or values above + // 360); normalize to the four values the backend accepts. + angle: normalizeAngle(parameters.angle) as RotateApiParams["angle"], +}); + +// Reconstruct the tool's UI parameters from a rotate-pdf request body. +export const rotateFromApiParams = ( + apiParams: RotateApiParams, +): Partial => ({ + angle: apiParams.angle, +}); + // Static configuration that can be used by both the hook and automation executor export const buildRotateFormData = ( parameters: RotateParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - // Normalize angle for backend (0, 90, 180, 270) - formData.append("angle", normalizeAngle(parameters.angle).toString()); - return formData; -}; +): FormData => + objectToFormData(rotateToApiParams(parameters), { fileInput: file }); // Static configuration object -export const rotateOperationConfig = { - toolType: ToolType.singleFile, +export const rotateOperationConfig = defineSingleFileTool({ buildFormData: buildRotateFormData, + toApiParams: rotateToApiParams, + fromApiParams: rotateFromApiParams, operationType: "rotate", - endpoint: "/api/v1/general/rotate-pdf", + endpoint: ENDPOINT, defaultParameters, -} as const; +}); export const useRotateOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/sanitize/useSanitizeOperation.ts b/frontend/editor/src/core/hooks/tools/sanitize/useSanitizeOperation.ts index 9c98b7d57f..26a5ca7250 100644 --- a/frontend/editor/src/core/hooks/tools/sanitize/useSanitizeOperation.ts +++ b/frontend/editor/src/core/hooks/tools/sanitize/useSanitizeOperation.ts @@ -1,54 +1,68 @@ import { useTranslation } from "react-i18next"; import { - ToolType, useToolOperation, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + objectToFormData, + type ToolApiParams, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import { SanitizeParameters, defaultParameters, } from "@app/hooks/tools/sanitize/useSanitizeParameters"; +const ENDPOINT = "/api/v1/security/sanitize-pdf" satisfies ToolEndpoint; +type SanitizeApiParams = ToolApiParams[typeof ENDPOINT]; + +// Convert the tool's UI parameters into the sanitize-pdf request body. The +// return type is the generated backend model, so a spec change that renames or +// drops a field breaks the build here. +export const sanitizeToApiParams = ( + parameters: SanitizeParameters, +): SanitizeApiParams => ({ + removeJavaScript: parameters.removeJavaScript ?? false, + removeEmbeddedFiles: parameters.removeEmbeddedFiles ?? false, + removeXMPMetadata: parameters.removeXMPMetadata ?? false, + removeMetadata: parameters.removeMetadata ?? false, + removeLinks: parameters.removeLinks ?? false, + removeFonts: parameters.removeFonts ?? false, +}); + +// Reconstruct the tool's UI parameters from a sanitize-pdf request body, so a +// stored or AI-authored step can be re-rendered in the settings UI. +export const sanitizeFromApiParams = ( + apiParams: SanitizeApiParams, +): Partial => ({ + removeJavaScript: + apiParams.removeJavaScript ?? defaultParameters.removeJavaScript, + removeEmbeddedFiles: + apiParams.removeEmbeddedFiles ?? defaultParameters.removeEmbeddedFiles, + removeXMPMetadata: + apiParams.removeXMPMetadata ?? defaultParameters.removeXMPMetadata, + removeMetadata: apiParams.removeMetadata ?? defaultParameters.removeMetadata, + removeLinks: apiParams.removeLinks ?? defaultParameters.removeLinks, + removeFonts: apiParams.removeFonts ?? defaultParameters.removeFonts, +}); + // Static function that can be used by both the hook and automation executor export const buildSanitizeFormData = ( parameters: SanitizeParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - - // Add parameters - formData.append( - "removeJavaScript", - (parameters.removeJavaScript ?? false).toString(), - ); - formData.append( - "removeEmbeddedFiles", - (parameters.removeEmbeddedFiles ?? false).toString(), - ); - formData.append( - "removeXMPMetadata", - (parameters.removeXMPMetadata ?? false).toString(), - ); - formData.append( - "removeMetadata", - (parameters.removeMetadata ?? false).toString(), - ); - formData.append("removeLinks", (parameters.removeLinks ?? false).toString()); - formData.append("removeFonts", (parameters.removeFonts ?? false).toString()); - - return formData; -}; +): FormData => + objectToFormData(sanitizeToApiParams(parameters), { fileInput: file }); // Static configuration object -export const sanitizeOperationConfig = { - toolType: ToolType.singleFile, +export const sanitizeOperationConfig = defineSingleFileTool({ buildFormData: buildSanitizeFormData, + toApiParams: sanitizeToApiParams, + fromApiParams: sanitizeFromApiParams, operationType: "sanitize", - endpoint: "/api/v1/security/sanitize-pdf", - multiFileEndpoint: false, + endpoint: ENDPOINT, defaultParameters, -} as const; +}); export const useSanitizeOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/scannerImageSplit/useScannerImageSplitOperation.ts b/frontend/editor/src/core/hooks/tools/scannerImageSplit/useScannerImageSplitOperation.ts index e035c90ce5..26db1414d0 100644 --- a/frontend/editor/src/core/hooks/tools/scannerImageSplit/useScannerImageSplitOperation.ts +++ b/frontend/editor/src/core/hooks/tools/scannerImageSplit/useScannerImageSplitOperation.ts @@ -1,10 +1,15 @@ import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { - ToolType, useToolOperation, ToolOperationConfig, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + objectToFormData, + type ToolApiParams, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import { ScannerImageSplitParameters, @@ -12,28 +17,51 @@ import { } from "@app/hooks/tools/scannerImageSplit/useScannerImageSplitParameters"; import { useToolResources } from "@app/hooks/tools/shared/useToolResources"; +const ENDPOINT = "/api/v1/misc/extract-image-scans" satisfies ToolEndpoint; +type ScannerImageSplitApiParams = ToolApiParams[typeof ENDPOINT]; + +// Convert the tool's UI parameters into the extract-image-scans request body. +// The frontend uses snake_case field names, but the backend model (the contract) +// uses camelCase, so the keys are renamed here. +export const scannerImageSplitToApiParams = ( + parameters: ScannerImageSplitParameters, +): ScannerImageSplitApiParams => ({ + angleThreshold: parameters.angle_threshold, + tolerance: parameters.tolerance, + minArea: parameters.min_area, + minContourArea: parameters.min_contour_area, + borderSize: parameters.border_size, +}); + +// Reconstruct the tool's UI parameters from an extract-image-scans request body, +// so a stored or AI-authored step can be re-rendered in the settings UI. +export const scannerImageSplitFromApiParams = ( + apiParams: ScannerImageSplitApiParams, +): Partial => ({ + angle_threshold: apiParams.angleThreshold, + tolerance: apiParams.tolerance, + min_area: apiParams.minArea, + min_contour_area: apiParams.minContourArea, + border_size: apiParams.borderSize, +}); + export const buildScannerImageSplitFormData = ( parameters: ScannerImageSplitParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - formData.append("angle_threshold", parameters.angle_threshold.toString()); - formData.append("tolerance", parameters.tolerance.toString()); - formData.append("min_area", parameters.min_area.toString()); - formData.append("min_contour_area", parameters.min_contour_area.toString()); - formData.append("border_size", parameters.border_size.toString()); - return formData; -}; +): FormData => + objectToFormData(scannerImageSplitToApiParams(parameters), { + fileInput: file, + }); // Static configuration object -export const scannerImageSplitOperationConfig = { - toolType: ToolType.singleFile, +export const scannerImageSplitOperationConfig = defineSingleFileTool({ buildFormData: buildScannerImageSplitFormData, + toApiParams: scannerImageSplitToApiParams, + fromApiParams: scannerImageSplitFromApiParams, operationType: "scannerImageSplit", - endpoint: "/api/v1/misc/extract-image-scans", + endpoint: ENDPOINT, defaultParameters, -} as const; +}); export const useScannerImageSplitOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/shared/migratedToolMappers.test.ts b/frontend/editor/src/core/hooks/tools/shared/migratedToolMappers.test.ts new file mode 100644 index 0000000000..d562384921 --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/shared/migratedToolMappers.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test } from "vitest"; +import { + ToolType, + type RegistryToolOperationConfig, +} from "@app/hooks/tools/shared/toolOperationTypes"; +import { objectToFormData } from "@app/hooks/tools/shared/toolApiMapping"; + +// Pilot tools. +import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation"; +import { rotateOperationConfig } from "@app/hooks/tools/rotate/useRotateOperation"; +import { mergeOperationConfig } from "@app/hooks/tools/merge/useMergeOperation"; +import { splitOperationConfig } from "@app/hooks/tools/split/useSplitOperation"; +// Rolled out in Phase 3. +import { addAttachmentsOperationConfig } from "@app/hooks/tools/addAttachments/useAddAttachmentsOperation"; +import { addPageNumbersOperationConfig } from "@app/components/tools/addPageNumbers/useAddPageNumbersOperation"; +import { addPasswordOperationConfig } from "@app/hooks/tools/addPassword/useAddPasswordOperation"; +import { addStampOperationConfig } from "@app/components/tools/addStamp/useAddStampOperation"; +import { addWatermarkOperationConfig } from "@app/hooks/tools/addWatermark/useAddWatermarkOperation"; +import { adjustPageScaleOperationConfig } from "@app/hooks/tools/adjustPageScale/useAdjustPageScaleOperation"; +import { autoRenameOperationConfig } from "@app/hooks/tools/autoRename/useAutoRenameOperation"; +import { bookletImpositionOperationConfig } from "@app/hooks/tools/bookletImposition/useBookletImpositionOperation"; +import { certSignOperationConfig } from "@app/hooks/tools/certSign/useCertSignOperation"; +import { changeMetadataOperationConfig } from "@app/hooks/tools/changeMetadata/useChangeMetadataOperation"; +import { changePermissionsOperationConfig } from "@app/hooks/tools/changePermissions/useChangePermissionsOperation"; +import { cropOperationConfig } from "@app/hooks/tools/crop/useCropOperation"; +import { editTableOfContentsOperationConfig } from "@app/hooks/tools/editTableOfContents/useEditTableOfContentsOperation"; +import { extractImagesOperationConfig } from "@app/hooks/tools/extractImages/useExtractImagesOperation"; +import { flattenOperationConfig } from "@app/hooks/tools/flatten/useFlattenOperation"; +import { ocrOperationConfig } from "@app/hooks/tools/ocr/useOCROperation"; +import { overlayPdfsOperationConfig } from "@app/hooks/tools/overlayPdfs/useOverlayPdfsOperation"; +import { pageLayoutOperationConfig } from "@app/hooks/tools/pageLayout/usePageLayoutOperation"; +import { redactOperationConfig } from "@app/hooks/tools/redact/useRedactOperation"; +import { removeBlanksOperationConfig } from "@app/hooks/tools/removeBlanks/useRemoveBlanksOperation"; +import { removeCertificateSignOperationConfig } from "@app/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation"; +import { removeImageOperationConfig } from "@app/hooks/tools/removeImage/useRemoveImageOperation"; +import { removePagesOperationConfig } from "@app/hooks/tools/removePages/useRemovePagesOperation"; +import { removePasswordOperationConfig } from "@app/hooks/tools/removePassword/useRemovePasswordOperation"; +import { reorganizePagesOperationConfig } from "@app/hooks/tools/reorganizePages/useReorganizePagesOperation"; +import { repairOperationConfig } from "@app/hooks/tools/repair/useRepairOperation"; +import { replaceColorOperationConfig } from "@app/hooks/tools/replaceColor/useReplaceColorOperation"; +import { sanitizeOperationConfig } from "@app/hooks/tools/sanitize/useSanitizeOperation"; +import { scannerImageSplitOperationConfig } from "@app/hooks/tools/scannerImageSplit/useScannerImageSplitOperation"; +import { singleLargePageOperationConfig } from "@app/hooks/tools/singleLargePage/useSingleLargePageOperation"; +import { timestampPdfOperationConfig } from "@app/hooks/tools/timestampPdf/useTimestampPdfOperation"; +import { unlockPdfFormsOperationConfig } from "@app/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation"; + +// Every tool migrated to the mapper seam. Erased to the registry shape so one +// loop can invoke toApiParams(defaultParameters) uniformly regardless of the +// tool's own parameter type. +const MIGRATED_CONFIGS = [ + compressOperationConfig, + rotateOperationConfig, + mergeOperationConfig, + splitOperationConfig, + addAttachmentsOperationConfig, + addPageNumbersOperationConfig, + addPasswordOperationConfig, + addStampOperationConfig, + addWatermarkOperationConfig, + adjustPageScaleOperationConfig, + autoRenameOperationConfig, + bookletImpositionOperationConfig, + certSignOperationConfig, + changeMetadataOperationConfig, + changePermissionsOperationConfig, + cropOperationConfig, + editTableOfContentsOperationConfig, + extractImagesOperationConfig, + flattenOperationConfig, + ocrOperationConfig, + overlayPdfsOperationConfig, + pageLayoutOperationConfig, + redactOperationConfig, + removeBlanksOperationConfig, + removeCertificateSignOperationConfig, + removeImageOperationConfig, + removePagesOperationConfig, + removePasswordOperationConfig, + reorganizePagesOperationConfig, + repairOperationConfig, + replaceColorOperationConfig, + sanitizeOperationConfig, + scannerImageSplitOperationConfig, + singleLargePageOperationConfig, + timestampPdfOperationConfig, + unlockPdfFormsOperationConfig, + // Erase each tool's own TParams to the shared registry shape (the same + // existential boundary asRegistryConfig applies) so one loop can call + // toApiParams(defaultParameters) uniformly. +] as unknown as RegistryToolOperationConfig[]; + +// A few tools have no static defaultParameters (the UI always supplies a value); +// give the sweep a minimal valid parameter set for those. +const FALLBACK_PARAMS: Record> = { + editTableOfContents: { bookmarks: [], replaceExisting: false }, +}; + +describe("migrated tool mappers (sweep)", () => { + const file = new File(["x"], "test.pdf", { type: "application/pdf" }); + + test.each( + MIGRATED_CONFIGS.map((config) => [config.operationType, config] as const), + )( + "%s: exposes both mappers and serializes its default parameters cleanly", + (_name, config) => { + // Every migrated tool authors both directions of the mapping. + expect(config.toApiParams).toBeDefined(); + expect(config.fromApiParams).toBeDefined(); + + // Serialize the defaults through the tool's own buildFormData - the real + // path the executor uses - so a tool whose toApiParams carries a structured + // field that buildFormData flattens itself (e.g. changeMetadata's + // allRequestParams map) is exercised too, not just tools whose mapper + // output is directly objectToFormData-able. Custom tools have no + // buildFormData, so fall back to serializing the mapper output directly. + const params = + config.defaultParameters ?? FALLBACK_PARAMS[config.operationType] ?? {}; + if (config.toolType === ToolType.multiFile) { + const build = config.buildFormData; + expect(() => build(params, [file])).not.toThrow(); + } else if (config.toolType === ToolType.singleFile) { + const build = config.buildFormData; + expect(() => build(params, file)).not.toThrow(); + } else { + const toApiParams = config.toApiParams!; + expect(() => + objectToFormData(toApiParams(params), { fileInput: file }), + ).not.toThrow(); + } + }, + ); +}); + +describe("redact mappers", () => { + test("toApiParams builds the auto-redact body from UI parameters", () => { + const api = redactOperationConfig.toApiParams!({ + mode: "automatic", + wordsToRedact: ["foo", "bar"], + useRegex: true, + wholeWordSearch: false, + redactColor: "#ff0000", + customPadding: 0.2, + convertPDFToImage: false, + }); + + expect(api).toEqual({ + listOfText: "foo\nbar", + useRegex: true, + wholeWordSearch: false, + redactColor: "ff0000", // '#' stripped for the backend + customPadding: 0.2, + convertPDFToImage: false, + }); + }); + + test("round-trips through fromApiParams", () => { + const api = redactOperationConfig.toApiParams!({ + mode: "automatic", + wordsToRedact: ["secret"], + useRegex: false, + wholeWordSearch: true, + redactColor: "#123456", + customPadding: 0.1, + convertPDFToImage: true, + }); + const roundTripped = redactOperationConfig.toApiParams!({ + mode: "automatic", + wordsToRedact: [], + useRegex: false, + wholeWordSearch: false, + redactColor: "#000000", + customPadding: 0, + convertPDFToImage: false, + ...redactOperationConfig.fromApiParams!(api), + }); + + expect(roundTripped).toEqual(api); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.test.ts b/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.test.ts new file mode 100644 index 0000000000..ba8c0bc2be --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "vitest"; +import { + isToolEndpoint, + objectToFormData, + type ToolApiParams, +} from "@app/hooks/tools/shared/toolApiMapping"; + +describe("isToolEndpoint", () => { + test("accepts a generated endpoint and rejects an unknown path", () => { + expect(isToolEndpoint("/api/v1/misc/compress-pdf")).toBe(true); + expect(isToolEndpoint("/api/v1/misc/not-a-real-tool")).toBe(false); + expect(isToolEndpoint("")).toBe(false); + }); +}); + +describe("objectToFormData", () => { + test("serializes primitive fields to string form values", () => { + const request: ToolApiParams["/api/v1/misc/compress-pdf"] = { + optimizeLevel: 3, + grayscale: true, + linearize: false, + expectedOutputSize: "25KB", + }; + const formData = objectToFormData(request); + + expect(formData.get("optimizeLevel")).toBe("3"); + expect(formData.get("grayscale")).toBe("true"); + expect(formData.get("linearize")).toBe("false"); + expect(formData.get("expectedOutputSize")).toBe("25KB"); + }); + + test("omits fields whose value is undefined", () => { + const request: ToolApiParams["/api/v1/misc/compress-pdf"] = { + optimizeLevel: 5, + expectedOutputSize: undefined, + }; + const formData = objectToFormData(request); + + expect(formData.has("optimizeLevel")).toBe(true); + expect(formData.has("expectedOutputSize")).toBe(false); + }); + + test("expands arrays into repeated fields", () => { + const request: ToolApiParams["/api/v1/misc/add-attachments"] = { + attachments: ["a.png", "b.png", "c.png"], + }; + const formData = objectToFormData(request); + + expect(formData.getAll("attachments")).toEqual(["a.png", "b.png", "c.png"]); + }); + + test("throws on a non-primitive field value rather than dropping it", () => { + // A redact request whose structured field was left un-encoded: the array + // items are objects, which cannot be sent as form fields. + const request: ToolApiParams["/api/v1/security/redact"] = { + redactions: [{ x: 1, y: 2 }], + }; + + expect(() => objectToFormData(request)).toThrow(/field "redactions"/); + }); + + test("appends a single file under its field name", () => { + const file = new File(["x"], "doc.pdf", { type: "application/pdf" }); + const request: ToolApiParams["/api/v1/misc/compress-pdf"] = { + optimizeLevel: 5, + }; + const formData = objectToFormData(request, { fileInput: file }); + + expect(formData.get("fileInput")).toBe(file); + expect(formData.get("optimizeLevel")).toBe("5"); + }); + + test("appends multiple files under the same field name", () => { + const files = [ + new File(["1"], "a.pdf", { type: "application/pdf" }), + new File(["2"], "b.pdf", { type: "application/pdf" }), + ]; + const formData = objectToFormData({}, { fileInput: files }); + + expect(formData.getAll("fileInput")).toEqual(files); + }); + + test("appends named file fields alongside fileInput", () => { + const doc = new File(["d"], "doc.pdf", { type: "application/pdf" }); + const stamp = new File(["s"], "stamp.png", { type: "image/png" }); + const formData = objectToFormData( + {}, + { fileInput: doc, stampImage: stamp }, + ); + + expect(formData.get("fileInput")).toBe(doc); + expect(formData.get("stampImage")).toBe(stamp); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.ts b/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.ts new file mode 100644 index 0000000000..9c6f5d9e4b --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.ts @@ -0,0 +1,99 @@ +import { + TOOL_ENDPOINTS, + type ToolApiParams, + type ToolApiRequest, + type ToolEndpoint, +} from "@app/types/toolApiTypes"; + +export type { ToolApiParams, ToolApiRequest, ToolEndpoint }; + +const TOOL_ENDPOINT_SET: ReadonlySet = new Set(TOOL_ENDPOINTS); + +/** + * Runtime check that a string is one of the generated tool endpoints, so callers can narrow an + * arbitrary endpoint path to {@link ToolEndpoint} against the real supported set rather than casting. + */ +export function isToolEndpoint(value: string): value is ToolEndpoint { + return TOOL_ENDPOINT_SET.has(value); +} + +/** + * Mapping for tools that take only a file and have no request parameters (their + * generated model is `Record`). Both directions are empty; the + * tool's buildFormData just appends the file. + */ +export function fileOnlyMapping(): { + toApiParams: () => Record; + fromApiParams: () => Record; +} { + return { toApiParams: () => ({}), fromApiParams: () => ({}) }; +} + +/** Named file fields to append alongside the serialized parameters. */ +export interface FormDataFiles { + /** Primary document input(s); appended under the `fileInput` field. */ + fileInput?: File | File[]; + /** Any other named file field the endpoint accepts. */ + [field: string]: File | File[] | undefined; +} + +function appendPrimitive( + formData: FormData, + key: string, + value: unknown, +): void { + if (value === undefined || value === null) return; + if (typeof value === "string") { + formData.append(key, value); + } else if (typeof value === "number" || typeof value === "boolean") { + formData.append(key, `${value}`); + } else { + // A non-primitive here means a mapper produced a value the backend cannot + // receive as a form field. Fail loudly rather than silently drop it: + // structured fields must be JSON-encoded in the mapper, and Files passed via + // the `files` argument. + throw new Error( + `objectToFormData: field "${key}" has an unsupported value of type ` + + `"${typeof value}"; expected a string, number, or boolean.`, + ); + } +} + +/** + * Serialize a backend request model (the output of a `toApiParams` function) + * into multipart FormData: primitives become string fields, arrays become + * repeated fields, and `undefined`/`null` are omitted. Files are appended + * separately via `files`, keeping file plumbing out of the parameter mapper. + * + * Throws if a field holds a non-primitive value, since that cannot be sent as a + * form field: structured fields must be JSON-encoded by the mapper. + */ +export function objectToFormData( + params: ToolApiRequest, + files?: FormDataFiles, +): FormData { + const formData = new FormData(); + + for (const [key, value] of Object.entries(params)) { + if (Array.isArray(value)) { + for (const item of value) { + appendPrimitive(formData, key, item); + } + } else { + appendPrimitive(formData, key, value); + } + } + + if (files) { + for (const [field, value] of Object.entries(files)) { + if (value === undefined) continue; + if (Array.isArray(value)) { + value.forEach((file) => formData.append(field, file)); + } else { + formData.append(field, value); + } + } + } + + return formData; +} diff --git a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts new file mode 100644 index 0000000000..03b85798d8 --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, test } from "vitest"; +import { + ToolCategoryId, + SubcategoryId, + type ToolRegistry, + type ToolRegistryEntry, +} from "@app/data/toolsTaxonomy"; +import { type ToolId } from "@app/types/toolId"; +import { + asRegistryConfig, + ToolType, +} from "@app/hooks/tools/shared/toolOperationTypes"; +import { + deserializeToolStep, + getExecutableTools, + serializeToolStep, + stepRequiresUpload, + type WorkingToolStep, +} from "@app/hooks/tools/shared/toolAutomation"; +import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation"; +import { defaultParameters as compressDefaults } from "@app/hooks/tools/compress/useCompressParameters"; +import { splitOperationConfig } from "@app/hooks/tools/split/useSplitOperation"; +import { SPLIT_METHODS } from "@app/constants/splitConstants"; +import { redactOperationConfig } from "@app/hooks/tools/redact/useRedactOperation"; + +function entry(over: Partial): ToolRegistryEntry { + return { + icon: null, + name: "", + component: null, + description: "", + categoryId: ToolCategoryId.RECOMMENDED_TOOLS, + subcategoryId: SubcategoryId.GENERAL, + automationSettings: null, + ...over, + }; +} + +const NoopSettings = () => null; + +// A migrated, param-less config (mappers present, no settings UI) -> "noSettings". +const repairConfig = asRegistryConfig({ + toolType: ToolType.singleFile, + operationType: "repair", + endpoint: "/api/v1/misc/repair", + defaultParameters: {}, + buildFormData: () => new FormData(), + toApiParams: () => ({}), + fromApiParams: () => ({}), +}); + +// A config with no mappers (not migrated) -> "unsupported". +const changeMetadataConfig = asRegistryConfig({ + toolType: ToolType.singleFile, + operationType: "changeMetadata", + endpoint: "/api/v1/misc/update-metadata", + defaultParameters: {}, + buildFormData: () => new FormData(), +}); + +const registry: Partial = { + compress: entry({ + name: "Compress", + automationSettings: NoopSettings, + operationConfig: asRegistryConfig(compressOperationConfig), + }), + repair: entry({ name: "Repair", operationConfig: repairConfig }), + changeMetadata: entry({ + name: "Change metadata", + automationSettings: NoopSettings, + operationConfig: changeMetadataConfig, + }), + // Excluded: automation explicitly off. + sign: entry({ + name: "Sign", + supportsAutomate: false, + operationConfig: repairConfig, + }), + // Excluded: no operationConfig at all. + extractPages: entry({ name: "Extract pages" }), +}; + +// Separate registry so these don't change the getExecutableTools expectations above. +const dynamicRegistry: Partial = { + split: entry({ + name: "Split", + automationSettings: NoopSettings, + operationConfig: asRegistryConfig(splitOperationConfig), + }), + redact: entry({ + name: "Redact", + automationSettings: NoopSettings, + operationConfig: asRegistryConfig(redactOperationConfig), + }), +}; + +describe("getExecutableTools", () => { + test("lists automatable tools with a resolvable endpoint, classified by support", () => { + const tools = getExecutableTools(registry); + expect(tools.map((t) => t.toolId)).toEqual([ + "changeMetadata", + "compress", + "repair", + ]); + expect(Object.fromEntries(tools.map((t) => [t.toolId, t.support]))).toEqual( + { + compress: "editable", + repair: "noSettings", + changeMetadata: "unsupported", + }, + ); + }); +}); + +describe("serialize/deserialize round-trip", () => { + test("compress maps UI params to the backend body and back", () => { + const step: WorkingToolStep = { + toolId: "compress" as ToolId, + operation: "/api/v1/misc/compress-pdf", + params: { + ...compressDefaults, + compressionLevel: 7, + compressionMethod: "filesize", + fileSizeValue: "2", + fileSizeUnit: "MB", + }, + support: "editable", + }; + + const api = serializeToolStep(step, registry); + expect(api.operation).toBe("/api/v1/misc/compress-pdf"); + expect(api.parameters).toMatchObject({ + optimizeLevel: 7, + expectedOutputSize: "2MB", + }); + + const back = deserializeToolStep(api, registry); + expect(back.toolId).toBe("compress"); + expect(back.params).toMatchObject({ + compressionLevel: 7, + compressionMethod: "filesize", + fileSizeValue: "2", + fileSizeUnit: "MB", + }); + }); + + test("an unknown endpoint is preserved as an unmapped step", () => { + const step = deserializeToolStep( + { operation: "/api/v1/unknown/thing", parameters: { keep: true } }, + registry, + ); + expect(step.toolId).toBeNull(); + expect(step.support).toBe("unknown"); + expect(serializeToolStep(step, registry)).toEqual({ + operation: "/api/v1/unknown/thing", + parameters: { keep: true }, + }); + }); + + test("a dynamic-endpoint tool (split by chapters) round-trips as an editable step", () => { + const step: WorkingToolStep = { + toolId: "split" as ToolId, + operation: "/api/v1/general/split-pdf-by-chapters", + params: { method: SPLIT_METHODS.BY_CHAPTERS, bookmarkLevel: "2" }, + support: "editable", + }; + + const api = serializeToolStep(step, dynamicRegistry); + expect(api.operation).toBe("/api/v1/general/split-pdf-by-chapters"); + expect(api.parameters).toMatchObject({ bookmarkLevel: 2 }); + + // No `method` in the stored body, so this only matches via the declared endpoint set. + const back = deserializeToolStep(api, dynamicRegistry); + expect(back.toolId).toBe("split"); + expect(back.support).toBe("editable"); + expect(back.operation).toBe("/api/v1/general/split-pdf-by-chapters"); + expect(back.params).toMatchObject({ method: SPLIT_METHODS.BY_CHAPTERS }); + }); + + test("a dynamic-endpoint tool whose routing field is dropped (redact) stays editable", () => { + const step: WorkingToolStep = { + toolId: "redact" as ToolId, + operation: "/api/v1/security/auto-redact", + params: { mode: "automatic", wordsToRedact: ["secret"] }, + support: "editable", + }; + + const api = serializeToolStep(step, dynamicRegistry); + expect(api.operation).toBe("/api/v1/security/auto-redact"); + expect(api.parameters).not.toHaveProperty("mode"); + + // No `mode` in the body, so it only matches via the declared set (replay would yield null). + const back = deserializeToolStep(api, dynamicRegistry); + expect(back.toolId).toBe("redact"); + expect(back.support).toBe("editable"); + expect(back.operation).toBe("/api/v1/security/auto-redact"); + expect(back.params).toMatchObject({ mode: "automatic" }); + }); +}); + +describe("stepRequiresUpload", () => { + const step = (params: Record): WorkingToolStep => ({ + toolId: "compress" as ToolId, + operation: "/api/v1/misc/compress-pdf", + params, + support: "editable", + }); + + test("detects a File (or list of Files) among the parameters", () => { + const image = new File(["x"], "logo.png", { type: "image/png" }); + expect(stepRequiresUpload(step({ level: 5 }))).toBe(false); + expect(stepRequiresUpload(step({ watermarkImage: image }))).toBe(true); + expect(stepRequiresUpload(step({ attachments: [image] }))).toBe(true); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts new file mode 100644 index 0000000000..d567a1dad1 --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts @@ -0,0 +1,260 @@ +/** + * Registry-level automation logic: which tools can be run as a backend operation step, how much + * of their parameters can be edited in a UI, and conversion between a tool's frontend parameter + * shape and the backend step contract (endpoint + backend parameters). + * + * This is core behaviour shared by every surface that composes or replays tool operations against + * the backend engine (portal pipelines today; backend-executed automations and AI plans later), + * so the "is this tool usable, and how" decision lives with the tools rather than in any one + * feature. It builds on each tool's `operationConfig` mappers (`toApiParams` / `fromApiParams`), + * keeping the frontend<->backend parameter mapping single-sourced in the tools. + */ + +import { type ReactNode } from "react"; +import { + getToolSupportsAutomate, + type SubcategoryId, + type ToolRegistry, + type ToolRegistryEntry, +} from "@app/data/toolsTaxonomy"; +import { type ToolId } from "@app/types/toolId"; +import { + isToolEndpoint, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; +import { + type ErasedToolParams, + type RegistryToolOperationConfig, +} from "@app/hooks/tools/shared/toolOperationTypes"; + +/** + * How much of a tool's parameters a UI can edit when composing a backend step: + * - `editable`: has both mappers and a settings UI -> render the settings UI. + * - `noSettings`: migrated to the mapper seam but has no parameters to configure. + * - `unsupported`: not migrated to the mapper seam -> parameters can't be mapped for editing yet; + * the step runs with the backend's defaults. + */ +export type ToolStepSupport = "editable" | "noSettings" | "unsupported"; + +/** A tool that can be added to a backend operation chain, with its editing support. */ +export interface ExecutableTool { + toolId: ToolId; + name: string; + icon: ReactNode; + /** Subcategory the tool belongs to, so a picker can group tools without re-reading the registry. */ + subcategoryId: SubcategoryId; + /** Endpoint resolved from default parameters, for display/inclusion. The stored step's endpoint is re-resolved from the configured parameters at serialization time. */ + endpoint: ToolEndpoint; + support: ToolStepSupport; +} + +/** + * The backend step contract: an endpoint path plus its backend-shaped parameters. `operation` is a + * plain string, not a {@link ToolEndpoint}, because this is the raw backend boundary (it mirrors + * the stored policy step) and a pipeline may reference endpoints the frontend does not model. + */ +export interface ToolApiStep { + operation: string; + parameters: Record; +} + +/** A step being edited in a UI that maps to a known tool: parameters are in the tool's frontend shape. */ +export interface KnownToolStep { + toolId: ToolId; + operation: ToolEndpoint; + params: ErasedToolParams; + support: ToolStepSupport; +} + +/** A stored step whose endpoint maps to no known tool: preserved verbatim, not editable. */ +export interface UnknownToolStep { + toolId: null; + operation: string; + params: ErasedToolParams; + support: "unknown"; +} + +/** A step being edited in a UI, discriminated by whether its endpoint maps to a known tool. */ +export type WorkingToolStep = KnownToolStep | UnknownToolStep; + +/** + * Resolve a tool's endpoint from parameters. Static endpoints ignore params; dynamic ones may + * return undefined if the params don't determine one. The result is validated against the generated + * endpoint set (via {@link isToolEndpoint}) rather than cast, so a config endpoint that is not a + * known {@link ToolEndpoint} (e.g. a custom tool's arbitrary string) resolves to undefined. + */ +function resolveEndpoint( + config: RegistryToolOperationConfig | undefined, + params: ErasedToolParams, +): ToolEndpoint | undefined { + const endpoint = config?.endpoint; + if (typeof endpoint === "string") { + return isToolEndpoint(endpoint) ? endpoint : undefined; + } + if (typeof endpoint === "function") { + const resolved = safeCall(endpoint, params); + return typeof resolved === "string" && isToolEndpoint(resolved) + ? resolved + : undefined; + } + return undefined; +} + +/** Invoke a dynamic-endpoint function defensively; a throw means the params don't determine one. */ +function safeCall( + fn: (params: ErasedToolParams) => string | null | undefined, + params: ErasedToolParams, +): string | null | undefined { + try { + return fn(params); + } catch { + return undefined; + } +} + +/** Classify how much of a tool's parameters a UI can edit when composing a step. */ +export function classifyToolStepSupport( + entry: ToolRegistryEntry, +): ToolStepSupport { + const config = entry.operationConfig; + const hasMappers = Boolean(config?.toApiParams && config?.fromApiParams); + if (!hasMappers) return "unsupported"; + return entry.automationSettings ? "editable" : "noSettings"; +} + +function isFileValue(value: unknown): boolean { + if (typeof File === "undefined") return false; + if (value instanceof File) return true; + return Array.isArray(value) && value.some((item) => item instanceof File); +} + +/** + * True if any of a step's parameters is an uploaded file (or list of files). Such a step cannot be + * saved into a stored pipeline yet: the file bytes are not persisted with the policy, so a later + * (e.g. scheduled) run would have nothing to send for that named file field. + */ +export function stepRequiresUpload(step: WorkingToolStep): boolean { + return Object.values(step.params).some(isFileValue); +} + +/** + * The tools that can be run as a backend operation step, sorted by name. Includes only automatable + * tools whose endpoint resolves from defaults (so they can become a backend step); this drops + * tools with no operationConfig and tools whose endpoint needs runtime input (e.g. convert). + */ +export function getExecutableTools( + registry: Partial, +): ExecutableTool[] { + const tools: ExecutableTool[] = []; + for (const [id, entry] of Object.entries(registry)) { + if (!entry || !getToolSupportsAutomate(entry)) continue; + const config = entry.operationConfig; + if (!config) continue; + const endpoint = resolveEndpoint(config, config.defaultParameters ?? {}); + if (!endpoint) continue; + tools.push({ + toolId: id as ToolId, + name: entry.name, + icon: entry.icon, + subcategoryId: entry.subcategoryId, + endpoint, + support: classifyToolStepSupport(entry), + }); + } + return tools.sort((a, b) => a.name.localeCompare(b.name)); +} + +/** A fresh working step for a tool just added to a chain, seeded with its default parameters. */ +export function newWorkingToolStep( + tool: ExecutableTool, + registry: Partial, +): KnownToolStep { + const config = registry[tool.toolId]?.operationConfig; + return { + toolId: tool.toolId, + operation: tool.endpoint, + params: { ...(config?.defaultParameters ?? {}) }, + support: tool.support, + }; +} + +/** Serialize a working step into the backend step contract (endpoint + backend parameters). */ +export function serializeToolStep( + step: WorkingToolStep, + registry: Partial, +): ToolApiStep { + const config = + step.toolId !== null ? registry[step.toolId]?.operationConfig : undefined; + if (!config) { + // Unmapped step (unknown endpoint on edit): round-trip it unchanged. + return { operation: step.operation, parameters: step.params }; + } + const merged = { ...(config.defaultParameters ?? {}), ...step.params }; + const operation = resolveEndpoint(config, merged) ?? step.operation; + const parameters = config.toApiParams + ? (config.toApiParams(merged) as Record) + : {}; + return { operation, parameters }; +} + +/** + * Find the registry tool for a stored step's endpoint: exact match for static endpoints, else + * membership in a dynamic tool's declared `endpoints` set (replaying its function can't recover a + * frontend-only routing field). Replay is only the fallback when no set is declared. + */ +function findToolByEndpoint( + step: ToolApiStep, + registry: Partial, +): [ToolId, ToolRegistryEntry] | undefined { + let dynamic: [ToolId, ToolRegistryEntry] | undefined; + for (const [id, entry] of Object.entries(registry)) { + const endpoint = entry?.operationConfig?.endpoint; + if (typeof endpoint === "string") { + if (endpoint === step.operation) return [id as ToolId, entry]; + } else if (typeof endpoint === "function" && !dynamic) { + const declared = entry?.operationConfig?.endpoints; + const matched = declared + ? declared.some((e) => e === step.operation) + : safeCall(endpoint, step.parameters) === step.operation; + if (matched) dynamic = [id as ToolId, entry]; + } + } + return dynamic; +} + +/** A stored step kept verbatim because its endpoint maps to no known tool. */ +function unmappedStep(step: ToolApiStep): UnknownToolStep { + return { + toolId: null, + operation: step.operation, + params: { ...step.parameters }, + support: "unknown", + }; +} + +/** + * Rehydrate a stored backend step into a working step for editing: map the endpoint back to a tool + * and its backend parameters back to the frontend shape via `fromApiParams`. Steps whose endpoint + * maps to no known tool are kept as an unmapped, non-editable working step (parameters preserved). + */ +export function deserializeToolStep( + step: ToolApiStep, + registry: Partial, +): WorkingToolStep { + const match = findToolByEndpoint(step, registry); + if (!match) return unmappedStep(step); + const [toolId, entry] = match; + const config = entry.operationConfig; + const params: ErasedToolParams = config?.fromApiParams + ? { + ...(config.defaultParameters ?? {}), + ...config.fromApiParams(step.parameters as never), + } + : { ...(config?.defaultParameters ?? {}) }; + // Validate against the generated endpoint set instead of casting the matched string. + const operation = + resolveEndpoint(config, params) ?? + (isToolEndpoint(step.operation) ? step.operation : undefined); + if (operation === undefined) return unmappedStep(step); + return { toolId, operation, params, support: classifyToolStepSupport(entry) }; +} diff --git a/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts b/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts index 0d48ce6d09..321443f463 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts @@ -3,9 +3,16 @@ import { StirlingFile } from "@app/types/fileContext"; import type { ResponseHandler } from "@app/utils/toolResponseProcessor"; import { ToolId } from "@app/types/toolId"; import type { ProcessingProgress } from "@app/hooks/tools/shared/useToolState"; +import type { ToolApiParams, ToolEndpoint } from "@app/types/toolApiTypes"; export type { ProcessingProgress, ResponseHandler }; +/** + * A tool operation's backend endpoint, checked against the generated ToolEndpoint + * set, or `null` when the operation has no backend endpoint. + */ +export type ToolOperationEndpoint = ToolEndpoint | null; + export enum ToolType { singleFile, multiFile, @@ -46,7 +53,7 @@ export interface CustomProcessorResult { * 2. Multi-file tools: toolType: multiFile, single API call with all files * 3. Complex tools: toolType: custom, customProcessor handles all processing logic */ -interface BaseToolOperationConfig { +interface BaseToolOperationConfig { /** Operation identifier for tracking and logging */ operationType: ToolId; @@ -72,6 +79,20 @@ interface BaseToolOperationConfig { /** Default parameter values for automation */ defaultParameters?: TParams; + /** + * Typed frontend params -> backend request model. When a tool provides this, + * it is the spec-checked source of truth for the request body and its + * buildFormData is derived from it via objectToFormData. Bound to the tool's + * endpoint, so a spec rename of that endpoint's model breaks the build here. + */ + toApiParams?(params: TParams): ToolApiParams[TEndpoint]; + + /** + * Backend request model -> partial frontend params, so a stored API call + * can be re-hydrated into this tool's settings UI. + */ + fromApiParams?(apiParams: ToolApiParams[TEndpoint]): Partial; + /** * For custom tools: if true, success implies all input files were successfully processed. * Use this for tools like Automate or Merge where Many-to-One relationships exist @@ -80,24 +101,30 @@ interface BaseToolOperationConfig { consumesAllInputs?: boolean; } -export interface SingleFileToolOperationConfig< +interface SingleFileToolBody< TParams, -> extends BaseToolOperationConfig { + TEndpoint extends ToolEndpoint, +> extends BaseToolOperationConfig { /** This tool processes one file at a time. */ toolType: ToolType.singleFile; /** Builds FormData for API request. */ buildFormData: (params: TParams, file: File) => FormData; - /** API endpoint for the operation. Can be static string or function for dynamic routing. */ - endpoint: string | ((params: TParams) => string); - customProcessor?: undefined; } -export interface MultiFileToolOperationConfig< +/** Single-file tool config; see {@link EndpointBinding} for the endpoint/endpoints rule. */ +export type SingleFileToolOperationConfig< TParams, -> extends BaseToolOperationConfig { + TEndpoint extends ToolEndpoint = ToolEndpoint, +> = SingleFileToolBody & + EndpointBinding; + +interface MultiFileToolBody< + TParams, + TEndpoint extends ToolEndpoint, +> extends BaseToolOperationConfig { /** This tool processes multiple files at once. */ toolType: ToolType.multiFile; @@ -107,15 +134,18 @@ export interface MultiFileToolOperationConfig< /** Builds FormData for API request. */ buildFormData: (params: TParams, files: File[]) => FormData; - /** API endpoint for the operation. Can be static string or function for dynamic routing. */ - endpoint: string | ((params: TParams) => string); - customProcessor?: undefined; } +/** Multi-file counterpart of {@link SingleFileToolOperationConfig}. */ +export type MultiFileToolOperationConfig< + TParams, + TEndpoint extends ToolEndpoint = ToolEndpoint, +> = MultiFileToolBody & EndpointBinding; + export interface CustomToolOperationConfig< TParams, -> extends BaseToolOperationConfig { +> extends BaseToolOperationConfig { /** This tool has custom behaviour. */ toolType: ToolType.custom; @@ -128,6 +158,9 @@ export interface CustomToolOperationConfig< */ endpoint?: string | ((params: TParams) => string | undefined); + /** `never` so `endpoints` stays readable across the union; custom tools declare no set. */ + endpoints?: never; + /** * Custom processing logic that completely bypasses standard file processing. * This tool handles all API calls, response processing, and file creation. @@ -143,11 +176,79 @@ export interface CustomToolOperationConfig< ) => Promise; } -export type ToolOperationConfig = - | SingleFileToolOperationConfig - | MultiFileToolOperationConfig +export type ToolOperationConfig< + TParams = void, + TEndpoint extends ToolEndpoint = ToolEndpoint, +> = + | SingleFileToolOperationConfig + | MultiFileToolOperationConfig | CustomToolOperationConfig; +/** + * A static `endpoint` declares no set; a dynamic (function) `endpoint` must declare its full + * `endpoints` set, which is how findToolByEndpoint maps a stored step back to its tool when the + * endpoint-selecting parameter is frontend-only. + */ +type EndpointBinding = + | { endpoint: TEndpoint | null; endpoints?: never } + | { + endpoint: (params: TParams) => TEndpoint | null; + endpoints: readonly TEndpoint[]; + }; + +/** Union-distributing Omit, so stripping a key from a discriminated config keeps its branches. */ +type DistributiveOmit = T extends unknown + ? Omit + : never; + +/** + * Define a single-file tool's operation config. Infers the endpoint literal from + * `endpoint` and binds toApiParams/fromApiParams to that endpoint's request + * model, so a mapper cannot silently drift from the generated spec. + */ +export function defineSingleFileTool< + TParams, + const TEndpoint extends ToolEndpoint, +>( + config: DistributiveOmit< + SingleFileToolOperationConfig, + "toolType" + >, +): SingleFileToolOperationConfig { + return { + ...config, + toolType: ToolType.singleFile, + } as SingleFileToolOperationConfig; +} + +/** Multi-file counterpart of {@link defineSingleFileTool}. */ +export function defineMultiFileTool< + TParams, + const TEndpoint extends ToolEndpoint, +>( + config: DistributiveOmit< + MultiFileToolOperationConfig, + "toolType" + >, +): MultiFileToolOperationConfig { + return { + ...config, + toolType: ToolType.multiFile, + } as MultiFileToolOperationConfig; +} + +/** + * Custom-processor counterpart of {@link defineSingleFileTool}, for tools whose + * customProcessor owns the API calls and file handling. Rejects fields that + * belong to the file-based patterns (e.g. buildFormData) and any property not on + * the config, so a stray or stale field is a build error rather than dead weight. + */ +export function defineCustomTool( + config: Omit, "toolType">, +): CustomToolOperationConfig { + return { ...config, toolType: ToolType.custom }; +} + /** * One generic source-of-truth for the props every automation settings component * accepts: the tool's parameters plus a typed change handler. diff --git a/frontend/editor/src/core/hooks/tools/shared/useToolApiCalls.ts b/frontend/editor/src/core/hooks/tools/shared/useToolApiCalls.ts index 28da62484a..cca1f5e563 100644 --- a/frontend/editor/src/core/hooks/tools/shared/useToolApiCalls.ts +++ b/frontend/editor/src/core/hooks/tools/shared/useToolApiCalls.ts @@ -10,7 +10,7 @@ import type { ProcessingProgress } from "@app/hooks/tools/shared/useToolState"; import type { StirlingFile, FileId } from "@app/types/fileContext"; export interface ApiCallsConfig { - endpoint: string | ((params: TParams) => string); + endpoint: string | null | ((params: TParams) => string | null); buildFormData: (params: TParams, file: File) => FormData; filePrefix?: string; responseHandler?: ResponseHandler; @@ -37,6 +37,19 @@ export const useToolApiCalls = () => { // Create cancel token for this operation cancelTokenRef.current = axios.CancelToken.source(); + // Params are the same for every file, so resolve the endpoint once. A null + // endpoint means the tool has no backend call (e.g. client-side tools) and + // should never reach here, so fail loudly rather than POST to null. + const endpoint = + typeof config.endpoint === "function" + ? config.endpoint(params) + : config.endpoint; + if (!endpoint) { + throw new Error( + "This operation has no backend endpoint and cannot be executed directly.", + ); + } + for (let i = 0; i < validFiles.length; i++) { const file = validFiles[i]; @@ -51,10 +64,6 @@ export const useToolApiCalls = () => { try { const formData = config.buildFormData(params, file); - const endpoint = - typeof config.endpoint === "function" - ? config.endpoint(params) - : config.endpoint; console.debug("[processFiles] POST", { endpoint, name: file.name }); const response = await apiClient.post(endpoint, formData, { responseType: "blob", diff --git a/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts b/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts index 5629dfe54b..7ee69da142 100644 --- a/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts +++ b/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts @@ -40,6 +40,9 @@ import { } from "@app/hooks/tools/shared/toolOperationHelpers"; import { ToolType, + defineSingleFileTool, + defineMultiFileTool, + defineCustomTool, ToolOperationConfig, ToolOperationHook, CustomProcessorResult, @@ -50,7 +53,12 @@ import { ResponseHandler, } from "@app/hooks/tools/shared/toolOperationTypes"; -export { ToolType }; +export { + ToolType, + defineSingleFileTool, + defineMultiFileTool, + defineCustomTool, +}; export type { ToolOperationConfig, ToolOperationHook, @@ -69,10 +77,10 @@ export { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; * Shared hook for tool operations providing consistent error handling, progress tracking, * and FileContext integration. Eliminates boilerplate while maintaining flexibility. * - * Supports three tool patterns: - * 1. Single-file tools: Set multiFileEndpoint: false, processes files individually - * 2. Multi-file tools: Set multiFileEndpoint: true, single API call with all files - * 3. Complex tools: Provide customProcessor for full control over processing logic + * Supports three tool patterns, selected by the config's toolType: + * 1. Single-file tools (ToolType.singleFile): processes files individually + * 2. Multi-file tools (ToolType.multiFile): single API call with all files + * 3. Complex tools (ToolType.custom): customProcessor takes full control * * @param config - Tool operation configuration * @returns Hook interface with state and execution methods @@ -266,6 +274,11 @@ export const useToolOperation = ( typeof config.endpoint === "function" ? config.endpoint(params) : config.endpoint; + if (!endpoint) { + throw new Error( + "This operation has no backend endpoint and cannot be executed directly.", + ); + } const response = await apiClient.post(endpoint, formData, { responseType: "blob", diff --git a/frontend/editor/src/core/hooks/tools/sign/useSignOperation.ts b/frontend/editor/src/core/hooks/tools/sign/useSignOperation.ts index bd92fdd758..d54be4ecbf 100644 --- a/frontend/editor/src/core/hooks/tools/sign/useSignOperation.ts +++ b/frontend/editor/src/core/hooks/tools/sign/useSignOperation.ts @@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next"; import { useToolOperation, ToolOperationHook, - ToolType, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; import { SignParameters, @@ -50,14 +50,16 @@ export const buildSignFormData = ( }; // Static configuration object -export const signOperationConfig = { - toolType: ToolType.singleFile, +export const signOperationConfig = defineSingleFileTool({ buildFormData: buildSignFormData, operationType: "sign", - endpoint: "/api/v1/security/add-signature", + // Signing is applied client-side in the viewer (see createStampTool -> + // flattenSignatures); there is no backend endpoint and the standard execute + // path is never used. + endpoint: null, filePrefix: "signed_", defaultParameters: DEFAULT_PARAMETERS, -} as const; +}); export const useSignOperation = (): ToolOperationHook => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/singleLargePage/useSingleLargePageOperation.ts b/frontend/editor/src/core/hooks/tools/singleLargePage/useSingleLargePageOperation.ts index a57a26a7dc..432aae59ec 100644 --- a/frontend/editor/src/core/hooks/tools/singleLargePage/useSingleLargePageOperation.ts +++ b/frontend/editor/src/core/hooks/tools/singleLargePage/useSingleLargePageOperation.ts @@ -1,32 +1,39 @@ import { useTranslation } from "react-i18next"; import { - ToolType, useToolOperation, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + fileOnlyMapping, + objectToFormData, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import { SingleLargePageParameters, defaultParameters, } from "@app/hooks/tools/singleLargePage/useSingleLargePageParameters"; +const ENDPOINT = "/api/v1/general/pdf-to-single-page" satisfies ToolEndpoint; + +// Single large page takes only a file; there are no request parameters to map. +const { toApiParams, fromApiParams } = fileOnlyMapping(); + // Static function that can be used by both the hook and automation executor export const buildSingleLargePageFormData = ( _parameters: SingleLargePageParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - return formData; -}; +): FormData => objectToFormData(toApiParams(), { fileInput: file }); // Static configuration object -export const singleLargePageOperationConfig = { - toolType: ToolType.singleFile, +export const singleLargePageOperationConfig = defineSingleFileTool({ buildFormData: buildSingleLargePageFormData, + toApiParams, + fromApiParams, operationType: "pdfToSinglePage", - endpoint: "/api/v1/general/pdf-to-single-page", + endpoint: ENDPOINT, defaultParameters, -} as const; +}); export const useSingleLargePageOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/split/useSplitOperation.test.ts b/frontend/editor/src/core/hooks/tools/split/useSplitOperation.test.ts new file mode 100644 index 0000000000..4cf8509c5d --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/split/useSplitOperation.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, test } from "vitest"; +import { + buildSplitFormData, + getSplitEndpoint, + splitFromApiParams, + splitToApiParams, +} from "@app/hooks/tools/split/useSplitOperation"; +import { + SplitParameters, + defaultParameters, +} from "@app/hooks/tools/split/useSplitParameters"; +import { SPLIT_METHODS } from "@app/constants/splitConstants"; + +const params = (overrides: Partial): SplitParameters => ({ + ...defaultParameters, + ...overrides, +}); + +describe("splitToApiParams", () => { + test("byPages sends pageNumbers", () => { + expect( + splitToApiParams( + params({ method: SPLIT_METHODS.BY_PAGES, pages: "2,5" }), + ), + ).toEqual({ pageNumbers: "2,5" }); + }); + + test("bySections sends divisions and split mode without custom pages", () => { + expect( + splitToApiParams( + params({ + method: SPLIT_METHODS.BY_SECTIONS, + hDiv: "3", + vDiv: "2", + merge: true, + splitMode: "SPLIT_ALL", + }), + ), + ).toEqual({ + horizontalDivisions: 3, + verticalDivisions: 2, + merge: true, + splitMode: "SPLIT_ALL", + }); + }); + + test("bySections includes pageNumbers only for CUSTOM mode", () => { + expect( + splitToApiParams( + params({ + method: SPLIT_METHODS.BY_SECTIONS, + splitMode: "CUSTOM", + customPages: "1,2", + }), + ), + ).toMatchObject({ splitMode: "CUSTOM", pageNumbers: "1,2" }); + }); + + test.each([ + { method: SPLIT_METHODS.BY_SIZE, splitType: 0 }, + { method: SPLIT_METHODS.BY_PAGE_COUNT, splitType: 1 }, + { method: SPLIT_METHODS.BY_DOC_COUNT, splitType: 2 }, + ])("$method maps to splitType $splitType", ({ method, splitType }) => { + expect(splitToApiParams(params({ method, splitValue: "5" }))).toEqual({ + splitType, + splitValue: "5", + }); + }); + + test("byChapters converts bookmarkLevel to a number", () => { + expect( + splitToApiParams( + params({ + method: SPLIT_METHODS.BY_CHAPTERS, + bookmarkLevel: "2", + includeMetadata: true, + }), + ), + ).toEqual({ + bookmarkLevel: 2, + includeMetadata: true, + allowDuplicates: false, + }); + }); + + test("byPageDivider sends duplexMode", () => { + expect( + splitToApiParams( + params({ method: SPLIT_METHODS.BY_PAGE_DIVIDER, duplexMode: true }), + ), + ).toEqual({ duplexMode: true }); + }); + + test("byPoster maps the factors to the spec's xFactor/yFactor fields", () => { + expect( + splitToApiParams( + params({ + method: SPLIT_METHODS.BY_POSTER, + pageSize: "A4", + xFactor: "3", + yFactor: "2", + rightToLeft: true, + }), + ), + ).toEqual({ pageSize: "A4", xFactor: 3, yFactor: 2, rightToLeft: true }); + }); + + // A cleared numeric field arrives as "". It must fall back to the default, + // not Number("") === 0, which the backend turns into an empty/degenerate PDF. + test("byPoster falls back to the default factor for empty fields", () => { + expect( + splitToApiParams( + params({ method: SPLIT_METHODS.BY_POSTER, xFactor: "", yFactor: "" }), + ), + ).toMatchObject({ xFactor: 2, yFactor: 2 }); + }); + + test("bySections falls back to the default divisions for empty fields", () => { + expect( + splitToApiParams( + params({ method: SPLIT_METHODS.BY_SECTIONS, hDiv: "", vDiv: "" }), + ), + ).toMatchObject({ horizontalDivisions: 2, verticalDivisions: 2 }); + }); + + test("byChapters falls back to the default bookmark level for an empty field", () => { + expect( + splitToApiParams( + params({ method: SPLIT_METHODS.BY_CHAPTERS, bookmarkLevel: "" }), + ), + ).toMatchObject({ bookmarkLevel: 1 }); + }); +}); + +describe("split round-trip", () => { + test.each>([ + { method: SPLIT_METHODS.BY_PAGES, pages: "2,5" }, + { + method: SPLIT_METHODS.BY_SECTIONS, + hDiv: "3", + vDiv: "2", + merge: true, + splitMode: "SPLIT_ALL", + }, + { + method: SPLIT_METHODS.BY_SECTIONS, + splitMode: "CUSTOM", + customPages: "1,2", + }, + { method: SPLIT_METHODS.BY_SIZE, splitValue: "10MB" }, + { method: SPLIT_METHODS.BY_PAGE_COUNT, splitValue: "5" }, + { method: SPLIT_METHODS.BY_DOC_COUNT, splitValue: "3" }, + { + method: SPLIT_METHODS.BY_CHAPTERS, + bookmarkLevel: "2", + includeMetadata: true, + }, + { method: SPLIT_METHODS.BY_PAGE_DIVIDER, duplexMode: true }, + { + method: SPLIT_METHODS.BY_POSTER, + pageSize: "A4", + xFactor: "3", + yFactor: "2", + }, + ])("toApiParams(fromApiParams(x)) reproduces x for %o", (overrides) => { + const api = splitToApiParams(params(overrides)); + const roundTripped = splitToApiParams(params(splitFromApiParams(api))); + + expect(roundTripped).toEqual(api); + }); +}); + +describe("getSplitEndpoint", () => { + test.each([ + { method: SPLIT_METHODS.BY_PAGES, endpoint: "/api/v1/general/split-pages" }, + { + method: SPLIT_METHODS.BY_SECTIONS, + endpoint: "/api/v1/general/split-pdf-by-sections", + }, + { + method: SPLIT_METHODS.BY_SIZE, + endpoint: "/api/v1/general/split-by-size-or-count", + }, + { + method: SPLIT_METHODS.BY_CHAPTERS, + endpoint: "/api/v1/general/split-pdf-by-chapters", + }, + { + method: SPLIT_METHODS.BY_PAGE_DIVIDER, + endpoint: "/api/v1/misc/auto-split-pdf", + }, + { + method: SPLIT_METHODS.BY_POSTER, + endpoint: "/api/v1/general/split-for-poster-print", + }, + ])("$method routes to $endpoint", ({ method, endpoint }) => { + expect(getSplitEndpoint(params({ method }))).toBe(endpoint); + }); +}); + +describe("buildSplitFormData", () => { + test("appends the file and the serialized parameters", () => { + const file = new File(["x"], "test.pdf", { type: "application/pdf" }); + const formData = buildSplitFormData( + params({ method: SPLIT_METHODS.BY_PAGES, pages: "3" }), + file, + ); + + expect(formData.get("fileInput")).toBe(file); + expect(formData.get("pageNumbers")).toBe("3"); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/split/useSplitOperation.ts b/frontend/editor/src/core/hooks/tools/split/useSplitOperation.ts index bedf83d09e..af14ca2045 100644 --- a/frontend/editor/src/core/hooks/tools/split/useSplitOperation.ts +++ b/frontend/editor/src/core/hooks/tools/split/useSplitOperation.ts @@ -1,123 +1,186 @@ import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { - ToolType, useToolOperation, ToolOperationConfig, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + objectToFormData, + type ToolApiParams, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import { SplitParameters, defaultParameters, } from "@app/hooks/tools/split/useSplitParameters"; -import { SPLIT_METHODS } from "@app/constants/splitConstants"; +import { SPLIT_METHODS, type SplitMethod } from "@app/constants/splitConstants"; import { useToolResources } from "@app/hooks/tools/shared/useToolResources"; -// Static functions that can be used by both the hook and automation executor -export const buildSplitFormData = ( +// Split routes to a different endpoint per method. This map is the single source +// of truth: getSplitEndpoint returns from it, and the mapper types below are +// derived from it, so the endpoint posted and the request shape checked can +// never point at different endpoints. +const SPLIT_ENDPOINTS = { + [SPLIT_METHODS.BY_PAGES]: "/api/v1/general/split-pages", + [SPLIT_METHODS.BY_SECTIONS]: "/api/v1/general/split-pdf-by-sections", + [SPLIT_METHODS.BY_SIZE]: "/api/v1/general/split-by-size-or-count", + [SPLIT_METHODS.BY_PAGE_COUNT]: "/api/v1/general/split-by-size-or-count", + [SPLIT_METHODS.BY_DOC_COUNT]: "/api/v1/general/split-by-size-or-count", + [SPLIT_METHODS.BY_CHAPTERS]: "/api/v1/general/split-pdf-by-chapters", + [SPLIT_METHODS.BY_PAGE_DIVIDER]: "/api/v1/misc/auto-split-pdf", + [SPLIT_METHODS.BY_POSTER]: "/api/v1/general/split-for-poster-print", +} as const satisfies Record; + +type SplitEndpoint = (typeof SPLIT_ENDPOINTS)[SplitMethod]; +type SplitApiParams = ToolApiParams[SplitEndpoint]; +type SectionsApiParams = + ToolApiParams[(typeof SPLIT_ENDPOINTS)[typeof SPLIT_METHODS.BY_SECTIONS]]; +type PosterApiParams = + ToolApiParams[(typeof SPLIT_ENDPOINTS)[typeof SPLIT_METHODS.BY_POSTER]]; + +// Convert the tool's UI parameters into the request body for the routed endpoint. +export const splitToApiParams = ( parameters: SplitParameters, - file: File, -): FormData => { - const formData = new FormData(); - - formData.append("fileInput", file); - +): SplitApiParams => { // Use BY_PAGES as default if no method is selected const method = parameters.method || SPLIT_METHODS.BY_PAGES; switch (method) { case SPLIT_METHODS.BY_PAGES: - formData.append("pageNumbers", parameters.pages); - break; - case SPLIT_METHODS.BY_SECTIONS: - formData.append("horizontalDivisions", parameters.hDiv); - formData.append("verticalDivisions", parameters.vDiv); - formData.append("merge", (parameters.merge ?? false).toString()); - formData.append("splitMode", parameters.splitMode || "SPLIT_ALL"); + return { pageNumbers: parameters.pages }; + case SPLIT_METHODS.BY_SECTIONS: { + const sections: SectionsApiParams = { + horizontalDivisions: Number(parameters.hDiv || "2"), + verticalDivisions: Number(parameters.vDiv || "2"), + merge: parameters.merge ?? false, + splitMode: (parameters.splitMode || + "SPLIT_ALL") as SectionsApiParams["splitMode"], + }; if (parameters.splitMode === "CUSTOM" && parameters.customPages) { - formData.append("pageNumbers", parameters.customPages); + sections.pageNumbers = parameters.customPages; } - break; + return sections; + } case SPLIT_METHODS.BY_SIZE: - formData.append("splitType", "0"); - formData.append("splitValue", parameters.splitValue); - break; + return { splitType: 0, splitValue: parameters.splitValue }; case SPLIT_METHODS.BY_PAGE_COUNT: - formData.append("splitType", "1"); - formData.append("splitValue", parameters.splitValue); - break; + return { splitType: 1, splitValue: parameters.splitValue }; case SPLIT_METHODS.BY_DOC_COUNT: - formData.append("splitType", "2"); - formData.append("splitValue", parameters.splitValue); - break; + return { splitType: 2, splitValue: parameters.splitValue }; case SPLIT_METHODS.BY_CHAPTERS: - formData.append("bookmarkLevel", parameters.bookmarkLevel); - formData.append( - "includeMetadata", - (parameters.includeMetadata ?? false).toString(), - ); - formData.append( - "allowDuplicates", - (parameters.allowDuplicates ?? false).toString(), - ); - break; + return { + bookmarkLevel: Number(parameters.bookmarkLevel || "1"), + includeMetadata: parameters.includeMetadata ?? false, + allowDuplicates: parameters.allowDuplicates ?? false, + }; case SPLIT_METHODS.BY_PAGE_DIVIDER: - formData.append( - "duplexMode", - (parameters.duplexMode ?? false).toString(), - ); - break; + return { duplexMode: parameters.duplexMode ?? false }; case SPLIT_METHODS.BY_POSTER: - formData.append("pageSize", parameters.pageSize || "A4"); - formData.append("xFactor", parameters.xFactor || "2"); - formData.append("yFactor", parameters.yFactor || "2"); - formData.append( - "rightToLeft", - (parameters.rightToLeft ?? false).toString(), - ); - break; + return { + pageSize: (parameters.pageSize || "A4") as PosterApiParams["pageSize"], + xFactor: Number(parameters.xFactor || "2"), + yFactor: Number(parameters.yFactor || "2"), + rightToLeft: parameters.rightToLeft ?? false, + }; default: throw new Error(`Unknown split method: ${method}`); } - - return formData; }; -export const getSplitEndpoint = (parameters: SplitParameters): string => { - // Default to BY_PAGES endpoint if no method selected yet - if (!parameters.method) { - return "/api/v1/general/split-pages"; +// Reconstruct the tool's UI parameters from a stored request body. The step +// carries no explicit method, so it is inferred from the fields present. +export const splitFromApiParams = ( + apiParams: SplitApiParams, +): Partial => { + if ("pageSize" in apiParams) { + return { + method: SPLIT_METHODS.BY_POSTER, + pageSize: apiParams.pageSize, + xFactor: + apiParams.xFactor !== undefined ? `${apiParams.xFactor}` : undefined, + yFactor: + apiParams.yFactor !== undefined ? `${apiParams.yFactor}` : undefined, + rightToLeft: apiParams.rightToLeft ?? defaultParameters.rightToLeft, + }; } - - switch (parameters.method) { - case null: - case SPLIT_METHODS.BY_PAGES: - return "/api/v1/general/split-pages"; - case SPLIT_METHODS.BY_SECTIONS: - return "/api/v1/general/split-pdf-by-sections"; - case SPLIT_METHODS.BY_SIZE: - case SPLIT_METHODS.BY_PAGE_COUNT: - case SPLIT_METHODS.BY_DOC_COUNT: - return "/api/v1/general/split-by-size-or-count"; - case SPLIT_METHODS.BY_CHAPTERS: - return "/api/v1/general/split-pdf-by-chapters"; - case SPLIT_METHODS.BY_PAGE_DIVIDER: - return "/api/v1/misc/auto-split-pdf"; - case SPLIT_METHODS.BY_POSTER: - return "/api/v1/general/split-for-poster-print"; - default: - throw new Error(`Unknown split method: ${parameters.method}`); + if ("horizontalDivisions" in apiParams || "verticalDivisions" in apiParams) { + return { + method: SPLIT_METHODS.BY_SECTIONS, + hDiv: + apiParams.horizontalDivisions !== undefined + ? `${apiParams.horizontalDivisions}` + : undefined, + vDiv: + apiParams.verticalDivisions !== undefined + ? `${apiParams.verticalDivisions}` + : undefined, + merge: apiParams.merge ?? false, + splitMode: apiParams.splitMode ?? "SPLIT_ALL", + customPages: + apiParams.splitMode === "CUSTOM" + ? apiParams.pageNumbers + : defaultParameters.customPages, + }; } + if ("bookmarkLevel" in apiParams) { + return { + method: SPLIT_METHODS.BY_CHAPTERS, + bookmarkLevel: + apiParams.bookmarkLevel !== undefined + ? `${apiParams.bookmarkLevel}` + : "", + includeMetadata: apiParams.includeMetadata ?? false, + allowDuplicates: apiParams.allowDuplicates ?? false, + }; + } + if ("splitType" in apiParams) { + const methodBySplitType = { + 0: SPLIT_METHODS.BY_SIZE, + 1: SPLIT_METHODS.BY_PAGE_COUNT, + 2: SPLIT_METHODS.BY_DOC_COUNT, + } as const; + return { + method: methodBySplitType[apiParams.splitType as 0 | 1 | 2], + splitValue: apiParams.splitValue ?? "", + }; + } + if ("duplexMode" in apiParams) { + return { + method: SPLIT_METHODS.BY_PAGE_DIVIDER, + duplexMode: apiParams.duplexMode ?? false, + }; + } + const pages = "pageNumbers" in apiParams ? apiParams.pageNumbers : undefined; + return { + method: SPLIT_METHODS.BY_PAGES, + pages: pages ?? defaultParameters.pages, + }; }; +// Static functions that can be used by both the hook and automation executor +export const buildSplitFormData = ( + parameters: SplitParameters, + file: File, +): FormData => + objectToFormData(splitToApiParams(parameters), { fileInput: file }); + +export const getSplitEndpoint = (parameters: SplitParameters): SplitEndpoint => + // Default to BY_PAGES when no method is selected yet. + SPLIT_ENDPOINTS[parameters.method ?? SPLIT_METHODS.BY_PAGES]; + // Static configuration object -export const splitOperationConfig = { - toolType: ToolType.singleFile, +export const splitOperationConfig = defineSingleFileTool({ buildFormData: buildSplitFormData, + toApiParams: splitToApiParams, + fromApiParams: splitFromApiParams, operationType: "split", endpoint: getSplitEndpoint, + // Full routing set: a stored step maps back to Split though its `method` selector is frontend-only. + endpoints: Array.from(new Set(Object.values(SPLIT_ENDPOINTS))), defaultParameters, -} as const; +}); export const useSplitOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/timestampPdf/useTimestampPdfOperation.ts b/frontend/editor/src/core/hooks/tools/timestampPdf/useTimestampPdfOperation.ts index b14f785574..0769f37952 100644 --- a/frontend/editor/src/core/hooks/tools/timestampPdf/useTimestampPdfOperation.ts +++ b/frontend/editor/src/core/hooks/tools/timestampPdf/useTimestampPdfOperation.ts @@ -1,34 +1,48 @@ import { useTranslation } from "react-i18next"; import { - ToolType, useToolOperation, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + objectToFormData, + type ToolApiParams, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import { TimestampPdfParameters, defaultParameters, } from "@app/hooks/tools/timestampPdf/useTimestampPdfParameters"; +const ENDPOINT = "/api/v1/security/timestamp-pdf" satisfies ToolEndpoint; +type TimestampPdfApiParams = ToolApiParams[typeof ENDPOINT]; + +export const timestampPdfToApiParams = ( + parameters: TimestampPdfParameters, +): TimestampPdfApiParams => ({ + tsaUrl: parameters.tsaUrl, +}); + +export const timestampPdfFromApiParams = ( + apiParams: TimestampPdfApiParams, +): Partial => ({ + tsaUrl: apiParams.tsaUrl, +}); + export const buildTimestampPdfFormData = ( parameters: TimestampPdfParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); +): FormData => + objectToFormData(timestampPdfToApiParams(parameters), { fileInput: file }); - formData.append("tsaUrl", parameters.tsaUrl); - - return formData; -}; - -export const timestampPdfOperationConfig = { - toolType: ToolType.singleFile, +export const timestampPdfOperationConfig = defineSingleFileTool({ buildFormData: buildTimestampPdfFormData, + toApiParams: timestampPdfToApiParams, + fromApiParams: timestampPdfFromApiParams, operationType: "timestampPdf", - endpoint: "/api/v1/security/timestamp-pdf", - multiFileEndpoint: false, + endpoint: ENDPOINT, defaultParameters, -} as const; +}); export const useTimestampPdfOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation.ts b/frontend/editor/src/core/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation.ts index 1e78d2d407..2212d284bd 100644 --- a/frontend/editor/src/core/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation.ts +++ b/frontend/editor/src/core/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation.ts @@ -1,32 +1,39 @@ import { useTranslation } from "react-i18next"; import { - ToolType, useToolOperation, + defineSingleFileTool, } from "@app/hooks/tools/shared/useToolOperation"; +import { + fileOnlyMapping, + objectToFormData, + type ToolEndpoint, +} from "@app/hooks/tools/shared/toolApiMapping"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; import { UnlockPdfFormsParameters, defaultParameters, } from "@app/hooks/tools/unlockPdfForms/useUnlockPdfFormsParameters"; +const ENDPOINT = "/api/v1/misc/unlock-pdf-forms" satisfies ToolEndpoint; + +// Unlock PDF forms takes only a file; there are no request parameters to map. +const { toApiParams, fromApiParams } = fileOnlyMapping(); + // Static function that can be used by both the hook and automation executor export const buildUnlockPdfFormsFormData = ( _parameters: UnlockPdfFormsParameters, file: File, -): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - return formData; -}; +): FormData => objectToFormData(toApiParams(), { fileInput: file }); // Static configuration object -export const unlockPdfFormsOperationConfig = { - toolType: ToolType.singleFile, +export const unlockPdfFormsOperationConfig = defineSingleFileTool({ buildFormData: buildUnlockPdfFormsFormData, + toApiParams, + fromApiParams, operationType: "unlockPDFForms", - endpoint: "/api/v1/misc/unlock-pdf-forms", + endpoint: ENDPOINT, defaultParameters, -} as const; +}); export const useUnlockPdfFormsOperation = () => { const { t } = useTranslation(); diff --git a/frontend/editor/src/core/hooks/usePolicyFileBadges.ts b/frontend/editor/src/core/hooks/usePolicyFileBadges.ts index e3297f9bd0..8013edf0b9 100644 --- a/frontend/editor/src/core/hooks/usePolicyFileBadges.ts +++ b/frontend/editor/src/core/hooks/usePolicyFileBadges.ts @@ -1,4 +1,4 @@ -import type { FileItemPolicyRef } from "@app/components/shared/FileSidebarFileItem"; +import type { FileItemPolicyRef } from "@app/components/shared/PolicyBadges"; /** * Policies that have run on each file, keyed by fileId — drives the shield diff --git a/frontend/editor/src/core/hooks/usePosthogTracking.test.tsx b/frontend/editor/src/core/hooks/usePosthogTracking.test.tsx new file mode 100644 index 0000000000..41f5fad7dc --- /dev/null +++ b/frontend/editor/src/core/hooks/usePosthogTracking.test.tsx @@ -0,0 +1,76 @@ +import { ReactNode } from "react"; +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; + +const posthogState = vi.hoisted(() => ({ loaded: false })); +const posthogMock = vi.hoisted(() => ({ + get __loaded() { + return posthogState.loaded; + }, + init: vi.fn(() => { + posthogState.loaded = true; + }), + opt_out_capturing: vi.fn(), + opt_in_capturing: vi.fn(), + set_config: vi.fn(), + has_opted_in_capturing: vi.fn(() => false), +})); + +vi.mock("posthog-js", () => ({ + default: posthogMock, +})); + +import { usePosthogTracking } from "@app/hooks/usePosthogTracking"; + +describe("usePosthogTracking", () => { + beforeEach(() => { + posthogState.loaded = false; + posthogMock.init.mockClear(); + posthogMock.opt_out_capturing.mockClear(); + posthogMock.opt_in_capturing.mockClear(); + posthogMock.set_config.mockClear(); + vi.stubEnv("VITE_PUBLIC_POSTHOG_KEY", "test-key"); + vi.stubEnv("VITE_PUBLIC_POSTHOG_HOST", "https://eu.i.posthog.com"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("does not initialize PostHog when analytics is disabled", async () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + renderHook(() => usePosthogTracking(), { wrapper }); + + await waitFor(() => { + expect(posthogMock.init).not.toHaveBeenCalled(); + }); + }); + + it("initializes PostHog when analytics is enabled", async () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + renderHook(() => usePosthogTracking(), { wrapper }); + + await waitFor(() => { + expect(posthogMock.init).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/frontend/editor/src/core/hooks/usePosthogTracking.ts b/frontend/editor/src/core/hooks/usePosthogTracking.ts new file mode 100644 index 0000000000..a8037ec4a7 --- /dev/null +++ b/frontend/editor/src/core/hooks/usePosthogTracking.ts @@ -0,0 +1,83 @@ +import { useEffect } from "react"; +import posthog from "posthog-js"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; + +function applyPosthogConsent(): void { + if (typeof window === "undefined" || !posthog.__loaded) { + return; + } + + const optedIn = + window.CookieConsent?.acceptedService?.("posthog", "analytics") || false; + + if (optedIn) { + posthog.set_config({ persistence: "localStorage+cookie" }); + posthog.opt_in_capturing(); + return; + } + + posthog.opt_out_capturing(); + posthog.set_config({ persistence: "memory" }); +} + +function ensurePosthogInitialized(): boolean { + if (typeof window === "undefined") { + return false; + } + + const posthogKey = import.meta.env.VITE_PUBLIC_POSTHOG_KEY; + const posthogHost = import.meta.env.VITE_PUBLIC_POSTHOG_HOST; + + if (!posthogKey || !posthogHost) { + return false; + } + + if (!posthog.__loaded) { + posthog.init(posthogKey, { + api_host: posthogHost, + defaults: "2025-05-24", + capture_exceptions: true, + debug: false, + opt_out_capturing_by_default: true, + persistence: "memory", + cross_subdomain_cookie: false, + }); + } + + return true; +} + +export function usePosthogTracking(): void { + const { config } = useAppConfig(); + + useEffect(() => { + const analyticsEnabled = config?.enableAnalytics === true; + const posthogEnabled = analyticsEnabled && config?.enablePosthog !== false; + + if (!posthogEnabled) { + if (posthog.__loaded) { + posthog.opt_out_capturing(); + posthog.set_config({ persistence: "memory" }); + } + return; + } + + if (!ensurePosthogInitialized()) { + return; + } + + applyPosthogConsent(); + + const handleConsentChange = () => { + applyPosthogConsent(); + }; + + window.addEventListener("cc:onConsent", handleConsentChange); + window.addEventListener("cc:onChange", handleConsentChange); + + return () => { + window.removeEventListener("cc:onConsent", handleConsentChange); + window.removeEventListener("cc:onChange", handleConsentChange); + }; + }, [config?.enableAnalytics, config?.enablePosthog]); +} diff --git a/frontend/editor/src/core/pages/HomePage.tsx b/frontend/editor/src/core/pages/HomePage.tsx index 2e5d2aa08d..c1b4f572bc 100644 --- a/frontend/editor/src/core/pages/HomePage.tsx +++ b/frontend/editor/src/core/pages/HomePage.tsx @@ -38,6 +38,7 @@ import { useFileHandler } from "@app/hooks/useFileHandler"; import { FolderTreePanel } from "@app/components/filesPage/FolderTreePanel"; import type { FileSidebarProps } from "@app/components/shared/FileSidebar"; +import { Button } from "@app/ui/Button"; import "@app/pages/HomePage.css"; const SIDEBAR_COLLAPSED_STORAGE_KEY = "stirling.fileSidebarCollapsed"; @@ -404,7 +405,8 @@ export default function HomePage() {
    )}
    - + {toolAvailability["automate"]?.available !== false && ( - + )} - - +
    {/* Back button - floating top left */} - - + {/* Video feed - fills available space */} {/* Capture button */} - + @@ -1291,15 +1289,14 @@ export default function MobileScannerPage() { align="center" style={{ maxWidth: "500px", margin: "0 auto" }} > - - + - + @@ -1383,23 +1379,22 @@ export default function MobileScannerPage() { > - - + - + @@ -1413,17 +1408,22 @@ export default function MobileScannerPage() { ) - - +
    ({ seedJwt: [false, { option: true }], page: async ({ page, stubOptions, autoGoto, seedJwt }, use) => { + suppressNativeFilePicker(page); await seedCookieConsent(page); if (seedJwt) { // Logged-in users hit the orchestrator path that surfaces the diff --git a/frontend/editor/src/core/tests/helpers/test-base.ts b/frontend/editor/src/core/tests/helpers/test-base.ts index 1659bc3abd..90de9432f9 100644 --- a/frontend/editor/src/core/tests/helpers/test-base.ts +++ b/frontend/editor/src/core/tests/helpers/test-base.ts @@ -1,6 +1,7 @@ import { test as base, expect } from "@playwright/test"; import * as fs from "node:fs/promises"; import * as path from "node:path"; +import { suppressNativeFilePicker } from "@app/tests/helpers/ui-helpers"; /** * Custom test fixture that: @@ -40,6 +41,7 @@ const COVERAGE_DIR = path.resolve( export const test = base.extend({ page: async ({ page }, use, testInfo) => { + suppressNativeFilePicker(page); await page.context().addCookies([ { name: "cc_cookie", diff --git a/frontend/editor/src/core/tests/helpers/ui-helpers.ts b/frontend/editor/src/core/tests/helpers/ui-helpers.ts index 7c08f93f60..353037c106 100644 --- a/frontend/editor/src/core/tests/helpers/ui-helpers.ts +++ b/frontend/editor/src/core/tests/helpers/ui-helpers.ts @@ -11,6 +11,37 @@ import { expect, type Page, type Locator } from "@playwright/test"; const MANTINE_MODAL_OVERLAY = ".mantine-Modal-overlay"; +/** + * Suppress the native OS file picker for the whole page, on every browser. + * + * Several upload entry points (the FileSidebar "Open from computer" button, + * the Mantine ``, AddFileCard, etc.) open a file dialog by clicking + * a hidden ``. On firefox/webkit Playwright only intercepts + * that dialog while the page has a `filechooser` listener - it toggles + * `Page.setInterceptFileChooserDialog` off the event subscription. With no + * listener the real OS picker leaks onto the host and hangs the nightly run. + * + * Registering a (no-op) `filechooser` listener flips that interception on for + * every browser, so the dialog is suppressed at the browser level however it + * was triggered - a programmatic `.click()`, a `