Compare commits
38 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0ff4404b3 | ||
|
|
b2dbb39c62 | ||
|
|
9399fce86d | ||
|
|
7460b065e5 | ||
|
|
e25c2637ec | ||
|
|
6aed350a3c | ||
|
|
71a7aba337 | ||
|
|
2ec89a7c52 | ||
|
|
7d2ba4b87f | ||
|
|
73b9cd1692 | ||
|
|
39dc609f95 | ||
|
|
948e9bdd4d | ||
|
|
eda088e143 | ||
|
|
8a34a6457f | ||
|
|
36cddb2566 | ||
|
|
0843e2909a | ||
|
|
2053ca15c8 | ||
|
|
0740f656e6 | ||
|
|
45a4f7508c | ||
|
|
a29e67deac | ||
|
|
c139acc3c6 | ||
|
|
15b7b4c925 | ||
|
|
b20109ac6c | ||
|
|
4039b94980 | ||
|
|
dafac6b0a4 | ||
|
|
fa21ac8fd7 | ||
|
|
0ed5050b21 | ||
|
|
9eb63ef3fd | ||
|
|
81590ea137 | ||
|
|
f5abbac42c | ||
|
|
1d61634a37 | ||
|
|
f0b90ab3b4 | ||
|
|
cfdcda6d2c | ||
|
|
e32daad5a4 | ||
|
|
82dcaefc43 | ||
|
|
a5fe6d6e04 | ||
|
|
93f921be90 | ||
|
|
f656e14ef7 |
8
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Lockfiles are generated files. Git's line-level text merge can combine
|
||||||
|
# both sides' insertions into syntactically invalid JSON *without raising a
|
||||||
|
# conflict* (this silently broke backend/package.json + package-lock.json in
|
||||||
|
# PR #233). Merge them as binary so any concurrent change surfaces as an
|
||||||
|
# explicit conflict; resolve by taking main's copy and regenerating:
|
||||||
|
# git checkout origin/main -- package-lock.json && npm install
|
||||||
|
package-lock.json merge=binary
|
||||||
|
bun.lock merge=binary
|
||||||
30
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
<!-- What does this PR do, in one or two sentences? -->
|
||||||
|
|
||||||
|
## Why / Motivation
|
||||||
|
|
||||||
|
<!-- What problem does this solve? Why now, and why this approach?
|
||||||
|
Link the issue or context that prompted it. -->
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
<!-- The notable changes, at a high level. Describe the outcome, not the diff. -->
|
||||||
|
|
||||||
|
## Tradeoffs & risks
|
||||||
|
|
||||||
|
<!-- What did you weigh? What could break, what's out of scope, and what
|
||||||
|
follow-ups (if any) does this leave behind? Note any security or
|
||||||
|
migration implications. -->
|
||||||
|
|
||||||
|
## How verified
|
||||||
|
|
||||||
|
<!-- How do you know this works? Tests added/run, manual steps, commands,
|
||||||
|
screenshots. Include the exact commands where relevant. -->
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] Ran the relevant build/test command for the area changed.
|
||||||
|
- [ ] Reviewed `git diff` and removed unrelated changes.
|
||||||
|
- [ ] Updated docs / env examples if setup, config, or behavior changed.
|
||||||
|
- [ ] No secrets, API keys, real documents, or `.env` files committed.
|
||||||
112
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
# CI: build and test.
|
||||||
|
#
|
||||||
|
# Adapted from the amal66/mike fork's .github/workflows/ci.yml (monorepo
|
||||||
|
# layout) to this repository's backend/ + frontend/ layout. Test steps use
|
||||||
|
# `npm test --if-present`, and the eval job checks for evals/run.mjs, so this
|
||||||
|
# workflow is safe to merge before or after the test-harness and evals PRs:
|
||||||
|
# on a tree without those pieces the test steps no-op and the build still
|
||||||
|
# gates the merge. Beyond the fork version this also builds the frontend
|
||||||
|
# (placeholder NEXT_PUBLIC_* env — verified sufficient for `next build`) and
|
||||||
|
# runs eslint as a blocking gate (the error backlog is at zero).
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend:
|
||||||
|
name: Backend build and tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: backend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: backend/package-lock.json
|
||||||
|
|
||||||
|
# Git's line-level merge can splice both sides of package(-lock).json
|
||||||
|
# into invalid JSON without a conflict (bit PR #233). npm's own error
|
||||||
|
# for an unparseable lockfile is the misleading "npm ci can only
|
||||||
|
# install with an existing package-lock.json" — fail fast with the real
|
||||||
|
# reason instead.
|
||||||
|
- name: Validate package.json and lockfile parse
|
||||||
|
run: node -e "for (const f of ['package.json','package-lock.json']) JSON.parse(require('fs').readFileSync(f, 'utf8'))"
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
|
||||||
|
# No-ops on a tree without a "test" script (e.g. before the vitest
|
||||||
|
# harness PR merges); runs the suite once it exists.
|
||||||
|
- run: npm test --if-present
|
||||||
|
|
||||||
|
- run: npm run build
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
name: Frontend build and tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
# Same silent-merge-corruption guard as the backend job.
|
||||||
|
- name: Validate package.json and lockfile parse
|
||||||
|
run: node -e "for (const f of ['package.json','package-lock.json']) JSON.parse(require('fs').readFileSync(f, 'utf8'))"
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
|
||||||
|
# No-ops on a tree without a "test" script (e.g. before the vitest
|
||||||
|
# harness PR merges); runs the suite once it exists.
|
||||||
|
- run: npm test --if-present
|
||||||
|
|
||||||
|
# Blocking gate: the eslint error backlog was burned down in this PR
|
||||||
|
# (0 errors; warnings do not fail the step), so any new error fails CI.
|
||||||
|
- run: npm run lint
|
||||||
|
|
||||||
|
# Production build. NEXT_PUBLIC_* values are inlined at build time and
|
||||||
|
# only need to be well-formed here — nothing is contacted during build.
|
||||||
|
# This catches type errors (next build runs tsc) and broken routes/imports.
|
||||||
|
- run: npm run build
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL: https://placeholder.supabase.co
|
||||||
|
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY: sb_publishable_placeholder
|
||||||
|
NEXT_PUBLIC_API_BASE_URL: http://localhost:3001
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Offline eval harness: deterministic scorecard for citation accuracy,
|
||||||
|
# prompt-injection resistance, and privilege/PII leakage. Runs against
|
||||||
|
# committed fixtures (no network, no LLM calls, no secrets), so it is cheap
|
||||||
|
# enough to gate every PR. --threshold 1.0 = every case must pass.
|
||||||
|
# Skips gracefully until the evals PR merges.
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
evals:
|
||||||
|
name: Eval harness
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Run eval harness (skips if evals/ not present)
|
||||||
|
run: |
|
||||||
|
if [ -f evals/run.mjs ]; then
|
||||||
|
node evals/run.mjs --threshold 1.0
|
||||||
|
else
|
||||||
|
echo "evals/run.mjs not present on this tree; skipping"
|
||||||
|
fi
|
||||||
|
|
@ -20,9 +20,24 @@ Thanks for helping improve Mike. Please keep contributions small, focused, and e
|
||||||
- why
|
- why
|
||||||
- testing
|
- testing
|
||||||
|
|
||||||
|
## System Workflows
|
||||||
|
|
||||||
|
System workflows live in the sibling
|
||||||
|
[`Open-Legal-Products/mike-workflows`](https://github.com/Open-Legal-Products/mike-workflows)
|
||||||
|
repository under `assistant-workflows/` and `tabular-review-workflows/`. Put
|
||||||
|
structured metadata in the YAML frontmatter at the top of `SKILL.md`, set
|
||||||
|
`metadata.mike-availability` to `system`, put workflow instructions in the body
|
||||||
|
of `SKILL.md`, and use `table-columns.yaml` for tabular review columns.
|
||||||
|
|
||||||
|
After changing system workflows, regenerate the app files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/build-workflows.js
|
||||||
|
```
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
Do not open a public issue for security vulnerabilities. Use [GitHub's private vulnerability reporting](https://github.com/willchen96/mike/security/advisories/new) instead.
|
Do not open a public issue for security vulnerabilities. Use [GitHub's private vulnerability reporting](https://github.com/Open-Legal-Products/mike/security/advisories/new) instead.
|
||||||
|
|
||||||
We will aim to respond promptly and coordinate a disclosure timeline with you.
|
We will aim to respond promptly and coordinate a disclosure timeline with you.
|
||||||
|
|
||||||
|
|
@ -39,3 +54,22 @@ Frontend:
|
||||||
```bash
|
```bash
|
||||||
npm run build --prefix frontend
|
npm run build --prefix frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test --prefix backend # backend unit + route integration tests (vitest)
|
||||||
|
npm test --prefix frontend # frontend component/hook tests (vitest + jsdom)
|
||||||
|
npm run test:e2e # Playwright end-to-end suite — see docs/e2e-ci.md
|
||||||
|
node evals/run.mjs --threshold 1.0 # offline eval harness (no network, no API keys)
|
||||||
|
npm run test:stack --prefix backend # gated: real-Supabase auth/access tests (run `supabase start` first)
|
||||||
|
```
|
||||||
|
|
||||||
|
- New features and bug fixes should come with a test at the lowest layer that
|
||||||
|
can catch the regression: unit first, then route-level integration, then
|
||||||
|
end-to-end only for flows a browser is genuinely needed to prove.
|
||||||
|
- CI runs the build, unit/integration tests, and the eval harness on every PR
|
||||||
|
(`.github/workflows/ci.yml`), and the Playwright suite in a full local stack
|
||||||
|
(`.github/workflows/e2e.yml`).
|
||||||
|
- Tests that need a live Supabase or an LLM key are env-gated and skip cleanly
|
||||||
|
when the environment is absent — a plain `npm test` should always be green.
|
||||||
|
|
|
||||||
12
README.md
|
|
@ -1,6 +1,10 @@
|
||||||
# Mike
|
# Mike
|
||||||
|
|
||||||
Mike is a legal document assistant with a Next.js frontend, an Express backend, Supabase Auth/Postgres, and Cloudflare R2-compatible object storage.
|

|
||||||
|
|
||||||
|
Mike or MIkeOSS is a legal AI platform that is able to assist you with document review, drafting and legal research.
|
||||||
|
|
||||||
|
It has a Next.js frontend, an Express backend, Supabase Auth/Postgres, and Cloudflare R2-compatible object storage.
|
||||||
|
|
||||||
Website: [mikeoss.com](https://mikeoss.com)
|
Website: [mikeoss.com](https://mikeoss.com)
|
||||||
|
|
||||||
|
|
@ -11,6 +15,12 @@ Website: [mikeoss.com](https://mikeoss.com)
|
||||||
- `backend/schema.sql` - Supabase schema for fresh databases
|
- `backend/schema.sql` - Supabase schema for fresh databases
|
||||||
- `backend/migrations/` - dated, incremental schema migrations; on an existing database, apply the files dated after the Mike version you deployed
|
- `backend/migrations/` - dated, incremental schema migrations; on an existing database, apply the files dated after the Mike version you deployed
|
||||||
|
|
||||||
|
## System Workflows
|
||||||
|
|
||||||
|
Mike's system assistant and tabular review workflows are maintained in the
|
||||||
|
[`Open-Legal-Products/mike-workflows`](https://github.com/Open-Legal-Products/mike-workflows)
|
||||||
|
repository.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Node.js 20 or newer
|
- Node.js 20 or newer
|
||||||
|
|
|
||||||
123
backend/bun.lock
|
|
@ -9,6 +9,7 @@
|
||||||
"@aws-sdk/client-s3": "^3.787.0",
|
"@aws-sdk/client-s3": "^3.787.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.787.0",
|
"@aws-sdk/s3-request-presigner": "^3.787.0",
|
||||||
"@google/genai": "^1.50.1",
|
"@google/genai": "^1.50.1",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"@supabase/supabase-js": "^2.49.4",
|
"@supabase/supabase-js": "^2.49.4",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"docx": "^9.5.0",
|
"docx": "^9.5.0",
|
||||||
|
|
@ -24,6 +25,8 @@
|
||||||
"multer": "^1.4.5-lts.2",
|
"multer": "^1.4.5-lts.2",
|
||||||
"pdfjs-dist": "^4.10.38",
|
"pdfjs-dist": "^4.10.38",
|
||||||
"resend": "^4.5.1",
|
"resend": "^4.5.1",
|
||||||
|
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||||
|
"zod": "^3.25.76",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
|
|
@ -179,6 +182,10 @@
|
||||||
|
|
||||||
"@google/genai": ["@google/genai@1.50.1", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-YbkX7H9+1Pt8wOt7DDREy8XSoiL6fRDzZQRyaVBarFf8MR3zHGqVdvM4cLbDXqPhxqvegZShgfxb8kw9C7YhAQ=="],
|
"@google/genai": ["@google/genai@1.50.1", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-YbkX7H9+1Pt8wOt7DDREy8XSoiL6fRDzZQRyaVBarFf8MR3zHGqVdvM4cLbDXqPhxqvegZShgfxb8kw9C7YhAQ=="],
|
||||||
|
|
||||||
|
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||||
|
|
||||||
"@napi-rs/canvas": ["@napi-rs/canvas@0.1.97", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.97", "@napi-rs/canvas-darwin-arm64": "0.1.97", "@napi-rs/canvas-darwin-x64": "0.1.97", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97", "@napi-rs/canvas-linux-arm64-gnu": "0.1.97", "@napi-rs/canvas-linux-arm64-musl": "0.1.97", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.97", "@napi-rs/canvas-linux-x64-gnu": "0.1.97", "@napi-rs/canvas-linux-x64-musl": "0.1.97", "@napi-rs/canvas-win32-arm64-msvc": "0.1.97", "@napi-rs/canvas-win32-x64-msvc": "0.1.97" } }, "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ=="],
|
"@napi-rs/canvas": ["@napi-rs/canvas@0.1.97", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.97", "@napi-rs/canvas-darwin-arm64": "0.1.97", "@napi-rs/canvas-darwin-x64": "0.1.97", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97", "@napi-rs/canvas-linux-arm64-gnu": "0.1.97", "@napi-rs/canvas-linux-arm64-musl": "0.1.97", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.97", "@napi-rs/canvas-linux-x64-gnu": "0.1.97", "@napi-rs/canvas-linux-x64-musl": "0.1.97", "@napi-rs/canvas-win32-arm64-msvc": "0.1.97", "@napi-rs/canvas-win32-x64-msvc": "0.1.97" } }, "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ=="],
|
||||||
|
|
||||||
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.97", "", { "os": "android", "cpu": "arm64" }, "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ=="],
|
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.97", "", { "os": "android", "cpu": "arm64" }, "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ=="],
|
||||||
|
|
@ -379,6 +386,10 @@
|
||||||
|
|
||||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||||
|
|
||||||
|
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||||
|
|
||||||
|
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||||
|
|
||||||
"append-field": ["append-field@1.0.0", "", {}, "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="],
|
"append-field": ["append-field@1.0.0", "", {}, "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="],
|
||||||
|
|
||||||
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||||
|
|
@ -423,6 +434,8 @@
|
||||||
|
|
||||||
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||||
|
|
||||||
|
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||||
|
|
||||||
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
||||||
|
|
||||||
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||||
|
|
@ -471,16 +484,22 @@
|
||||||
|
|
||||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||||
|
|
||||||
|
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
||||||
|
|
||||||
|
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
|
||||||
|
|
||||||
"express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="],
|
"express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="],
|
||||||
|
|
||||||
"express-rate-limit": ["express-rate-limit@8.5.1", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ=="],
|
"express-rate-limit": ["express-rate-limit@8.5.1", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ=="],
|
||||||
|
|
||||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||||
|
|
||||||
"fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="],
|
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||||
|
|
||||||
"fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="],
|
"fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="],
|
||||||
|
|
||||||
|
"fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
|
||||||
|
|
||||||
"fast-xml-builder": ["fast-xml-builder@1.1.5", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA=="],
|
"fast-xml-builder": ["fast-xml-builder@1.1.5", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA=="],
|
||||||
|
|
||||||
"fast-xml-parser": ["fast-xml-parser@5.7.2", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.5", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w=="],
|
"fast-xml-parser": ["fast-xml-parser@5.7.2", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.5", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w=="],
|
||||||
|
|
@ -523,6 +542,8 @@
|
||||||
|
|
||||||
"helmet": ["helmet@8.1.0", "", {}, "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg=="],
|
"helmet": ["helmet@8.1.0", "", {}, "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg=="],
|
||||||
|
|
||||||
|
"hono": ["hono@4.12.25", "", {}, "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ=="],
|
||||||
|
|
||||||
"html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="],
|
"html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="],
|
||||||
|
|
||||||
"htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="],
|
"htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="],
|
||||||
|
|
@ -533,7 +554,7 @@
|
||||||
|
|
||||||
"iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="],
|
"iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="],
|
||||||
|
|
||||||
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||||
|
|
||||||
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
|
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
|
||||||
|
|
||||||
|
|
@ -543,12 +564,22 @@
|
||||||
|
|
||||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||||
|
|
||||||
|
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||||
|
|
||||||
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||||
|
|
||||||
|
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|
||||||
|
"jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
|
||||||
|
|
||||||
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
|
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
|
||||||
|
|
||||||
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
|
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
|
||||||
|
|
||||||
|
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||||
|
|
||||||
|
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||||
|
|
||||||
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
|
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
|
||||||
|
|
||||||
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
||||||
|
|
@ -577,9 +608,9 @@
|
||||||
|
|
||||||
"mime": ["mime@1.6.0", "", { "bin": "cli.js" }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
|
"mime": ["mime@1.6.0", "", { "bin": "cli.js" }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
|
||||||
|
|
||||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||||
|
|
||||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||||
|
|
||||||
"minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
|
"minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
|
||||||
|
|
||||||
|
|
@ -605,6 +636,8 @@
|
||||||
|
|
||||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||||
|
|
||||||
|
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||||
|
|
||||||
"option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="],
|
"option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="],
|
||||||
|
|
||||||
"p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
|
"p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
|
||||||
|
|
@ -619,12 +652,16 @@
|
||||||
|
|
||||||
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
|
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
|
||||||
|
|
||||||
|
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||||
|
|
||||||
"path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="],
|
"path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="],
|
||||||
|
|
||||||
"pdfjs-dist": ["pdfjs-dist@4.10.38", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.65" } }, "sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ=="],
|
"pdfjs-dist": ["pdfjs-dist@4.10.38", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.65" } }, "sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ=="],
|
||||||
|
|
||||||
"peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="],
|
"peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="],
|
||||||
|
|
||||||
|
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||||
|
|
||||||
"prettier": ["prettier@3.8.1", "", { "bin": "bin/prettier.cjs" }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
|
"prettier": ["prettier@3.8.1", "", { "bin": "bin/prettier.cjs" }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
|
||||||
|
|
||||||
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
||||||
|
|
@ -637,7 +674,7 @@
|
||||||
|
|
||||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||||
|
|
||||||
"raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
|
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||||
|
|
||||||
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||||
|
|
||||||
|
|
@ -647,12 +684,16 @@
|
||||||
|
|
||||||
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||||
|
|
||||||
|
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||||
|
|
||||||
"resend": ["resend@4.8.0", "", { "dependencies": { "@react-email/render": "1.1.2" } }, "sha512-R8eBOFQDO6dzRTDmaMEdpqrkmgSjPpVXt4nGfWsZdYOet0kqra0xgbvTES6HmCriZEXbmGk3e0DiGIaLFTFSHA=="],
|
"resend": ["resend@4.8.0", "", { "dependencies": { "@react-email/render": "1.1.2" } }, "sha512-R8eBOFQDO6dzRTDmaMEdpqrkmgSjPpVXt4nGfWsZdYOet0kqra0xgbvTES6HmCriZEXbmGk3e0DiGIaLFTFSHA=="],
|
||||||
|
|
||||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||||
|
|
||||||
"retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
|
"retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
|
||||||
|
|
||||||
|
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||||
|
|
||||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||||
|
|
||||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||||
|
|
@ -671,6 +712,10 @@
|
||||||
|
|
||||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||||
|
|
||||||
|
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||||
|
|
||||||
|
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||||
|
|
||||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||||
|
|
||||||
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
|
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
|
||||||
|
|
@ -719,8 +764,14 @@
|
||||||
|
|
||||||
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||||
|
|
||||||
|
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
|
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||||
|
|
||||||
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
|
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
|
||||||
|
|
||||||
|
"xlsx": ["xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", { "bin": { "xlsx": "./bin/xlsx.njs" } }],
|
||||||
|
|
||||||
"xml": ["xml@1.0.1", "", {}, "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="],
|
"xml": ["xml@1.0.1", "", {}, "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="],
|
||||||
|
|
||||||
"xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": "bin/cli.js" }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="],
|
"xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": "bin/cli.js" }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="],
|
||||||
|
|
@ -729,6 +780,10 @@
|
||||||
|
|
||||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||||
|
|
||||||
|
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||||
|
|
||||||
|
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||||
|
|
||||||
"@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
"@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||||
|
|
||||||
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||||
|
|
@ -737,20 +792,36 @@
|
||||||
|
|
||||||
"@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
|
"@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||||
|
|
||||||
"@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="],
|
"@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="],
|
||||||
|
|
||||||
|
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||||
|
|
||||||
|
"body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||||
|
|
||||||
|
"body-parser/raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
|
||||||
|
|
||||||
"docx/@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
"docx/@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
||||||
|
|
||||||
"https-proxy-agent/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
"https-proxy-agent/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
"protobufjs/@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
"protobufjs/@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
||||||
|
|
||||||
|
"react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="],
|
||||||
|
|
||||||
"readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
"readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||||
|
|
||||||
|
"router/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||||
|
|
||||||
"send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
"string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
"string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||||
|
|
||||||
|
"type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||||
|
|
||||||
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||||
|
|
||||||
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||||
|
|
@ -761,16 +832,58 @@
|
||||||
|
|
||||||
"@aws-sdk/xml-builder/fast-xml-parser/path-expression-matcher": ["path-expression-matcher@1.4.0", "", {}, "sha512-s4DQMxIdhj3jLFWd9LxHOplj4p9yQ4ffMGowFf3cpEgrrJjEhN0V5nxw4Ye1EViAGDoL4/1AeO6qHpqYPOzE4Q=="],
|
"@aws-sdk/xml-builder/fast-xml-parser/path-expression-matcher": ["path-expression-matcher@1.4.0", "", {}, "sha512-s4DQMxIdhj3jLFWd9LxHOplj4p9yQ4ffMGowFf3cpEgrrJjEhN0V5nxw4Ye1EViAGDoL4/1AeO6qHpqYPOzE4Q=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
||||||
|
|
||||||
|
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||||
|
|
||||||
"docx/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
"docx/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
|
||||||
"https-proxy-agent/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"https-proxy-agent/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
"protobufjs/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
"protobufjs/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
|
||||||
|
"router/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||||
|
|
||||||
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||||
|
|
||||||
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||||
|
|
||||||
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/body-parser/qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||||
|
|
||||||
|
"@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,19 +27,34 @@ stable
|
||||||
as $$
|
as $$
|
||||||
with owned as (
|
with owned as (
|
||||||
select
|
select
|
||||||
w.*,
|
w.id,
|
||||||
|
w.user_id::text as user_id,
|
||||||
|
w.title,
|
||||||
|
w.type,
|
||||||
|
w.prompt_md,
|
||||||
|
w.columns_config,
|
||||||
|
w.practice,
|
||||||
|
false as is_system,
|
||||||
|
w.created_at,
|
||||||
true as allow_edit,
|
true as allow_edit,
|
||||||
true as is_owner,
|
true as is_owner,
|
||||||
null::text as shared_by_name,
|
null::text as shared_by_name,
|
||||||
0 as sort_bucket
|
0 as sort_bucket
|
||||||
from public.workflows w
|
from public.workflows w
|
||||||
where w.user_id = p_user_id
|
where w.user_id::text = p_user_id
|
||||||
and w.is_system = false
|
|
||||||
and (p_type is null or w.type = p_type)
|
and (p_type is null or w.type = p_type)
|
||||||
),
|
),
|
||||||
shared as (
|
shared as (
|
||||||
select
|
select
|
||||||
w.*,
|
w.id,
|
||||||
|
w.user_id::text as user_id,
|
||||||
|
w.title,
|
||||||
|
w.type,
|
||||||
|
w.prompt_md,
|
||||||
|
w.columns_config,
|
||||||
|
w.practice,
|
||||||
|
false as is_system,
|
||||||
|
w.created_at,
|
||||||
ws.allow_edit,
|
ws.allow_edit,
|
||||||
false as is_owner,
|
false as is_owner,
|
||||||
nullif(trim(up.display_name), '') as shared_by_name,
|
nullif(trim(up.display_name), '') as shared_by_name,
|
||||||
|
|
@ -48,7 +63,7 @@ as $$
|
||||||
join public.workflows w
|
join public.workflows w
|
||||||
on w.id = ws.workflow_id
|
on w.id = ws.workflow_id
|
||||||
left join public.user_profiles up
|
left join public.user_profiles up
|
||||||
on up.user_id::text = ws.shared_by_user_id
|
on up.user_id::text = ws.shared_by_user_id::text
|
||||||
where lower(ws.shared_with_email) = lower(coalesce(p_user_email, ''))
|
where lower(ws.shared_with_email) = lower(coalesce(p_user_email, ''))
|
||||||
and (p_type is null or w.type = p_type)
|
and (p_type is null or w.type = p_type)
|
||||||
),
|
),
|
||||||
|
|
|
||||||
120
backend/migrations/20260625_01_workflow_metadata.sql
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
-- Migration date: 2026-06-25
|
||||||
|
|
||||||
|
-- Custom workflow metadata fields and workflow overview read model. System
|
||||||
|
-- workflow versions remain generated from the repository metadata and are not
|
||||||
|
-- stored on user workflow rows.
|
||||||
|
|
||||||
|
drop function if exists public.get_workflows_overview(text, text, text);
|
||||||
|
|
||||||
|
alter table public.workflows
|
||||||
|
drop column if exists author,
|
||||||
|
drop column if exists category,
|
||||||
|
drop column if exists is_system,
|
||||||
|
add column if not exists language text default 'English',
|
||||||
|
add column if not exists jurisdictions text[] default array['General']::text[];
|
||||||
|
|
||||||
|
alter table public.workflows
|
||||||
|
alter column language set default 'English',
|
||||||
|
alter column practice set default 'General Transactions',
|
||||||
|
alter column jurisdictions set default array['General']::text[];
|
||||||
|
|
||||||
|
update public.workflows
|
||||||
|
set
|
||||||
|
language = coalesce(nullif(trim(language), ''), 'English'),
|
||||||
|
practice = coalesce(nullif(trim(practice), ''), 'General Transactions'),
|
||||||
|
jurisdictions = coalesce(jurisdictions, array['General']::text[])
|
||||||
|
where user_id is not null;
|
||||||
|
|
||||||
|
create or replace function public.get_workflows_overview(
|
||||||
|
p_user_id text,
|
||||||
|
p_user_email text default null,
|
||||||
|
p_type text default null
|
||||||
|
)
|
||||||
|
returns table (
|
||||||
|
id uuid,
|
||||||
|
user_id text,
|
||||||
|
title text,
|
||||||
|
type text,
|
||||||
|
prompt_md text,
|
||||||
|
columns_config jsonb,
|
||||||
|
language text,
|
||||||
|
practice text,
|
||||||
|
jurisdictions text[],
|
||||||
|
is_system boolean,
|
||||||
|
created_at timestamptz,
|
||||||
|
allow_edit boolean,
|
||||||
|
is_owner boolean,
|
||||||
|
shared_by_name text
|
||||||
|
)
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $$
|
||||||
|
with owned as (
|
||||||
|
select
|
||||||
|
w.id,
|
||||||
|
w.user_id::text as user_id,
|
||||||
|
w.title,
|
||||||
|
w.type,
|
||||||
|
w.prompt_md,
|
||||||
|
w.columns_config,
|
||||||
|
w.language,
|
||||||
|
w.practice,
|
||||||
|
w.jurisdictions,
|
||||||
|
false as is_system,
|
||||||
|
w.created_at,
|
||||||
|
true as allow_edit,
|
||||||
|
true as is_owner,
|
||||||
|
null::text as shared_by_name,
|
||||||
|
0 as sort_bucket
|
||||||
|
from public.workflows w
|
||||||
|
where w.user_id::text = p_user_id
|
||||||
|
and (p_type is null or w.type = p_type)
|
||||||
|
),
|
||||||
|
shared as (
|
||||||
|
select
|
||||||
|
w.id,
|
||||||
|
w.user_id::text as user_id,
|
||||||
|
w.title,
|
||||||
|
w.type,
|
||||||
|
w.prompt_md,
|
||||||
|
w.columns_config,
|
||||||
|
w.language,
|
||||||
|
w.practice,
|
||||||
|
w.jurisdictions,
|
||||||
|
false as is_system,
|
||||||
|
w.created_at,
|
||||||
|
ws.allow_edit,
|
||||||
|
false as is_owner,
|
||||||
|
nullif(trim(up.display_name), '') as shared_by_name,
|
||||||
|
1 as sort_bucket
|
||||||
|
from public.workflow_shares ws
|
||||||
|
join public.workflows w
|
||||||
|
on w.id = ws.workflow_id
|
||||||
|
left join public.user_profiles up
|
||||||
|
on up.user_id::text = ws.shared_by_user_id::text
|
||||||
|
where lower(ws.shared_with_email) = lower(coalesce(p_user_email, ''))
|
||||||
|
and (p_type is null or w.type = p_type)
|
||||||
|
),
|
||||||
|
visible_workflows as (
|
||||||
|
select * from owned
|
||||||
|
union all
|
||||||
|
select * from shared
|
||||||
|
)
|
||||||
|
select
|
||||||
|
vw.id,
|
||||||
|
vw.user_id,
|
||||||
|
vw.title,
|
||||||
|
vw.type,
|
||||||
|
vw.prompt_md,
|
||||||
|
vw.columns_config,
|
||||||
|
vw.language,
|
||||||
|
vw.practice,
|
||||||
|
vw.jurisdictions,
|
||||||
|
vw.is_system,
|
||||||
|
vw.created_at,
|
||||||
|
vw.allow_edit,
|
||||||
|
vw.is_owner,
|
||||||
|
vw.shared_by_name
|
||||||
|
from visible_workflows vw
|
||||||
|
order by vw.sort_bucket asc, vw.created_at desc;
|
||||||
|
$$;
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
-- Migration date: 2026-06-29
|
||||||
|
|
||||||
|
-- Review queue for user-submitted workflows that may later be published to the
|
||||||
|
-- open-source workflow repository. The backend writes with the service role.
|
||||||
|
|
||||||
|
create table if not exists public.workflow_open_source_submissions (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
workflow_id uuid not null references public.workflows(id) on delete cascade,
|
||||||
|
submitted_by_user_id text not null,
|
||||||
|
submitter_email text,
|
||||||
|
submitter_name text,
|
||||||
|
contributor_mode text not null default 'anonymous',
|
||||||
|
status text not null default 'pending',
|
||||||
|
snapshot jsonb not null,
|
||||||
|
submitted_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
reviewed_at timestamptz,
|
||||||
|
review_notes text,
|
||||||
|
constraint workflow_open_source_submissions_status_check
|
||||||
|
check (status in ('pending', 'approved', 'rejected')),
|
||||||
|
constraint workflow_open_source_submissions_contributor_mode_check
|
||||||
|
check (contributor_mode in ('named', 'anonymous'))
|
||||||
|
);
|
||||||
|
|
||||||
|
create unique index if not exists idx_workflow_open_source_submissions_pending
|
||||||
|
on public.workflow_open_source_submissions(workflow_id, submitted_by_user_id)
|
||||||
|
where status = 'pending';
|
||||||
|
|
||||||
|
create index if not exists idx_workflow_open_source_submissions_reviewer_queue
|
||||||
|
on public.workflow_open_source_submissions(status, submitted_at desc);
|
||||||
|
|
||||||
|
create index if not exists idx_workflow_open_source_submissions_submitter
|
||||||
|
on public.workflow_open_source_submissions(submitted_by_user_id, submitted_at desc);
|
||||||
|
|
||||||
|
alter table public.workflow_open_source_submissions enable row level security;
|
||||||
|
|
||||||
|
revoke all privileges on table public.workflow_open_source_submissions
|
||||||
|
from anon, authenticated;
|
||||||
41
backend/migrations/20260703_01_user_profile_email.sql
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
-- Mirror auth.users.email into user_profiles so backend sharing checks can
|
||||||
|
-- resolve one email without scanning Supabase Auth users.
|
||||||
|
|
||||||
|
alter table public.user_profiles
|
||||||
|
add column if not exists email text;
|
||||||
|
|
||||||
|
update public.user_profiles up
|
||||||
|
set email = lower(au.email)
|
||||||
|
from auth.users au
|
||||||
|
where up.user_id = au.id
|
||||||
|
and au.email is not null
|
||||||
|
and (
|
||||||
|
up.email is null
|
||||||
|
or up.email <> lower(au.email)
|
||||||
|
);
|
||||||
|
|
||||||
|
create unique index if not exists user_profiles_email_lower_unique
|
||||||
|
on public.user_profiles (lower(email))
|
||||||
|
where email is not null and btrim(email) <> '';
|
||||||
|
|
||||||
|
create index if not exists idx_user_profiles_email
|
||||||
|
on public.user_profiles(email);
|
||||||
|
|
||||||
|
create or replace function public.handle_new_user()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
insert into public.user_profiles (user_id, email)
|
||||||
|
values (new.id, lower(new.email))
|
||||||
|
on conflict (user_id) do update
|
||||||
|
set email = excluded.email,
|
||||||
|
updated_at = now();
|
||||||
|
return new;
|
||||||
|
exception when others then
|
||||||
|
-- Never block signup if the profile insert fails.
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
84
backend/migrations/20260703_02_project_practice.sql
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
-- Add optional practice metadata to projects and expose it in the overview RPC.
|
||||||
|
|
||||||
|
alter table public.projects
|
||||||
|
add column if not exists practice text;
|
||||||
|
|
||||||
|
drop function if exists public.get_projects_overview(text, text);
|
||||||
|
|
||||||
|
create or replace function public.get_projects_overview(
|
||||||
|
p_user_id text,
|
||||||
|
p_user_email text default null
|
||||||
|
)
|
||||||
|
returns table (
|
||||||
|
id uuid,
|
||||||
|
user_id text,
|
||||||
|
name text,
|
||||||
|
cm_number text,
|
||||||
|
practice text,
|
||||||
|
shared_with jsonb,
|
||||||
|
created_at timestamptz,
|
||||||
|
updated_at timestamptz,
|
||||||
|
is_owner boolean,
|
||||||
|
owner_display_name text,
|
||||||
|
owner_email text,
|
||||||
|
document_count integer,
|
||||||
|
chat_count integer,
|
||||||
|
review_count integer
|
||||||
|
)
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $$
|
||||||
|
with visible_projects as (
|
||||||
|
select p.*
|
||||||
|
from public.projects p
|
||||||
|
where p.user_id = p_user_id
|
||||||
|
or (
|
||||||
|
coalesce(p_user_email, '') <> ''
|
||||||
|
and p.user_id <> p_user_id
|
||||||
|
and p.shared_with @> jsonb_build_array(p_user_email)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
document_counts as (
|
||||||
|
select d.project_id, count(*)::integer as document_count
|
||||||
|
from public.documents d
|
||||||
|
where d.project_id in (select vp.id from visible_projects vp)
|
||||||
|
group by d.project_id
|
||||||
|
),
|
||||||
|
chat_counts as (
|
||||||
|
select c.project_id, count(*)::integer as chat_count
|
||||||
|
from public.chats c
|
||||||
|
where c.project_id in (select vp.id from visible_projects vp)
|
||||||
|
group by c.project_id
|
||||||
|
),
|
||||||
|
review_counts as (
|
||||||
|
select tr.project_id, count(*)::integer as review_count
|
||||||
|
from public.tabular_reviews tr
|
||||||
|
where tr.project_id in (select vp.id from visible_projects vp)
|
||||||
|
group by tr.project_id
|
||||||
|
)
|
||||||
|
select
|
||||||
|
vp.id,
|
||||||
|
vp.user_id,
|
||||||
|
vp.name,
|
||||||
|
vp.cm_number,
|
||||||
|
vp.practice,
|
||||||
|
vp.shared_with,
|
||||||
|
vp.created_at,
|
||||||
|
vp.updated_at,
|
||||||
|
vp.user_id = p_user_id as is_owner,
|
||||||
|
nullif(trim(up.display_name), '') as owner_display_name,
|
||||||
|
null::text as owner_email,
|
||||||
|
coalesce(dc.document_count, 0) as document_count,
|
||||||
|
coalesce(cc.chat_count, 0) as chat_count,
|
||||||
|
coalesce(rc.review_count, 0) as review_count
|
||||||
|
from visible_projects vp
|
||||||
|
left join public.user_profiles up
|
||||||
|
on up.user_id::text = vp.user_id
|
||||||
|
left join document_counts dc
|
||||||
|
on dc.project_id = vp.id
|
||||||
|
left join chat_counts cc
|
||||||
|
on cc.project_id = vp.id
|
||||||
|
left join review_counts rc
|
||||||
|
on rc.project_id = vp.id
|
||||||
|
order by vp.created_at desc;
|
||||||
|
$$;
|
||||||
19
backend/migrations/20260704_01_chat_message_citations.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if exists (
|
||||||
|
select 1
|
||||||
|
from information_schema.columns
|
||||||
|
where table_schema = 'public'
|
||||||
|
and table_name = 'chat_messages'
|
||||||
|
and column_name = 'annotations'
|
||||||
|
) and not exists (
|
||||||
|
select 1
|
||||||
|
from information_schema.columns
|
||||||
|
where table_schema = 'public'
|
||||||
|
and table_name = 'chat_messages'
|
||||||
|
and column_name = 'citations'
|
||||||
|
) then
|
||||||
|
alter table public.chat_messages
|
||||||
|
rename column annotations to citations;
|
||||||
|
end if;
|
||||||
|
end $$;
|
||||||
71
backend/migrations/20260710_01_library_documents.sql
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
-- Migration date: 2026-07-10
|
||||||
|
|
||||||
|
alter table public.documents
|
||||||
|
add column if not exists library_kind text default 'file';
|
||||||
|
|
||||||
|
update public.documents
|
||||||
|
set library_kind = 'file'
|
||||||
|
where library_kind is null;
|
||||||
|
|
||||||
|
alter table public.documents
|
||||||
|
alter column library_kind set default 'file',
|
||||||
|
alter column library_kind set not null;
|
||||||
|
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if not exists (
|
||||||
|
select 1
|
||||||
|
from pg_constraint
|
||||||
|
where conname = 'documents_library_kind_check'
|
||||||
|
and conrelid = 'public.documents'::regclass
|
||||||
|
) then
|
||||||
|
alter table public.documents
|
||||||
|
add constraint documents_library_kind_check
|
||||||
|
check (library_kind in ('file', 'template'));
|
||||||
|
end if;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create table if not exists public.library_folders (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id text not null,
|
||||||
|
library_kind text not null default 'file',
|
||||||
|
name text not null,
|
||||||
|
parent_folder_id uuid references public.library_folders(id) on delete cascade,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
constraint library_folders_kind_check
|
||||||
|
check (library_kind in ('file', 'template'))
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.documents
|
||||||
|
add column if not exists library_folder_id uuid;
|
||||||
|
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if not exists (
|
||||||
|
select 1
|
||||||
|
from pg_constraint
|
||||||
|
where conname = 'documents_library_folder_id_fkey'
|
||||||
|
and conrelid = 'public.documents'::regclass
|
||||||
|
) then
|
||||||
|
alter table public.documents
|
||||||
|
add constraint documents_library_folder_id_fkey
|
||||||
|
foreign key (library_folder_id)
|
||||||
|
references public.library_folders(id)
|
||||||
|
on delete set null;
|
||||||
|
end if;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create index if not exists idx_library_folders_user_kind
|
||||||
|
on public.library_folders(user_id, library_kind);
|
||||||
|
|
||||||
|
create index if not exists idx_library_folders_parent
|
||||||
|
on public.library_folders(parent_folder_id);
|
||||||
|
|
||||||
|
create index if not exists idx_documents_library_kind_folder
|
||||||
|
on public.documents(user_id, library_kind, library_folder_id)
|
||||||
|
where project_id is null;
|
||||||
|
|
||||||
|
revoke all on public.library_folders from anon, authenticated;
|
||||||
12
backend/migrations/20260716_01_chat_messages_workflow.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
-- Add chat_messages.workflow.
|
||||||
|
--
|
||||||
|
-- The app persists a `workflow` field on user messages
|
||||||
|
-- (backend/src/routes/chat.ts, projectChat.ts) and reads it back when rendering
|
||||||
|
-- chat history (frontend ChatView -> UserMessage, to show which workflow the
|
||||||
|
-- message was sent under). The column was referenced in code but never created
|
||||||
|
-- by schema.sql or any migration, so every user-message insert failed with
|
||||||
|
-- PostgREST PGRST204 ("Could not find the 'workflow' column") and was dropped
|
||||||
|
-- silently — user prompts vanished on reload while the assistant reply
|
||||||
|
-- persisted. Mirrors the existing content/files jsonb columns.
|
||||||
|
alter table public.chat_messages
|
||||||
|
add column if not exists workflow jsonb;
|
||||||
1776
backend/package-lock.json
generated
|
|
@ -5,7 +5,10 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js"
|
"start": "node dist/index.js",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:stack": "bash scripts/test-stack.sh",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.90.0",
|
"@anthropic-ai/sdk": "^0.90.0",
|
||||||
|
|
@ -28,6 +31,7 @@
|
||||||
"multer": "^1.4.5-lts.2",
|
"multer": "^1.4.5-lts.2",
|
||||||
"pdfjs-dist": "^4.10.38",
|
"pdfjs-dist": "^4.10.38",
|
||||||
"resend": "^4.5.1",
|
"resend": "^4.5.1",
|
||||||
|
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -35,9 +39,13 @@
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^22.14.1",
|
"@types/node": "^22.14.1",
|
||||||
|
"@types/supertest": "^7.2.1",
|
||||||
|
"@vitest/coverage-v8": "^4.1.9",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
|
"supertest": "^7.2.2",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.19.3",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
},
|
},
|
||||||
"license": "AGPL-3.0-only"
|
"license": "AGPL-3.0-only"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ create extension if not exists "pgcrypto";
|
||||||
create table if not exists public.user_profiles (
|
create table if not exists public.user_profiles (
|
||||||
id uuid primary key default gen_random_uuid(),
|
id uuid primary key default gen_random_uuid(),
|
||||||
user_id uuid not null unique references auth.users(id) on delete cascade,
|
user_id uuid not null unique references auth.users(id) on delete cascade,
|
||||||
|
email text,
|
||||||
display_name text,
|
display_name text,
|
||||||
organisation text,
|
organisation text,
|
||||||
tier text not null default 'Free',
|
tier text not null default 'Free',
|
||||||
|
|
@ -29,6 +30,13 @@ create table if not exists public.user_profiles (
|
||||||
create index if not exists idx_user_profiles_user
|
create index if not exists idx_user_profiles_user
|
||||||
on public.user_profiles(user_id);
|
on public.user_profiles(user_id);
|
||||||
|
|
||||||
|
create unique index if not exists user_profiles_email_lower_unique
|
||||||
|
on public.user_profiles (lower(email))
|
||||||
|
where email is not null and btrim(email) <> '';
|
||||||
|
|
||||||
|
create index if not exists idx_user_profiles_email
|
||||||
|
on public.user_profiles(email);
|
||||||
|
|
||||||
create or replace function public.handle_new_user()
|
create or replace function public.handle_new_user()
|
||||||
returns trigger
|
returns trigger
|
||||||
language plpgsql
|
language plpgsql
|
||||||
|
|
@ -36,9 +44,11 @@ security definer
|
||||||
set search_path = public
|
set search_path = public
|
||||||
as $$
|
as $$
|
||||||
begin
|
begin
|
||||||
insert into public.user_profiles (user_id)
|
insert into public.user_profiles (user_id, email)
|
||||||
values (new.id)
|
values (new.id, lower(new.email))
|
||||||
on conflict (user_id) do nothing;
|
on conflict (user_id) do update
|
||||||
|
set email = excluded.email,
|
||||||
|
updated_at = now();
|
||||||
return new;
|
return new;
|
||||||
exception when others then
|
exception when others then
|
||||||
-- Never block signup if the profile insert fails.
|
-- Never block signup if the profile insert fails.
|
||||||
|
|
@ -186,6 +196,7 @@ create table if not exists public.projects (
|
||||||
user_id text not null,
|
user_id text not null,
|
||||||
name text not null,
|
name text not null,
|
||||||
cm_number text,
|
cm_number text,
|
||||||
|
practice text,
|
||||||
visibility text not null default 'private',
|
visibility text not null default 'private',
|
||||||
shared_with jsonb not null default '[]'::jsonb,
|
shared_with jsonb not null default '[]'::jsonb,
|
||||||
created_at timestamptz not null default now(),
|
created_at timestamptz not null default now(),
|
||||||
|
|
@ -211,14 +222,36 @@ create table if not exists public.project_subfolders (
|
||||||
create index if not exists idx_project_subfolders_project
|
create index if not exists idx_project_subfolders_project
|
||||||
on public.project_subfolders(project_id);
|
on public.project_subfolders(project_id);
|
||||||
|
|
||||||
|
create table if not exists public.library_folders (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id text not null,
|
||||||
|
library_kind text not null default 'file',
|
||||||
|
name text not null,
|
||||||
|
parent_folder_id uuid references public.library_folders(id) on delete cascade,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
constraint library_folders_kind_check
|
||||||
|
check (library_kind in ('file', 'template'))
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_library_folders_user_kind
|
||||||
|
on public.library_folders(user_id, library_kind);
|
||||||
|
|
||||||
|
create index if not exists idx_library_folders_parent
|
||||||
|
on public.library_folders(parent_folder_id);
|
||||||
|
|
||||||
create table if not exists public.documents (
|
create table if not exists public.documents (
|
||||||
id uuid primary key default gen_random_uuid(),
|
id uuid primary key default gen_random_uuid(),
|
||||||
project_id uuid references public.projects(id) on delete cascade,
|
project_id uuid references public.projects(id) on delete cascade,
|
||||||
user_id text not null,
|
user_id text not null,
|
||||||
status text not null default 'pending',
|
status text not null default 'pending',
|
||||||
folder_id uuid references public.project_subfolders(id) on delete set null,
|
folder_id uuid references public.project_subfolders(id) on delete set null,
|
||||||
|
library_kind text not null default 'file',
|
||||||
|
library_folder_id uuid references public.library_folders(id) on delete set null,
|
||||||
created_at timestamptz not null default now(),
|
created_at timestamptz not null default now(),
|
||||||
updated_at timestamptz not null default now()
|
updated_at timestamptz not null default now(),
|
||||||
|
constraint documents_library_kind_check
|
||||||
|
check (library_kind in ('file', 'template'))
|
||||||
);
|
);
|
||||||
|
|
||||||
create index if not exists idx_documents_user_project
|
create index if not exists idx_documents_user_project
|
||||||
|
|
@ -227,6 +260,10 @@ create index if not exists idx_documents_user_project
|
||||||
create index if not exists idx_documents_project_folder
|
create index if not exists idx_documents_project_folder
|
||||||
on public.documents(project_id, folder_id);
|
on public.documents(project_id, folder_id);
|
||||||
|
|
||||||
|
create index if not exists idx_documents_library_kind_folder
|
||||||
|
on public.documents(user_id, library_kind, library_folder_id)
|
||||||
|
where project_id is null;
|
||||||
|
|
||||||
create table if not exists public.document_versions (
|
create table if not exists public.document_versions (
|
||||||
id uuid primary key default gen_random_uuid(),
|
id uuid primary key default gen_random_uuid(),
|
||||||
document_id uuid not null references public.documents(id) on delete cascade,
|
document_id uuid not null references public.documents(id) on delete cascade,
|
||||||
|
|
@ -323,8 +360,9 @@ create table if not exists public.workflows (
|
||||||
type text not null,
|
type text not null,
|
||||||
prompt_md text,
|
prompt_md text,
|
||||||
columns_config jsonb,
|
columns_config jsonb,
|
||||||
practice text,
|
language text default 'English',
|
||||||
is_system boolean not null default false,
|
practice text default 'General Transactions',
|
||||||
|
jurisdictions text[] default array['General']::text[],
|
||||||
created_at timestamptz not null default now()
|
created_at timestamptz not null default now()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -371,7 +409,9 @@ returns table (
|
||||||
type text,
|
type text,
|
||||||
prompt_md text,
|
prompt_md text,
|
||||||
columns_config jsonb,
|
columns_config jsonb,
|
||||||
|
language text,
|
||||||
practice text,
|
practice text,
|
||||||
|
jurisdictions text[],
|
||||||
is_system boolean,
|
is_system boolean,
|
||||||
created_at timestamptz,
|
created_at timestamptz,
|
||||||
allow_edit boolean,
|
allow_edit boolean,
|
||||||
|
|
@ -383,19 +423,38 @@ stable
|
||||||
as $$
|
as $$
|
||||||
with owned as (
|
with owned as (
|
||||||
select
|
select
|
||||||
w.*,
|
w.id,
|
||||||
|
w.user_id::text as user_id,
|
||||||
|
w.title,
|
||||||
|
w.type,
|
||||||
|
w.prompt_md,
|
||||||
|
w.columns_config,
|
||||||
|
w.language,
|
||||||
|
w.practice,
|
||||||
|
w.jurisdictions,
|
||||||
|
false as is_system,
|
||||||
|
w.created_at,
|
||||||
true as allow_edit,
|
true as allow_edit,
|
||||||
true as is_owner,
|
true as is_owner,
|
||||||
null::text as shared_by_name,
|
null::text as shared_by_name,
|
||||||
0 as sort_bucket
|
0 as sort_bucket
|
||||||
from public.workflows w
|
from public.workflows w
|
||||||
where w.user_id = p_user_id
|
where w.user_id::text = p_user_id
|
||||||
and w.is_system = false
|
|
||||||
and (p_type is null or w.type = p_type)
|
and (p_type is null or w.type = p_type)
|
||||||
),
|
),
|
||||||
shared as (
|
shared as (
|
||||||
select
|
select
|
||||||
w.*,
|
w.id,
|
||||||
|
w.user_id::text as user_id,
|
||||||
|
w.title,
|
||||||
|
w.type,
|
||||||
|
w.prompt_md,
|
||||||
|
w.columns_config,
|
||||||
|
w.language,
|
||||||
|
w.practice,
|
||||||
|
w.jurisdictions,
|
||||||
|
false as is_system,
|
||||||
|
w.created_at,
|
||||||
ws.allow_edit,
|
ws.allow_edit,
|
||||||
false as is_owner,
|
false as is_owner,
|
||||||
nullif(trim(up.display_name), '') as shared_by_name,
|
nullif(trim(up.display_name), '') as shared_by_name,
|
||||||
|
|
@ -404,7 +463,7 @@ as $$
|
||||||
join public.workflows w
|
join public.workflows w
|
||||||
on w.id = ws.workflow_id
|
on w.id = ws.workflow_id
|
||||||
left join public.user_profiles up
|
left join public.user_profiles up
|
||||||
on up.user_id::text = ws.shared_by_user_id
|
on up.user_id::text = ws.shared_by_user_id::text
|
||||||
where lower(ws.shared_with_email) = lower(coalesce(p_user_email, ''))
|
where lower(ws.shared_with_email) = lower(coalesce(p_user_email, ''))
|
||||||
and (p_type is null or w.type = p_type)
|
and (p_type is null or w.type = p_type)
|
||||||
),
|
),
|
||||||
|
|
@ -420,7 +479,9 @@ as $$
|
||||||
vw.type,
|
vw.type,
|
||||||
vw.prompt_md,
|
vw.prompt_md,
|
||||||
vw.columns_config,
|
vw.columns_config,
|
||||||
|
vw.language,
|
||||||
vw.practice,
|
vw.practice,
|
||||||
|
vw.jurisdictions,
|
||||||
vw.is_system,
|
vw.is_system,
|
||||||
vw.created_at,
|
vw.created_at,
|
||||||
vw.allow_edit,
|
vw.allow_edit,
|
||||||
|
|
@ -489,7 +550,8 @@ create table if not exists public.chat_messages (
|
||||||
role text not null,
|
role text not null,
|
||||||
content jsonb,
|
content jsonb,
|
||||||
files jsonb,
|
files jsonb,
|
||||||
annotations jsonb,
|
workflow jsonb,
|
||||||
|
citations jsonb,
|
||||||
created_at timestamptz not null default now()
|
created_at timestamptz not null default now()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -549,6 +611,7 @@ returns table (
|
||||||
user_id text,
|
user_id text,
|
||||||
name text,
|
name text,
|
||||||
cm_number text,
|
cm_number text,
|
||||||
|
practice text,
|
||||||
shared_with jsonb,
|
shared_with jsonb,
|
||||||
created_at timestamptz,
|
created_at timestamptz,
|
||||||
updated_at timestamptz,
|
updated_at timestamptz,
|
||||||
|
|
@ -595,6 +658,7 @@ as $$
|
||||||
vp.user_id,
|
vp.user_id,
|
||||||
vp.name,
|
vp.name,
|
||||||
vp.cm_number,
|
vp.cm_number,
|
||||||
|
vp.practice,
|
||||||
vp.shared_with,
|
vp.shared_with,
|
||||||
vp.created_at,
|
vp.created_at,
|
||||||
vp.updated_at,
|
vp.updated_at,
|
||||||
|
|
@ -800,6 +864,7 @@ alter table public.courtlistener_opinion_cluster_index enable row level security
|
||||||
revoke all on public.user_profiles from anon, authenticated;
|
revoke all on public.user_profiles from anon, authenticated;
|
||||||
revoke all on public.projects from anon, authenticated;
|
revoke all on public.projects from anon, authenticated;
|
||||||
revoke all on public.project_subfolders from anon, authenticated;
|
revoke all on public.project_subfolders from anon, authenticated;
|
||||||
|
revoke all on public.library_folders from anon, authenticated;
|
||||||
revoke all on public.documents from anon, authenticated;
|
revoke all on public.documents from anon, authenticated;
|
||||||
revoke all on public.document_versions from anon, authenticated;
|
revoke all on public.document_versions from anon, authenticated;
|
||||||
revoke all on public.document_edits from anon, authenticated;
|
revoke all on public.document_edits from anon, authenticated;
|
||||||
|
|
@ -820,3 +885,14 @@ revoke all on public.user_mcp_connector_tools from anon, authenticated;
|
||||||
revoke all on public.user_mcp_tool_audit_logs from anon, authenticated;
|
revoke all on public.user_mcp_tool_audit_logs from anon, authenticated;
|
||||||
revoke all on public.courtlistener_citation_index from anon, authenticated;
|
revoke all on public.courtlistener_citation_index from anon, authenticated;
|
||||||
revoke all on public.courtlistener_opinion_cluster_index from anon, authenticated;
|
revoke all on public.courtlistener_opinion_cluster_index from anon, authenticated;
|
||||||
|
|
||||||
|
-- Tables created by this file are owned by the database bootstrap role. The
|
||||||
|
-- backend connects as service_role, so grant it only the data privileges that
|
||||||
|
-- the direct browser roles above intentionally do not have. RLS is still
|
||||||
|
-- enabled as defense in depth; service_role bypasses it for the backend path.
|
||||||
|
grant select, insert, update, delete
|
||||||
|
on all tables in schema public
|
||||||
|
to service_role;
|
||||||
|
grant usage, select
|
||||||
|
on all sequences in schema public
|
||||||
|
to service_role;
|
||||||
|
|
|
||||||
63
backend/scripts/test-stack.sh
Executable file
|
|
@ -0,0 +1,63 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run the gated stack-level integration tests against a local Supabase stack.
|
||||||
|
#
|
||||||
|
# These tests exercise the REAL stack (GoTrue auth + Postgres RLS) instead of
|
||||||
|
# mocks. They are the harness you re-run on every Supabase image bump to prove
|
||||||
|
# the auth↔API contract and the deny-all RLS firewall still hold.
|
||||||
|
#
|
||||||
|
# Usage: supabase start # in the repo, once
|
||||||
|
# npm run test:stack (from backend/)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
BACKEND_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
SCHEMA_FILE="$BACKEND_DIR/schema.sql"
|
||||||
|
|
||||||
|
if ! command -v supabase >/dev/null 2>&1; then
|
||||||
|
echo "supabase CLI not found. Install: brew install supabase/tap/supabase" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
STATUS="$(supabase status -o json 2>/dev/null)" || {
|
||||||
|
echo "No running Supabase stack. Start one with: supabase start" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
read_key() { node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>process.stdout.write(String(JSON.parse(s)['$1']??'')))" <<<"$STATUS"; }
|
||||||
|
|
||||||
|
SUPABASE_TEST_URL="$(read_key API_URL)"
|
||||||
|
SUPABASE_TEST_SERVICE_ROLE_KEY="$(read_key SERVICE_ROLE_KEY)"
|
||||||
|
SUPABASE_TEST_ANON_KEY="$(read_key ANON_KEY)"
|
||||||
|
SUPABASE_TEST_DB_URL="$(read_key DB_URL)"
|
||||||
|
|
||||||
|
if [[ -z "$SUPABASE_TEST_URL" || -z "$SUPABASE_TEST_SERVICE_ROLE_KEY" || -z "$SUPABASE_TEST_ANON_KEY" || -z "$SUPABASE_TEST_DB_URL" ]]; then
|
||||||
|
echo "Could not read API_URL/DB_URL/SERVICE_ROLE_KEY/ANON_KEY from 'supabase status'." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
export SUPABASE_TEST_URL SUPABASE_TEST_SERVICE_ROLE_KEY SUPABASE_TEST_ANON_KEY
|
||||||
|
|
||||||
|
if ! command -v psql >/dev/null 2>&1; then
|
||||||
|
echo "psql not found. Install PostgreSQL's client tools before running stack tests." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A newly started local stack contains Supabase's system schemas but none of
|
||||||
|
# Mike's application tables. Initialize only an empty stack: silently resetting
|
||||||
|
# or modifying an existing application database would be surprising.
|
||||||
|
PROJECTS_TABLE="$(
|
||||||
|
psql "$SUPABASE_TEST_DB_URL" -XAtq \
|
||||||
|
-c "select to_regclass('public.projects');"
|
||||||
|
)"
|
||||||
|
if [[ "$PROJECTS_TABLE" != "projects" ]]; then
|
||||||
|
echo "Mike schema not found; loading $SCHEMA_FILE"
|
||||||
|
psql "$SUPABASE_TEST_DB_URL" -X \
|
||||||
|
--set ON_ERROR_STOP=1 \
|
||||||
|
--file "$SCHEMA_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Running stack integration tests against $SUPABASE_TEST_URL"
|
||||||
|
cd "$BACKEND_DIR"
|
||||||
|
exec npx vitest run \
|
||||||
|
src/__tests__/integration/stack.supabase.test.ts \
|
||||||
|
src/__tests__/integration/access.supabase.test.ts \
|
||||||
|
"$@"
|
||||||
97
backend/src/__tests__/integration/access.supabase.test.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
filterAccessibleDocumentIds,
|
||||||
|
listAccessibleProjectIds,
|
||||||
|
} from "../../lib/access";
|
||||||
|
|
||||||
|
// Gated: runs only against a real (local) Supabase stack.
|
||||||
|
// supabase start, then export:
|
||||||
|
// SUPABASE_TEST_URL, SUPABASE_TEST_SERVICE_ROLE_KEY
|
||||||
|
// or use scripts/test-stack.sh which reads them from `supabase status`.
|
||||||
|
const url = process.env.SUPABASE_TEST_URL;
|
||||||
|
const serviceKey = process.env.SUPABASE_TEST_SERVICE_ROLE_KEY;
|
||||||
|
|
||||||
|
const maybeDescribe = url && serviceKey ? describe : describe.skip;
|
||||||
|
|
||||||
|
maybeDescribe("Supabase access integration", () => {
|
||||||
|
it("proves tabular document filtering drops foreign document IDs", async () => {
|
||||||
|
const admin = createClient(url!, serviceKey!, {
|
||||||
|
auth: { persistSession: false },
|
||||||
|
});
|
||||||
|
const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
const ownerId = crypto.randomUUID();
|
||||||
|
const reviewerId = crypto.randomUUID();
|
||||||
|
const sharedProjectId = crypto.randomUUID();
|
||||||
|
const privateProjectId = crypto.randomUUID();
|
||||||
|
const sharedDocId = crypto.randomUUID();
|
||||||
|
const privateDocId = crypto.randomUUID();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const projectsInsert = await admin.from("projects").insert([
|
||||||
|
{
|
||||||
|
id: sharedProjectId,
|
||||||
|
user_id: ownerId,
|
||||||
|
name: `shared-${suffix}`,
|
||||||
|
shared_with: [`reviewer-${suffix}@example.com`],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: privateProjectId,
|
||||||
|
user_id: ownerId,
|
||||||
|
name: `private-${suffix}`,
|
||||||
|
shared_with: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
if (projectsInsert.error) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not seed projects: ${projectsInsert.error.message}`,
|
||||||
|
{ cause: projectsInsert.error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// filename/file_type live on document_versions in this schema —
|
||||||
|
// the documents rows only need identity + ownership columns.
|
||||||
|
const documentsInsert = await admin.from("documents").insert([
|
||||||
|
{
|
||||||
|
id: sharedDocId,
|
||||||
|
user_id: ownerId,
|
||||||
|
project_id: sharedProjectId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: privateDocId,
|
||||||
|
user_id: ownerId,
|
||||||
|
project_id: privateProjectId,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
if (documentsInsert.error) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not seed documents: ${documentsInsert.error.message}`,
|
||||||
|
{ cause: documentsInsert.error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
listAccessibleProjectIds(
|
||||||
|
reviewerId,
|
||||||
|
`reviewer-${suffix}@example.com`,
|
||||||
|
admin as any,
|
||||||
|
),
|
||||||
|
).resolves.toContain(sharedProjectId);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
filterAccessibleDocumentIds(
|
||||||
|
[sharedDocId, privateDocId],
|
||||||
|
reviewerId,
|
||||||
|
`reviewer-${suffix}@example.com`,
|
||||||
|
admin as any,
|
||||||
|
),
|
||||||
|
).resolves.toEqual([sharedDocId]);
|
||||||
|
} finally {
|
||||||
|
await admin.from("documents").delete().in("id", [sharedDocId, privateDocId]);
|
||||||
|
await admin
|
||||||
|
.from("projects")
|
||||||
|
.delete()
|
||||||
|
.in("id", [sharedProjectId, privateProjectId]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
173
backend/src/__tests__/integration/chat.routes.test.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import request from "supertest";
|
||||||
|
|
||||||
|
// Hoisted mock fn so the vi.mock factory below (which is itself hoisted above
|
||||||
|
// the imports) can reference it. Lets each test drive the stream outcome.
|
||||||
|
const { runLLMStream } = vi.hoisted(() => ({
|
||||||
|
runLLMStream: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// A permissive, chainable Supabase stub. Every query-builder method returns the
|
||||||
|
// same object (so arbitrary chains work), the object is awaitable (thenable),
|
||||||
|
// and the terminal single()/maybeSingle() resolve to a chat row. The chat
|
||||||
|
// routes only read `.id`/`.title` and check `.error`, so this is enough to let
|
||||||
|
// a request flow through chat creation and message inserts without real IO.
|
||||||
|
function makeQuery() {
|
||||||
|
const result = { data: { id: "chat-1", title: null }, error: null };
|
||||||
|
const q: Record<string, unknown> = {};
|
||||||
|
const chain = [
|
||||||
|
"select", "insert", "update", "delete", "upsert",
|
||||||
|
"eq", "neq", "in", "is", "or", "lt", "gt", "gte", "lte",
|
||||||
|
"filter", "order", "limit", "range", "contains",
|
||||||
|
];
|
||||||
|
for (const m of chain) q[m] = vi.fn(() => q);
|
||||||
|
q.single = vi.fn(() => Promise.resolve(result));
|
||||||
|
q.maybeSingle = vi.fn(() => Promise.resolve(result));
|
||||||
|
q.then = (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) =>
|
||||||
|
Promise.resolve(result).then(resolve, reject);
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockSupabase() {
|
||||||
|
return {
|
||||||
|
from: vi.fn(() => makeQuery()),
|
||||||
|
rpc: vi.fn(() => Promise.resolve({ data: null, error: null })),
|
||||||
|
auth: {
|
||||||
|
getUser: () =>
|
||||||
|
Promise.resolve({ data: { user: { id: "u1" } }, error: null }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock("../../lib/supabase", () => ({
|
||||||
|
createServerSupabase: vi.fn(() => mockSupabase()),
|
||||||
|
getUserIdFromRequest: vi.fn(async () => "u1"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Authenticate every request as user "u1" without exercising the real Supabase
|
||||||
|
// JWT path. requireMfaIfEnrolled must be exported too — userRouter (mounted by
|
||||||
|
// the app) imports it at module load.
|
||||||
|
vi.mock("../../middleware/auth", () => ({
|
||||||
|
requireAuth: (
|
||||||
|
_req: unknown,
|
||||||
|
res: { locals: Record<string, unknown> },
|
||||||
|
next: () => void,
|
||||||
|
) => {
|
||||||
|
res.locals.userId = "u1";
|
||||||
|
res.locals.userEmail = "u1@test.local";
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
requireMfaIfEnrolled: (_req: unknown, _res: unknown, next: () => void) =>
|
||||||
|
next(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Keep the real error helpers (the failure-path test relies on genuine
|
||||||
|
// isAbortError + AssistantStreamError behavior) but stub the functions that
|
||||||
|
// would otherwise hit the DB or the LLM.
|
||||||
|
vi.mock("../../lib/chat", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../../lib/chat")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
buildDocContext: vi.fn(async () => ({ docIndex: {}, docStore: new Map() })),
|
||||||
|
enrichWithPriorEvents: vi.fn(async (messages: unknown) => messages),
|
||||||
|
buildWorkflowStore: vi.fn(async () => new Map()),
|
||||||
|
buildMessages: vi.fn(() => []),
|
||||||
|
runLLMStream: (...args: unknown[]) => runLLMStream(...args),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../../lib/userSettings", () => ({
|
||||||
|
getUserModelSettings: vi.fn(async () => ({
|
||||||
|
legal_research_us: false,
|
||||||
|
title_model: "test-model",
|
||||||
|
tabular_model: "test-model",
|
||||||
|
api_keys: {},
|
||||||
|
})),
|
||||||
|
getUserApiKeys: vi.fn(async () => ({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { app } from "../../app";
|
||||||
|
|
||||||
|
const VALID_BODY = { messages: [{ role: "user", content: "hello" }] };
|
||||||
|
|
||||||
|
describe("POST /chat — streaming endpoint", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
runLLMStream.mockResolvedValue({
|
||||||
|
fullText: "hi there",
|
||||||
|
events: [],
|
||||||
|
citations: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("streams SSE with a chat_id event on the happy path", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/chat")
|
||||||
|
.set("Authorization", "Bearer test")
|
||||||
|
.send(VALID_BODY);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers["content-type"]).toContain("text/event-stream");
|
||||||
|
expect(res.text).toContain('"type":"chat_id"');
|
||||||
|
expect(runLLMStream).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces a stream failure as an in-stream error event, not an HTTP error", async () => {
|
||||||
|
runLLMStream.mockRejectedValue(new Error("upstream LLM failure"));
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/chat")
|
||||||
|
.set("Authorization", "Bearer test")
|
||||||
|
.send(VALID_BODY);
|
||||||
|
|
||||||
|
// Headers were already flushed (200) before the stream threw, so the
|
||||||
|
// failure surfaces as an in-stream error event + [DONE].
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.text).toContain('"type":"error"');
|
||||||
|
expect(res.text).toContain("[DONE]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 on an empty messages array (never starts a stream)", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/chat")
|
||||||
|
.set("Authorization", "Bearer test")
|
||||||
|
.send({ messages: [] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body).toHaveProperty("detail");
|
||||||
|
expect(runLLMStream).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when messages is missing entirely", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/chat")
|
||||||
|
.set("Authorization", "Bearer test")
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(runLLMStream).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when chat_id is not a non-empty string", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/chat")
|
||||||
|
.set("Authorization", "Bearer test")
|
||||||
|
.send({ ...VALID_BODY, chat_id: " " });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.detail).toBe("chat_id must be a non-empty string");
|
||||||
|
expect(runLLMStream).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PATCH /chat/:chatId", () => {
|
||||||
|
it("returns 400 when title is missing", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.patch("/chat/chat-1")
|
||||||
|
.set("Authorization", "Bearer test")
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.detail).toBe("title is required");
|
||||||
|
});
|
||||||
|
});
|
||||||
108
backend/src/__tests__/integration/documentsUpload.routes.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import request from "supertest";
|
||||||
|
|
||||||
|
function mockSupabase() {
|
||||||
|
const result = { data: null, error: null };
|
||||||
|
const q: Record<string, unknown> = {};
|
||||||
|
const chain = [
|
||||||
|
"select", "insert", "update", "delete", "upsert",
|
||||||
|
"eq", "neq", "in", "is", "or", "not", "lt", "order", "limit",
|
||||||
|
];
|
||||||
|
for (const m of chain) q[m] = vi.fn(() => q);
|
||||||
|
q.single = vi.fn(() => Promise.resolve(result));
|
||||||
|
q.maybeSingle = vi.fn(() => Promise.resolve(result));
|
||||||
|
q.then = (resolve: (v: unknown) => unknown) =>
|
||||||
|
Promise.resolve(result).then(resolve);
|
||||||
|
return {
|
||||||
|
from: vi.fn(() => q),
|
||||||
|
rpc: vi.fn(() => Promise.resolve(result)),
|
||||||
|
auth: {
|
||||||
|
getUser: () =>
|
||||||
|
Promise.resolve({ data: { user: { id: "u1" } }, error: null }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock("../../lib/supabase", () => ({
|
||||||
|
createServerSupabase: vi.fn(() => mockSupabase()),
|
||||||
|
getUserIdFromRequest: vi.fn(async () => "u1"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../middleware/auth", () => ({
|
||||||
|
requireAuth: (
|
||||||
|
_req: unknown,
|
||||||
|
res: { locals: Record<string, unknown> },
|
||||||
|
next: () => void,
|
||||||
|
) => {
|
||||||
|
res.locals.userId = "u1";
|
||||||
|
res.locals.userEmail = "u1@test.local";
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
requireMfaIfEnrolled: (_req: unknown, _res: unknown, next: () => void) =>
|
||||||
|
next(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Stub the storage IO functions so a request that clears validation never
|
||||||
|
// touches R2/S3, while keeping the rest of the storage module (key builders,
|
||||||
|
// disposition helpers) real. The validation tests below reject before storage
|
||||||
|
// is reached, but this guards against accidental real IO regardless.
|
||||||
|
vi.mock("../../lib/storage", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../../lib/storage")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
uploadFile: vi.fn(async () => {}),
|
||||||
|
downloadFile: vi.fn(async () => null),
|
||||||
|
deleteFile: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { app } from "../../app";
|
||||||
|
|
||||||
|
describe("POST /single-documents — upload validation", () => {
|
||||||
|
it("rejects an unsupported file extension with 400", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/single-documents")
|
||||||
|
.set("Authorization", "Bearer test")
|
||||||
|
.attach("file", Buffer.from("hello world"), {
|
||||||
|
filename: "notes.txt",
|
||||||
|
contentType: "text/plain",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.detail).toMatch(/unsupported file type/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a request with no file attached with 400", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/single-documents")
|
||||||
|
.set("Authorization", "Bearer test")
|
||||||
|
.field("note", "no file here");
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.detail).toBe("file is required");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /single-documents/download-zip — bounds", () => {
|
||||||
|
it("returns 400 when document_ids is empty", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/single-documents/download-zip")
|
||||||
|
.set("Authorization", "Bearer test")
|
||||||
|
.send({ document_ids: [] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.detail).toMatch(/document_ids is required/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when none of the requested documents are accessible", async () => {
|
||||||
|
// The documents lookup resolves to no rows (stubbed DB), so the
|
||||||
|
// access filter leaves nothing to zip.
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/single-documents/download-zip")
|
||||||
|
.set("Authorization", "Bearer test")
|
||||||
|
.send({ document_ids: ["d-other-user"] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.detail).toBe("No documents found");
|
||||||
|
});
|
||||||
|
});
|
||||||
80
backend/src/__tests__/integration/health.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import request from "supertest";
|
||||||
|
|
||||||
|
// requireAuth reads SUPABASE_URL / SUPABASE_SECRET_KEY from process.env at
|
||||||
|
// request time (not import time), so setting them here is early enough even
|
||||||
|
// though imported modules evaluate before this assignment runs.
|
||||||
|
process.env.SUPABASE_URL = "http://supabase.test.local";
|
||||||
|
process.env.SUPABASE_SECRET_KEY = "test-service-key";
|
||||||
|
|
||||||
|
// Mock the supabase-js client factory so the real requireAuth middleware never
|
||||||
|
// makes a network call: auth.getUser() resolves to no user for any token,
|
||||||
|
// simulating an invalid/expired JWT.
|
||||||
|
vi.mock("@supabase/supabase-js", () => ({
|
||||||
|
createClient: vi.fn(() => ({
|
||||||
|
from: () => {
|
||||||
|
const q: Record<string, unknown> = {};
|
||||||
|
const chain = [
|
||||||
|
"select", "insert", "update", "delete", "upsert",
|
||||||
|
"eq", "neq", "in", "is", "or", "not", "filter",
|
||||||
|
"order", "limit",
|
||||||
|
];
|
||||||
|
for (const m of chain) q[m] = () => q;
|
||||||
|
q.single = () => Promise.resolve({ data: null, error: null });
|
||||||
|
q.maybeSingle = () => Promise.resolve({ data: null, error: null });
|
||||||
|
q.then = (resolve: (v: unknown) => unknown) =>
|
||||||
|
Promise.resolve({ data: null, error: null }).then(resolve);
|
||||||
|
return q;
|
||||||
|
},
|
||||||
|
rpc: () => Promise.resolve({ data: null, error: null }),
|
||||||
|
auth: {
|
||||||
|
getUser: () =>
|
||||||
|
Promise.resolve({ data: { user: null }, error: null }),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Vitest hoists vi.mock() calls before all imports, so this regular import
|
||||||
|
// receives the mocked supabase-js module even though it appears after the
|
||||||
|
// vi.mock() call in source order.
|
||||||
|
import { app } from "../../app";
|
||||||
|
|
||||||
|
describe("GET /health", () => {
|
||||||
|
it("returns 200 with { ok: true }", async () => {
|
||||||
|
const res = await request(app).get("/health");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("requireAuth middleware", () => {
|
||||||
|
it("rejects requests with no Authorization header (401)", async () => {
|
||||||
|
const res = await request(app).get("/chat");
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body).toHaveProperty("detail");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects requests with a non-Bearer Authorization header (401)", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get("/chat")
|
||||||
|
.set("Authorization", "Basic dXNlcjpwYXNz");
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects requests with an invalid Bearer token (401)", async () => {
|
||||||
|
// The mocked createClient().auth.getUser returns { user: null } for
|
||||||
|
// any token — simulating an expired/invalid token.
|
||||||
|
const res = await request(app)
|
||||||
|
.get("/chat")
|
||||||
|
.set("Authorization", "Bearer invalid-token");
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.detail).toMatch(/invalid|expired/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("404 handling", () => {
|
||||||
|
it("returns 404 for unknown routes", async () => {
|
||||||
|
const res = await request(app).get("/this-route-does-not-exist");
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
149
backend/src/__tests__/integration/projectChat.routes.test.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import request from "supertest";
|
||||||
|
|
||||||
|
const { runLLMStream, checkProjectAccess } = vi.hoisted(() => ({
|
||||||
|
runLLMStream: vi.fn(),
|
||||||
|
checkProjectAccess: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function makeQuery() {
|
||||||
|
const result = {
|
||||||
|
data: { id: "chat-1", title: null, project_id: "p1" },
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
const q: Record<string, unknown> = {};
|
||||||
|
const chain = [
|
||||||
|
"select", "insert", "update", "delete", "upsert",
|
||||||
|
"eq", "neq", "in", "is", "or", "lt", "gt", "gte", "lte",
|
||||||
|
"filter", "order", "limit", "range", "contains",
|
||||||
|
];
|
||||||
|
for (const m of chain) q[m] = vi.fn(() => q);
|
||||||
|
q.single = vi.fn(() => Promise.resolve(result));
|
||||||
|
q.maybeSingle = vi.fn(() => Promise.resolve(result));
|
||||||
|
q.then = (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) =>
|
||||||
|
Promise.resolve(result).then(resolve, reject);
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockSupabase() {
|
||||||
|
return {
|
||||||
|
from: vi.fn(() => makeQuery()),
|
||||||
|
rpc: vi.fn(() => Promise.resolve({ data: null, error: null })),
|
||||||
|
auth: {
|
||||||
|
getUser: () =>
|
||||||
|
Promise.resolve({ data: { user: { id: "u1" } }, error: null }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock("../../lib/supabase", () => ({
|
||||||
|
createServerSupabase: vi.fn(() => mockSupabase()),
|
||||||
|
getUserIdFromRequest: vi.fn(async () => "u1"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../middleware/auth", () => ({
|
||||||
|
requireAuth: (
|
||||||
|
_req: unknown,
|
||||||
|
res: { locals: Record<string, unknown> },
|
||||||
|
next: () => void,
|
||||||
|
) => {
|
||||||
|
res.locals.userId = "u1";
|
||||||
|
res.locals.userEmail = "u1@test.local";
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
requireMfaIfEnrolled: (_req: unknown, _res: unknown, next: () => void) =>
|
||||||
|
next(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../lib/chat", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../../lib/chat")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
buildProjectDocContext: vi.fn(async () => ({
|
||||||
|
docIndex: {},
|
||||||
|
docStore: new Map(),
|
||||||
|
folderPaths: new Map(),
|
||||||
|
})),
|
||||||
|
enrichWithPriorEvents: vi.fn(async (messages: unknown) => messages),
|
||||||
|
buildWorkflowStore: vi.fn(async () => new Map()),
|
||||||
|
buildMessages: vi.fn(() => []),
|
||||||
|
runLLMStream: (...args: unknown[]) => runLLMStream(...args),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../../lib/userSettings", () => ({
|
||||||
|
getUserModelSettings: vi.fn(async () => ({
|
||||||
|
legal_research_us: false,
|
||||||
|
title_model: "test-model",
|
||||||
|
tabular_model: "test-model",
|
||||||
|
api_keys: {},
|
||||||
|
})),
|
||||||
|
getUserApiKeys: vi.fn(async () => ({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../lib/access", () => ({
|
||||||
|
checkProjectAccess: (...args: unknown[]) => checkProjectAccess(...args),
|
||||||
|
ensureDocAccess: vi.fn(async () => ({ ok: true, isOwner: true })),
|
||||||
|
ensureReviewAccess: vi.fn(async () => ({ ok: true, isOwner: true })),
|
||||||
|
filterAccessibleDocumentIds: vi.fn(async (ids: string[]) => ids),
|
||||||
|
listAccessibleProjectIds: vi.fn(async () => []),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { app } from "../../app";
|
||||||
|
|
||||||
|
const VALID_BODY = { messages: [{ role: "user", content: "hello" }] };
|
||||||
|
|
||||||
|
describe("POST /projects/:projectId/chat", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
runLLMStream.mockResolvedValue({
|
||||||
|
fullText: "",
|
||||||
|
events: [],
|
||||||
|
citations: [],
|
||||||
|
});
|
||||||
|
checkProjectAccess.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
isOwner: true,
|
||||||
|
project: { id: "p1", user_id: "u1", shared_with: null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 and never streams when project access is denied", async () => {
|
||||||
|
checkProjectAccess.mockResolvedValue({ ok: false });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/projects/p1/chat")
|
||||||
|
.set("Authorization", "Bearer test")
|
||||||
|
.send(VALID_BODY);
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.detail).toBe("Project not found");
|
||||||
|
// The guard fires before any LLM stream.
|
||||||
|
expect(runLLMStream).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("streams SSE on the happy path with project access granted", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/projects/p1/chat")
|
||||||
|
.set("Authorization", "Bearer test")
|
||||||
|
.send(VALID_BODY);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers["content-type"]).toContain("text/event-stream");
|
||||||
|
expect(res.text).toContain('"type":"chat_id"');
|
||||||
|
expect(runLLMStream).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces a stream failure as an in-stream error event, not an HTTP error", async () => {
|
||||||
|
runLLMStream.mockRejectedValue(new Error("upstream LLM failure"));
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/projects/p1/chat")
|
||||||
|
.set("Authorization", "Bearer test")
|
||||||
|
.send(VALID_BODY);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.text).toContain('"type":"error"');
|
||||||
|
expect(res.text).toContain("[DONE]");
|
||||||
|
});
|
||||||
|
});
|
||||||
415
backend/src/__tests__/integration/projects.routes.test.ts
Normal file
|
|
@ -0,0 +1,415 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import request from "supertest";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hoisted mock fns we want to reconfigure per-test.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const { checkProjectAccess, deleteUserProjects } = vi.hoisted(() => ({
|
||||||
|
checkProjectAccess: vi.fn(),
|
||||||
|
deleteUserProjects: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Configurable Supabase stub. Each test seeds `supabaseState` in beforeEach;
|
||||||
|
// terminal query operations (.single()/.maybeSingle()/thenable) resolve to the
|
||||||
|
// per-table result, and rpc() resolves to a per-call result. Insert payloads
|
||||||
|
// are recorded so tests can assert on normalisation (lowercasing / dedupe).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
type QueryResult = { data: unknown; error: unknown };
|
||||||
|
|
||||||
|
let supabaseState: {
|
||||||
|
rpc: QueryResult;
|
||||||
|
tables: Record<string, QueryResult>;
|
||||||
|
inserts: { table: string; payload: unknown }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function resetSupabaseState() {
|
||||||
|
supabaseState = {
|
||||||
|
rpc: { data: [], error: null },
|
||||||
|
tables: {},
|
||||||
|
inserts: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
resetSupabaseState();
|
||||||
|
|
||||||
|
function resultForTable(table: string): QueryResult {
|
||||||
|
return supabaseState.tables[table] ?? { data: null, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeQuery(table: string) {
|
||||||
|
const q: Record<string, unknown> = {};
|
||||||
|
const chain = [
|
||||||
|
"select", "update", "delete", "upsert",
|
||||||
|
"eq", "neq", "in", "is", "or", "not", "lt", "gt", "gte", "lte",
|
||||||
|
"filter", "order", "limit", "range", "contains",
|
||||||
|
];
|
||||||
|
for (const m of chain) q[m] = vi.fn(() => q);
|
||||||
|
q.insert = vi.fn((payload: unknown) => {
|
||||||
|
supabaseState.inserts.push({ table, payload });
|
||||||
|
return q;
|
||||||
|
});
|
||||||
|
q.single = vi.fn(() => Promise.resolve(resultForTable(table)));
|
||||||
|
q.maybeSingle = vi.fn(() => Promise.resolve(resultForTable(table)));
|
||||||
|
q.then = (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) =>
|
||||||
|
Promise.resolve(resultForTable(table)).then(resolve, reject);
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockSupabase() {
|
||||||
|
return {
|
||||||
|
from: vi.fn((table: string) => makeQuery(table)),
|
||||||
|
rpc: vi.fn(() => Promise.resolve(supabaseState.rpc)),
|
||||||
|
auth: {
|
||||||
|
getUser: () =>
|
||||||
|
Promise.resolve({ data: { user: { id: "u1" } }, error: null }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock("../../lib/supabase", () => ({
|
||||||
|
createServerSupabase: vi.fn(() => mockSupabase()),
|
||||||
|
getUserIdFromRequest: vi.fn(async () => "u1"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../middleware/auth", () => ({
|
||||||
|
requireAuth: (
|
||||||
|
_req: unknown,
|
||||||
|
res: { locals: Record<string, unknown> },
|
||||||
|
next: () => void,
|
||||||
|
) => {
|
||||||
|
res.locals.userId = "u1";
|
||||||
|
res.locals.userEmail = "u1@test.local";
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
requireMfaIfEnrolled: (_req: unknown, _res: unknown, next: () => void) =>
|
||||||
|
next(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Every export of lib/access must be present — other routers (chat, documents,
|
||||||
|
// downloads, tabular) import from it at app load.
|
||||||
|
vi.mock("../../lib/access", () => ({
|
||||||
|
checkProjectAccess: (...args: unknown[]) => checkProjectAccess(...args),
|
||||||
|
ensureDocAccess: vi.fn(async () => ({ ok: true, isOwner: true })),
|
||||||
|
ensureReviewAccess: vi.fn(async () => ({ ok: true, isOwner: true })),
|
||||||
|
filterAccessibleDocumentIds: vi.fn(async (ids: string[]) => ids),
|
||||||
|
listAccessibleProjectIds: vi.fn(async () => []),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// user router imports all four cleanup helpers at module load.
|
||||||
|
vi.mock("../../lib/userDataCleanup", () => ({
|
||||||
|
deleteUserProjects: (...args: unknown[]) => deleteUserProjects(...args),
|
||||||
|
deleteAllUserChats: vi.fn(async () => {}),
|
||||||
|
deleteAllUserTabularReviews: vi.fn(async () => {}),
|
||||||
|
deleteUserAccountData: vi.fn(async () => {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Version-path enrichment hits the DB in real life; no-op it so the route
|
||||||
|
// responses are driven purely by the documents/projects table stubs.
|
||||||
|
vi.mock("../../lib/documentVersions", () => ({
|
||||||
|
attachActiveVersionPaths: vi.fn(async () => {}),
|
||||||
|
attachLatestVersionNumbers: vi.fn(async () => {}),
|
||||||
|
loadActiveVersion: vi.fn(async () => null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { app } from "../../app";
|
||||||
|
|
||||||
|
const AUTH = ["Authorization", "Bearer test"] as const;
|
||||||
|
|
||||||
|
describe("projects.routes", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
resetSupabaseState();
|
||||||
|
checkProjectAccess.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
isOwner: true,
|
||||||
|
project: { id: "p1", user_id: "u1", shared_with: null },
|
||||||
|
});
|
||||||
|
deleteUserProjects.mockResolvedValue(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /projects (overview) ──────────────────────────────────────────
|
||||||
|
describe("GET /projects", () => {
|
||||||
|
it("returns the overview rows from the RPC", async () => {
|
||||||
|
supabaseState.rpc = {
|
||||||
|
data: [{ id: "p1", name: "Alpha" }],
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await request(app).get("/projects").set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual([{ id: "p1", name: "Alpha" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 with detail when the RPC errors", async () => {
|
||||||
|
supabaseState.rpc = { data: null, error: { message: "boom" } };
|
||||||
|
|
||||||
|
const res = await request(app).get("/projects").set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.detail).toBe("boom");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST /projects (create) ───────────────────────────────────────────
|
||||||
|
describe("POST /projects", () => {
|
||||||
|
it("returns 400 when name is missing/blank", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/projects")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({ name: " " });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.detail).toBe("name is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when sharing the project with yourself", async () => {
|
||||||
|
// The authed user's email is u1@test.local; supplying it (in any
|
||||||
|
// case) must be rejected.
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/projects")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({ name: "Beta", shared_with: ["U1@Test.Local"] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.detail).toBe(
|
||||||
|
"You cannot share a project with yourself.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates the project (201) and normalises shared_with", async () => {
|
||||||
|
// Sharing requires each recipient to have a mirrored user_profiles
|
||||||
|
// row (findMissingUserEmails); seed both emails so validation
|
||||||
|
// passes and the create path proceeds.
|
||||||
|
supabaseState.tables.user_profiles = {
|
||||||
|
data: [{ email: "a@x.com" }, { email: "b@x.com" }],
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
supabaseState.tables.projects = {
|
||||||
|
data: {
|
||||||
|
id: "p9",
|
||||||
|
name: "Gamma",
|
||||||
|
user_id: "u1",
|
||||||
|
shared_with: ["a@x.com", "b@x.com"],
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/projects")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({
|
||||||
|
name: " Gamma ",
|
||||||
|
shared_with: ["A@x.com", "a@x.com", "B@X.com", "", " "],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body).toMatchObject({ id: "p9", documents: [] });
|
||||||
|
|
||||||
|
// The insert payload should be lowercased, deduped, trimmed and
|
||||||
|
// the name trimmed.
|
||||||
|
const insert = supabaseState.inserts.find(
|
||||||
|
(i) => i.table === "projects",
|
||||||
|
);
|
||||||
|
expect(insert?.payload).toMatchObject({
|
||||||
|
name: "Gamma",
|
||||||
|
shared_with: ["a@x.com", "b@x.com"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when a shared_with recipient is not a Mike user", async () => {
|
||||||
|
// No user_profiles rows seeded → findMissingUserEmails reports the
|
||||||
|
// recipient as unknown and the create is rejected before insert.
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/projects")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({ name: "Gamma", shared_with: ["ghost@x.com"] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.detail).toBe(
|
||||||
|
"ghost@x.com does not belong to a Mike user.",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
supabaseState.inserts.find((i) => i.table === "projects"),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the insert errors", async () => {
|
||||||
|
supabaseState.tables.projects = {
|
||||||
|
data: null,
|
||||||
|
error: { message: "insert failed" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/projects")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({ name: "Delta" });
|
||||||
|
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.detail).toBe("insert failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /projects/:projectId (detail, inline access) ──────────────────
|
||||||
|
describe("GET /projects/:projectId", () => {
|
||||||
|
it("returns 404 when the project does not exist", async () => {
|
||||||
|
supabaseState.tables.projects = { data: null, error: null };
|
||||||
|
|
||||||
|
const res = await request(app).get("/projects/p1").set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.detail).toBe("Project not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when the caller is neither owner nor shared", async () => {
|
||||||
|
supabaseState.tables.projects = {
|
||||||
|
data: {
|
||||||
|
id: "p1",
|
||||||
|
user_id: "someone-else",
|
||||||
|
shared_with: ["other@x.com"],
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await request(app).get("/projects/p1").set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.detail).toBe("Project not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grants access to a shared member (is_owner false)", async () => {
|
||||||
|
supabaseState.tables.projects = {
|
||||||
|
data: {
|
||||||
|
id: "p1",
|
||||||
|
user_id: "someone-else",
|
||||||
|
shared_with: ["u1@test.local"],
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
supabaseState.tables.documents = { data: [], error: null };
|
||||||
|
supabaseState.tables.project_subfolders = { data: [], error: null };
|
||||||
|
|
||||||
|
const res = await request(app).get("/projects/p1").set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toMatchObject({ id: "p1", is_owner: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 with documents/folders/is_owner when owned", async () => {
|
||||||
|
supabaseState.tables.projects = {
|
||||||
|
data: { id: "p1", user_id: "u1", shared_with: null },
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
supabaseState.tables.documents = {
|
||||||
|
data: [{ id: "d1", user_id: "u1" }],
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
supabaseState.tables.project_subfolders = {
|
||||||
|
data: [{ id: "f1" }],
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await request(app).get("/projects/p1").set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toMatchObject({
|
||||||
|
id: "p1",
|
||||||
|
is_owner: true,
|
||||||
|
documents: [{ id: "d1" }],
|
||||||
|
folders: [{ id: "f1" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /projects/:projectId/documents (checkProjectAccess guard) ─────
|
||||||
|
describe("GET /projects/:projectId/documents", () => {
|
||||||
|
it("returns 404 when checkProjectAccess denies access", async () => {
|
||||||
|
checkProjectAccess.mockResolvedValue({ ok: false });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get("/projects/p1/documents")
|
||||||
|
.set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.detail).toBe("Project not found");
|
||||||
|
expect(checkProjectAccess).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 with documents when access is granted", async () => {
|
||||||
|
supabaseState.tables.documents = {
|
||||||
|
data: [{ id: "d1" }, { id: "d2" }],
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get("/projects/p1/documents")
|
||||||
|
.set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual([{ id: "d1" }, { id: "d2" }]);
|
||||||
|
expect(checkProjectAccess).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── PATCH /projects/:projectId (sharing normalisation) ────────────────
|
||||||
|
describe("PATCH /projects/:projectId", () => {
|
||||||
|
it("returns 400 when sharing the project with yourself", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.patch("/projects/p1")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({ shared_with: ["u1@test.local"] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.detail).toBe(
|
||||||
|
"You cannot share a project with yourself.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when the update matches no owned project", async () => {
|
||||||
|
supabaseState.tables.projects = { data: null, error: null };
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.patch("/projects/p1")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({ name: "Renamed" });
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.detail).toBe("Project not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── DELETE /projects/:projectId ───────────────────────────────────────
|
||||||
|
describe("DELETE /projects/:projectId", () => {
|
||||||
|
it("returns 404 when nothing was deleted", async () => {
|
||||||
|
deleteUserProjects.mockResolvedValue(0);
|
||||||
|
|
||||||
|
const res = await request(app).delete("/projects/p1").set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.detail).toBe("Project not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 204 when the project is deleted", async () => {
|
||||||
|
deleteUserProjects.mockResolvedValue(1);
|
||||||
|
|
||||||
|
const res = await request(app).delete("/projects/p1").set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
// Signature is deleteUserProjects(db, userId, [projectId]).
|
||||||
|
expect(deleteUserProjects).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
"u1",
|
||||||
|
["p1"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when deletion throws", async () => {
|
||||||
|
deleteUserProjects.mockRejectedValue(new Error("cascade failed"));
|
||||||
|
|
||||||
|
const res = await request(app).delete("/projects/p1").set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.detail).toBe("cascade failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
146
backend/src/__tests__/integration/stack.supabase.test.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
// Stack-level integration test: exercises the REAL Supabase stack (GoTrue auth +
|
||||||
|
// Postgres RLS) rather than mocks. This is the harness that makes pinning a fixed
|
||||||
|
// Supabase version set safe — it's what you re-run on every image bump to prove
|
||||||
|
// the auth↔API contract and the deny-all RLS firewall still hold. It also anchors
|
||||||
|
// the security model's central claim: RLS denies the user/anon path, and the API
|
||||||
|
// reaches data only via the service-role key.
|
||||||
|
//
|
||||||
|
// Gated: skipped unless a stack is provided (default CI unit run skips it).
|
||||||
|
// Locally: `supabase start`, then export the printed keys as:
|
||||||
|
// SUPABASE_TEST_URL, SUPABASE_TEST_SERVICE_ROLE_KEY, SUPABASE_TEST_ANON_KEY
|
||||||
|
const url = process.env.SUPABASE_TEST_URL;
|
||||||
|
const serviceKey = process.env.SUPABASE_TEST_SERVICE_ROLE_KEY;
|
||||||
|
const anonKey = process.env.SUPABASE_TEST_ANON_KEY;
|
||||||
|
const maybeDescribe =
|
||||||
|
url && serviceKey && anonKey ? describe : describe.skip;
|
||||||
|
|
||||||
|
// Every public table the app owns (backend/schema.sql + migrations). The
|
||||||
|
// anon/user path must never return rows from any of these (deny-all); a
|
||||||
|
// regression that ships a table without RLS — or with a permissive policy —
|
||||||
|
// trips the leak sweep below. A table missing from an older local stack
|
||||||
|
// returns an error (no rows), which never counts as a leak.
|
||||||
|
const PUBLIC_TABLES = [
|
||||||
|
"chat_messages", "chats", "courtlistener_citation_index",
|
||||||
|
"courtlistener_opinion_cluster_index", "document_edits",
|
||||||
|
"document_versions", "documents", "hidden_workflows", "library_folders",
|
||||||
|
"project_subfolders", "projects", "tabular_cells",
|
||||||
|
"tabular_review_chat_messages", "tabular_review_chats", "tabular_reviews",
|
||||||
|
"user_api_keys", "user_mcp_connector_tools", "user_mcp_connectors",
|
||||||
|
"user_mcp_oauth_states", "user_mcp_oauth_tokens",
|
||||||
|
"user_mcp_tool_audit_logs", "user_profiles",
|
||||||
|
"workflow_open_source_submissions", "workflow_shares", "workflows",
|
||||||
|
];
|
||||||
|
|
||||||
|
maybeDescribe("Supabase stack — auth contract + RLS deny-all firewall", () => {
|
||||||
|
const password = "StackTest1!";
|
||||||
|
const emailA = `stack-a-${Date.now()}@test.local`;
|
||||||
|
const emailB = `stack-b-${Date.now()}@test.local`;
|
||||||
|
|
||||||
|
let admin: SupabaseClient; // service-role: BYPASSRLS, the app's data path
|
||||||
|
let userA = "";
|
||||||
|
let userB = "";
|
||||||
|
let tokenA = "";
|
||||||
|
let projectId = "";
|
||||||
|
|
||||||
|
// A client acting as a signed-in end user (anon key + the user's JWT): this is
|
||||||
|
// the path RLS must fence off.
|
||||||
|
const asUser = (token: string) =>
|
||||||
|
createClient(url!, anonKey!, {
|
||||||
|
auth: { persistSession: false, autoRefreshToken: false },
|
||||||
|
global: { headers: { Authorization: `Bearer ${token}` } },
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
admin = createClient(url!, serviceKey!, {
|
||||||
|
auth: { persistSession: false, autoRefreshToken: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const a = await admin.auth.admin.createUser({
|
||||||
|
email: emailA, password, email_confirm: true,
|
||||||
|
});
|
||||||
|
const b = await admin.auth.admin.createUser({
|
||||||
|
email: emailB, password, email_confirm: true,
|
||||||
|
});
|
||||||
|
if (a.error || !a.data.user) throw a.error ?? new Error("no user A");
|
||||||
|
if (b.error || !b.data.user) throw b.error ?? new Error("no user B");
|
||||||
|
userA = a.data.user.id;
|
||||||
|
userB = b.data.user.id;
|
||||||
|
|
||||||
|
// Sign in as A to get a real access token (the token the API middleware
|
||||||
|
// validates via auth.getUser).
|
||||||
|
const signIn = await createClient(url!, anonKey!, {
|
||||||
|
auth: { persistSession: false, autoRefreshToken: false },
|
||||||
|
}).auth.signInWithPassword({ email: emailA, password });
|
||||||
|
if (signIn.error || !signIn.data.session) {
|
||||||
|
throw signIn.error ?? new Error("no session for A");
|
||||||
|
}
|
||||||
|
tokenA = signIn.data.session.access_token;
|
||||||
|
|
||||||
|
// Seed one row owned by A via the service role (the app's real write path).
|
||||||
|
const proj = await admin
|
||||||
|
.from("projects")
|
||||||
|
.insert({ user_id: userA, name: "Stack Test Project" })
|
||||||
|
.select("id")
|
||||||
|
.single();
|
||||||
|
if (proj.error || !proj.data) throw proj.error ?? new Error("no project");
|
||||||
|
projectId = proj.data.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (projectId) await admin.from("projects").delete().eq("id", projectId);
|
||||||
|
if (userA) await admin.auth.admin.deleteUser(userA);
|
||||||
|
if (userB) await admin.auth.admin.deleteUser(userB);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auth contract: the access token resolves to its user (middleware path)", async () => {
|
||||||
|
const { data, error } = await admin.auth.getUser(tokenA);
|
||||||
|
expect(error).toBeNull();
|
||||||
|
expect(data.user?.id).toBe(userA);
|
||||||
|
expect(data.user?.email).toBe(emailA);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("RLS: the service role sees seeded rows the owner cannot see via the user path", async () => {
|
||||||
|
// Service role (app data path) sees the project…
|
||||||
|
const svc = await admin
|
||||||
|
.from("projects").select("id").eq("id", projectId);
|
||||||
|
expect(svc.error).toBeNull();
|
||||||
|
expect(svc.data ?? []).toHaveLength(1);
|
||||||
|
|
||||||
|
// …but the owner, going through the user/anon path, sees zero rows —
|
||||||
|
// deny-all RLS is the firewall; the app must use the service role.
|
||||||
|
const owner = await asUser(tokenA)
|
||||||
|
.from("projects").select("id").eq("id", projectId);
|
||||||
|
expect(owner.data ?? []).toHaveLength(0);
|
||||||
|
|
||||||
|
// And the owner's profile (if any) is equally invisible to the user path.
|
||||||
|
const prof = await asUser(tokenA)
|
||||||
|
.from("user_profiles").select("user_id").eq("user_id", userA);
|
||||||
|
expect(prof.data ?? []).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tenant isolation: user B cannot read user A's project via the user path", async () => {
|
||||||
|
const signInB = await createClient(url!, anonKey!, {
|
||||||
|
auth: { persistSession: false, autoRefreshToken: false },
|
||||||
|
}).auth.signInWithPassword({ email: emailB, password });
|
||||||
|
const tokenB = signInB.data.session!.access_token;
|
||||||
|
|
||||||
|
const cross = await asUser(tokenB)
|
||||||
|
.from("projects").select("id").eq("id", projectId);
|
||||||
|
expect(cross.data ?? []).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leak sweep: no public table returns rows to the authenticated user path", async () => {
|
||||||
|
const client = asUser(tokenA);
|
||||||
|
const leaks: string[] = [];
|
||||||
|
for (const table of PUBLIC_TABLES) {
|
||||||
|
const { data } = await client.from(table).select("*").limit(1);
|
||||||
|
if ((data ?? []).length > 0) leaks.push(table);
|
||||||
|
}
|
||||||
|
// Any table returning rows to a normal user means RLS is missing or a
|
||||||
|
// policy is permissive — the exact regression this guards against.
|
||||||
|
expect(leaks).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
713
backend/src/__tests__/integration/tabular.routes.test.ts
Normal file
|
|
@ -0,0 +1,713 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import request from "supertest";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hoisted mock fns reconfigured per-test. Access helpers + model settings are
|
||||||
|
// mocked so the tests drive review-access decisions, document-access filtering
|
||||||
|
// and the missing-API-key guard without touching real Supabase / LLM IO. The
|
||||||
|
// streaming endpoints (chat/generate) are only exercised up to their GUARDS —
|
||||||
|
// the SSE loop itself is never reached in these tests.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const {
|
||||||
|
ensureReviewAccess,
|
||||||
|
checkProjectAccess,
|
||||||
|
filterAccessibleDocumentIds,
|
||||||
|
getUserModelSettings,
|
||||||
|
loadActiveVersion,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
ensureReviewAccess: vi.fn(),
|
||||||
|
checkProjectAccess: vi.fn(),
|
||||||
|
filterAccessibleDocumentIds: vi.fn(),
|
||||||
|
getUserModelSettings: vi.fn(),
|
||||||
|
loadActiveVersion: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Configurable Supabase stub (mirrors projects.routes.test). Each test seeds
|
||||||
|
// `supabaseState` in beforeEach; terminal query operations resolve to the
|
||||||
|
// per-table result, rpc() resolves to a per-call result. Insert payloads are
|
||||||
|
// recorded so tests can assert on what got persisted.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
type QueryResult = { data: unknown; error: unknown };
|
||||||
|
|
||||||
|
let supabaseState: {
|
||||||
|
rpc: QueryResult;
|
||||||
|
tables: Record<string, QueryResult>;
|
||||||
|
inserts: { table: string; payload: unknown }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function resetSupabaseState() {
|
||||||
|
supabaseState = {
|
||||||
|
rpc: { data: [], error: null },
|
||||||
|
tables: {},
|
||||||
|
inserts: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
resetSupabaseState();
|
||||||
|
|
||||||
|
function resultForTable(table: string): QueryResult {
|
||||||
|
return supabaseState.tables[table] ?? { data: null, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeQuery(table: string) {
|
||||||
|
const q: Record<string, unknown> = {};
|
||||||
|
const chain = [
|
||||||
|
"select", "update", "delete", "upsert",
|
||||||
|
"eq", "neq", "in", "is", "or", "not", "lt", "gt", "gte", "lte",
|
||||||
|
"filter", "order", "limit", "range", "contains",
|
||||||
|
];
|
||||||
|
for (const m of chain) q[m] = vi.fn(() => q);
|
||||||
|
q.insert = vi.fn((payload: unknown) => {
|
||||||
|
supabaseState.inserts.push({ table, payload });
|
||||||
|
return q;
|
||||||
|
});
|
||||||
|
q.single = vi.fn(() => Promise.resolve(resultForTable(table)));
|
||||||
|
q.maybeSingle = vi.fn(() => Promise.resolve(resultForTable(table)));
|
||||||
|
q.then = (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) =>
|
||||||
|
Promise.resolve(resultForTable(table)).then(resolve, reject);
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockSupabase() {
|
||||||
|
return {
|
||||||
|
from: vi.fn((table: string) => makeQuery(table)),
|
||||||
|
rpc: vi.fn(() => Promise.resolve(supabaseState.rpc)),
|
||||||
|
auth: {
|
||||||
|
getUser: () =>
|
||||||
|
Promise.resolve({ data: { user: { id: "u1" } }, error: null }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock("../../lib/supabase", () => ({
|
||||||
|
createServerSupabase: vi.fn(() => mockSupabase()),
|
||||||
|
getUserIdFromRequest: vi.fn(async () => "u1"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../middleware/auth", () => ({
|
||||||
|
requireAuth: (
|
||||||
|
_req: unknown,
|
||||||
|
res: { locals: Record<string, unknown> },
|
||||||
|
next: () => void,
|
||||||
|
) => {
|
||||||
|
res.locals.userId = "u1";
|
||||||
|
res.locals.userEmail = "u1@test.local";
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
requireMfaIfEnrolled: (_req: unknown, _res: unknown, next: () => void) =>
|
||||||
|
next(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../lib/access", () => ({
|
||||||
|
ensureReviewAccess: (...args: unknown[]) => ensureReviewAccess(...args),
|
||||||
|
checkProjectAccess: (...args: unknown[]) => checkProjectAccess(...args),
|
||||||
|
filterAccessibleDocumentIds: (...args: unknown[]) =>
|
||||||
|
filterAccessibleDocumentIds(...args),
|
||||||
|
ensureDocAccess: vi.fn(async () => ({ ok: true, isOwner: true })),
|
||||||
|
listAccessibleProjectIds: vi.fn(async () => []),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../lib/userSettings", () => ({
|
||||||
|
getUserModelSettings: (...args: unknown[]) => getUserModelSettings(...args),
|
||||||
|
getUserApiKeys: vi.fn(async () => ({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Version-path enrichment + active-version resolution hit the DB in real life;
|
||||||
|
// no-op them so route responses are driven purely by the table stubs.
|
||||||
|
vi.mock("../../lib/documentVersions", () => ({
|
||||||
|
attachActiveVersionPaths: vi.fn(async () => {}),
|
||||||
|
attachLatestVersionNumbers: vi.fn(async () => {}),
|
||||||
|
loadActiveVersion: (...args: unknown[]) => loadActiveVersion(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { app } from "../../app";
|
||||||
|
|
||||||
|
const AUTH = ["Authorization", "Bearer test"] as const;
|
||||||
|
|
||||||
|
describe("tabular.routes", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
resetSupabaseState();
|
||||||
|
// Default: caller is the owner with full access.
|
||||||
|
ensureReviewAccess.mockResolvedValue({ ok: true, isOwner: true });
|
||||||
|
checkProjectAccess.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
isOwner: true,
|
||||||
|
project: { id: "p1", user_id: "u1", shared_with: null },
|
||||||
|
});
|
||||||
|
// Default: every requested doc is accessible (identity passthrough).
|
||||||
|
filterAccessibleDocumentIds.mockImplementation(
|
||||||
|
async (ids: string[]) => ids,
|
||||||
|
);
|
||||||
|
getUserModelSettings.mockResolvedValue({
|
||||||
|
title_model: "claude-haiku-4-5",
|
||||||
|
tabular_model: "claude-sonnet-4-5",
|
||||||
|
legal_research_us: false,
|
||||||
|
api_keys: { claude: "sk-test" },
|
||||||
|
});
|
||||||
|
loadActiveVersion.mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /tabular-review (overview) ────────────────────────────────────
|
||||||
|
describe("GET /tabular-review", () => {
|
||||||
|
it("returns the overview rows from the RPC", async () => {
|
||||||
|
supabaseState.rpc = {
|
||||||
|
data: [{ id: "r1", title: "Alpha" }],
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await request(app).get("/tabular-review").set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual([{ id: "r1", title: "Alpha" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 with detail when the RPC errors", async () => {
|
||||||
|
supabaseState.rpc = { data: null, error: { message: "boom" } };
|
||||||
|
|
||||||
|
const res = await request(app).get("/tabular-review").set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.detail).toBe("boom");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST /tabular-review (create) ─────────────────────────────────────
|
||||||
|
describe("POST /tabular-review", () => {
|
||||||
|
it("creates a review (201) and only persists accessible documents", async () => {
|
||||||
|
supabaseState.tables.tabular_reviews = {
|
||||||
|
data: { id: "r9", title: "Gamma", document_ids: ["d1"] },
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
// d2 is not accessible — it must be filtered out of the insert.
|
||||||
|
filterAccessibleDocumentIds.mockResolvedValue(["d1"]);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/tabular-review")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({
|
||||||
|
title: "Gamma",
|
||||||
|
document_ids: ["d1", "d2"],
|
||||||
|
columns_config: [{ index: 0, name: "Col", prompt: "p" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body).toMatchObject({ id: "r9" });
|
||||||
|
|
||||||
|
const reviewInsert = supabaseState.inserts.find(
|
||||||
|
(i) => i.table === "tabular_reviews",
|
||||||
|
);
|
||||||
|
expect(reviewInsert?.payload).toMatchObject({
|
||||||
|
document_ids: ["d1"],
|
||||||
|
});
|
||||||
|
// Cells are created for accessible docs × columns only (1 × 1).
|
||||||
|
const cellInsert = supabaseState.inserts.find(
|
||||||
|
(i) => i.table === "tabular_cells",
|
||||||
|
);
|
||||||
|
expect(cellInsert?.payload).toEqual([
|
||||||
|
{
|
||||||
|
review_id: "r9",
|
||||||
|
document_id: "d1",
|
||||||
|
column_index: 0,
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when project access is denied", async () => {
|
||||||
|
checkProjectAccess.mockResolvedValue({ ok: false });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/tabular-review")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({
|
||||||
|
project_id: "p-nope",
|
||||||
|
document_ids: [],
|
||||||
|
columns_config: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.detail).toBe("Project not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the review insert errors", async () => {
|
||||||
|
supabaseState.tables.tabular_reviews = {
|
||||||
|
data: null,
|
||||||
|
error: { message: "insert failed" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/tabular-review")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({ document_ids: [], columns_config: [] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.detail).toBe("insert failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /tabular-review/:reviewId (detail) ────────────────────────────
|
||||||
|
describe("GET /tabular-review/:reviewId", () => {
|
||||||
|
it("returns 404 when the review does not exist", async () => {
|
||||||
|
supabaseState.tables.tabular_reviews = { data: null, error: null };
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get("/tabular-review/r1")
|
||||||
|
.set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.detail).toBe("Review not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when review access is denied", async () => {
|
||||||
|
supabaseState.tables.tabular_reviews = {
|
||||||
|
data: { id: "r1", user_id: "other", project_id: null },
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
ensureReviewAccess.mockResolvedValue({ ok: false });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get("/tabular-review/r1")
|
||||||
|
.set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.detail).toBe("Review not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 with review/cells/documents + is_owner", async () => {
|
||||||
|
supabaseState.tables.tabular_reviews = {
|
||||||
|
data: {
|
||||||
|
id: "r1",
|
||||||
|
user_id: "u1",
|
||||||
|
project_id: null,
|
||||||
|
document_ids: ["d1"],
|
||||||
|
columns_config: [],
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
supabaseState.tables.tabular_cells = {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "c1",
|
||||||
|
document_id: "d1",
|
||||||
|
column_index: 0,
|
||||||
|
content: null,
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
supabaseState.tables.documents = {
|
||||||
|
data: [{ id: "d1", current_version_id: null }],
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get("/tabular-review/r1")
|
||||||
|
.set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.review).toMatchObject({ id: "r1", is_owner: true });
|
||||||
|
expect(res.body.cells).toHaveLength(1);
|
||||||
|
expect(res.body.documents).toEqual([
|
||||||
|
{ id: "d1", current_version_id: null },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── PATCH /tabular-review/:reviewId ───────────────────────────────────
|
||||||
|
describe("PATCH /tabular-review/:reviewId", () => {
|
||||||
|
it("returns 400 when project_id is an invalid type", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.patch("/tabular-review/r1")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({ project_id: 123 });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.detail).toBe(
|
||||||
|
"project_id must be a non-empty string or null",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when sharing the review with yourself", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.patch("/tabular-review/r1")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({ shared_with: ["U1@Test.Local"] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.detail).toBe(
|
||||||
|
"You cannot share a tabular review with yourself.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when the review does not exist", async () => {
|
||||||
|
supabaseState.tables.tabular_reviews = { data: null, error: null };
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.patch("/tabular-review/r1")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({ title: "Renamed" });
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.detail).toBe("Review not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 403 when a non-owner edits columns_config", async () => {
|
||||||
|
supabaseState.tables.tabular_reviews = {
|
||||||
|
data: { id: "r1", user_id: "other", project_id: "p1" },
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
ensureReviewAccess.mockResolvedValue({ ok: true, isOwner: false });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.patch("/tabular-review/r1")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({ columns_config: [{ index: 0, name: "X", prompt: "p" }] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.detail).toBe("Only the review owner can change columns");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── DELETE /tabular-review/:reviewId ──────────────────────────────────
|
||||||
|
describe("DELETE /tabular-review/:reviewId", () => {
|
||||||
|
it("returns 204 on success", async () => {
|
||||||
|
supabaseState.tables.tabular_reviews = { data: null, error: null };
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete("/tabular-review/r1")
|
||||||
|
.set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the delete errors", async () => {
|
||||||
|
supabaseState.tables.tabular_reviews = {
|
||||||
|
data: null,
|
||||||
|
error: { message: "delete failed" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete("/tabular-review/r1")
|
||||||
|
.set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.detail).toBe("delete failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST /tabular-review/:reviewId/clear-cells ────────────────────────
|
||||||
|
describe("POST /tabular-review/:reviewId/clear-cells", () => {
|
||||||
|
it("returns 400 when document_ids is missing", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/tabular-review/r1/clear-cells")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.detail).toBe("document_ids is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when review access is denied", async () => {
|
||||||
|
supabaseState.tables.tabular_reviews = {
|
||||||
|
data: { id: "r1", user_id: "other", project_id: null },
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
ensureReviewAccess.mockResolvedValue({ ok: false });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/tabular-review/r1/clear-cells")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({ document_ids: ["d1"] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.detail).toBe("Review not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 204 on success", async () => {
|
||||||
|
supabaseState.tables.tabular_reviews = {
|
||||||
|
data: { id: "r1", user_id: "u1", project_id: null },
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/tabular-review/r1/clear-cells")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({ document_ids: ["d1"] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST /tabular-review/:reviewId/regenerate-cell ────────────────────
|
||||||
|
describe("POST /tabular-review/:reviewId/regenerate-cell", () => {
|
||||||
|
it("returns 400 when document_id / column_index are missing", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/tabular-review/r1/regenerate-cell")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.detail).toBe(
|
||||||
|
"document_id and column_index are required",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when review access is denied", async () => {
|
||||||
|
supabaseState.tables.tabular_reviews = {
|
||||||
|
data: { id: "r1", user_id: "other", project_id: null },
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
ensureReviewAccess.mockResolvedValue({ ok: false });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/tabular-review/r1/regenerate-cell")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({ document_id: "d1", column_index: 0 });
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.detail).toBe("Review not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when the column is not configured", async () => {
|
||||||
|
supabaseState.tables.tabular_reviews = {
|
||||||
|
data: {
|
||||||
|
id: "r1",
|
||||||
|
user_id: "u1",
|
||||||
|
project_id: null,
|
||||||
|
columns_config: [{ index: 5, name: "Other", prompt: "p" }],
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/tabular-review/r1/regenerate-cell")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({ document_id: "d1", column_index: 0 });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.detail).toBe("Column not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when the document is not accessible", async () => {
|
||||||
|
supabaseState.tables.tabular_reviews = {
|
||||||
|
data: {
|
||||||
|
id: "r1",
|
||||||
|
user_id: "u1",
|
||||||
|
project_id: null,
|
||||||
|
columns_config: [{ index: 0, name: "Col", prompt: "p" }],
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
filterAccessibleDocumentIds.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/tabular-review/r1/regenerate-cell")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({ document_id: "d-forbidden", column_index: 0 });
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.detail).toBe("Document not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 422 with missing_api_key when the model key is absent", async () => {
|
||||||
|
supabaseState.tables.tabular_reviews = {
|
||||||
|
data: {
|
||||||
|
id: "r1",
|
||||||
|
user_id: "u1",
|
||||||
|
project_id: null,
|
||||||
|
columns_config: [{ index: 0, name: "Col", prompt: "p" }],
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
supabaseState.tables.documents = {
|
||||||
|
data: { id: "d1", current_version_id: null },
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
getUserModelSettings.mockResolvedValue({
|
||||||
|
title_model: "claude-haiku-4-5",
|
||||||
|
tabular_model: "claude-sonnet-4-5",
|
||||||
|
legal_research_us: false,
|
||||||
|
api_keys: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/tabular-review/r1/regenerate-cell")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({ document_id: "d1", column_index: 0 });
|
||||||
|
|
||||||
|
expect(res.status).toBe(422);
|
||||||
|
expect(res.body.code).toBe("missing_api_key");
|
||||||
|
expect(res.body.provider).toBe("claude");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST /tabular-review/:reviewId/generate (streaming GUARDS only) ───
|
||||||
|
describe("POST /tabular-review/:reviewId/generate", () => {
|
||||||
|
it("returns 404 when the review does not exist", async () => {
|
||||||
|
supabaseState.tables.tabular_reviews = { data: null, error: null };
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/tabular-review/r1/generate")
|
||||||
|
.set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.detail).toBe("Review not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when review access is denied", async () => {
|
||||||
|
supabaseState.tables.tabular_reviews = {
|
||||||
|
data: { id: "r1", user_id: "other", project_id: null },
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
ensureReviewAccess.mockResolvedValue({ ok: false });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/tabular-review/r1/generate")
|
||||||
|
.set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.detail).toBe("Review not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when no columns are configured", async () => {
|
||||||
|
supabaseState.tables.tabular_reviews = {
|
||||||
|
data: {
|
||||||
|
id: "r1",
|
||||||
|
user_id: "u1",
|
||||||
|
project_id: null,
|
||||||
|
columns_config: [],
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/tabular-review/r1/generate")
|
||||||
|
.set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.detail).toBe("No columns configured");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 422 missing_api_key before streaming when the key is absent", async () => {
|
||||||
|
supabaseState.tables.tabular_reviews = {
|
||||||
|
data: {
|
||||||
|
id: "r1",
|
||||||
|
user_id: "u1",
|
||||||
|
project_id: null,
|
||||||
|
columns_config: [{ index: 0, name: "Col", prompt: "p" }],
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
supabaseState.tables.tabular_cells = { data: [], error: null };
|
||||||
|
getUserModelSettings.mockResolvedValue({
|
||||||
|
title_model: "claude-haiku-4-5",
|
||||||
|
tabular_model: "claude-sonnet-4-5",
|
||||||
|
legal_research_us: false,
|
||||||
|
api_keys: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/tabular-review/r1/generate")
|
||||||
|
.set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(422);
|
||||||
|
expect(res.body.code).toBe("missing_api_key");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST /tabular-review/:reviewId/chat (streaming GUARDS only) ───────
|
||||||
|
describe("POST /tabular-review/:reviewId/chat", () => {
|
||||||
|
it("returns 400 when no user message is present", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/tabular-review/r1/chat")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({ messages: [{ role: "assistant", content: "hi" }] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.detail).toBe("messages must include a user message");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when review access is denied", async () => {
|
||||||
|
supabaseState.tables.tabular_reviews = {
|
||||||
|
data: { id: "r1", user_id: "other", project_id: null },
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
ensureReviewAccess.mockResolvedValue({ ok: false });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/tabular-review/r1/chat")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({ messages: [{ role: "user", content: "hello" }] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.detail).toBe("Review not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 422 missing_api_key before streaming when the key is absent", async () => {
|
||||||
|
supabaseState.tables.tabular_reviews = {
|
||||||
|
data: {
|
||||||
|
id: "r1",
|
||||||
|
user_id: "u1",
|
||||||
|
project_id: null,
|
||||||
|
columns_config: [],
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
supabaseState.tables.tabular_cells = { data: [], error: null };
|
||||||
|
getUserModelSettings.mockResolvedValue({
|
||||||
|
title_model: "claude-haiku-4-5",
|
||||||
|
tabular_model: "claude-sonnet-4-5",
|
||||||
|
legal_research_us: false,
|
||||||
|
api_keys: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/tabular-review/r1/chat")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({ messages: [{ role: "user", content: "hello" }] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(422);
|
||||||
|
expect(res.body.code).toBe("missing_api_key");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /tabular-review/:reviewId/chats ───────────────────────────────
|
||||||
|
describe("GET /tabular-review/:reviewId/chats", () => {
|
||||||
|
it("returns 404 when review access is denied", async () => {
|
||||||
|
supabaseState.tables.tabular_reviews = {
|
||||||
|
data: { id: "r1", user_id: "other", project_id: null },
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
ensureReviewAccess.mockResolvedValue({ ok: false });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get("/tabular-review/r1/chats")
|
||||||
|
.set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.detail).toBe("Review not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the chat list when access is granted", async () => {
|
||||||
|
supabaseState.tables.tabular_reviews = {
|
||||||
|
data: { id: "r1", user_id: "u1", project_id: null },
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
supabaseState.tables.tabular_review_chats = {
|
||||||
|
data: [{ id: "chat-1", title: "T", user_id: "u1" }],
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get("/tabular-review/r1/chats")
|
||||||
|
.set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual([
|
||||||
|
{ id: "chat-1", title: "T", user_id: "u1" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
588
backend/src/__tests__/integration/user.routes.test.ts
Normal file
|
|
@ -0,0 +1,588 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import request from "supertest";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hoisted mock fns we reconfigure per-test. These cover the three security
|
||||||
|
// surfaces this suite baselines:
|
||||||
|
// - the MFA route guard (requireMfaIfEnrolled)
|
||||||
|
// - the API-key crypto boundary (userApiKeys)
|
||||||
|
// - the destructive data export / deletion helpers (userDataExport /
|
||||||
|
// userDataCleanup)
|
||||||
|
// Each is a vi.fn so we can both reconfigure behaviour and assert call args.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const {
|
||||||
|
requireMfaIfEnrolled,
|
||||||
|
getUserApiKeyStatus,
|
||||||
|
saveUserApiKey,
|
||||||
|
hasEnvApiKey,
|
||||||
|
normalizeApiKeyProvider,
|
||||||
|
deleteAllUserChats,
|
||||||
|
deleteAllUserTabularReviews,
|
||||||
|
deleteUserAccountData,
|
||||||
|
deleteUserProjects,
|
||||||
|
buildUserAccountExport,
|
||||||
|
buildUserChatsExport,
|
||||||
|
buildUserTabularReviewsExport,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
requireMfaIfEnrolled: vi.fn(),
|
||||||
|
getUserApiKeyStatus: vi.fn(),
|
||||||
|
saveUserApiKey: vi.fn(),
|
||||||
|
hasEnvApiKey: vi.fn(),
|
||||||
|
normalizeApiKeyProvider: vi.fn(),
|
||||||
|
deleteAllUserChats: vi.fn(),
|
||||||
|
deleteAllUserTabularReviews: vi.fn(),
|
||||||
|
deleteUserAccountData: vi.fn(),
|
||||||
|
deleteUserProjects: vi.fn(),
|
||||||
|
buildUserAccountExport: vi.fn(),
|
||||||
|
buildUserChatsExport: vi.fn(),
|
||||||
|
buildUserTabularReviewsExport: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Configurable Supabase stub. The only route in this suite that reaches the
|
||||||
|
// DB directly is GET /user/profile (via loadProfile → selectProfile). Tests
|
||||||
|
// seed `supabaseState.tables.user_profiles`; terminal query ops resolve to the
|
||||||
|
// per-table result and auth.admin methods are stubbed where routes call them.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
type QueryResult = { data: unknown; error: unknown };
|
||||||
|
|
||||||
|
let supabaseState: {
|
||||||
|
tables: Record<string, QueryResult>;
|
||||||
|
adminGetUserById: QueryResult;
|
||||||
|
adminDeleteUser: { error: unknown };
|
||||||
|
};
|
||||||
|
|
||||||
|
function resetSupabaseState() {
|
||||||
|
supabaseState = {
|
||||||
|
tables: {},
|
||||||
|
adminGetUserById: {
|
||||||
|
data: { user: { id: "u1", factors: [] } },
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
adminDeleteUser: { error: null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
resetSupabaseState();
|
||||||
|
|
||||||
|
function resultForTable(table: string): QueryResult {
|
||||||
|
return supabaseState.tables[table] ?? { data: null, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeQuery(table: string) {
|
||||||
|
const q: Record<string, unknown> = {};
|
||||||
|
const chain = [
|
||||||
|
"select", "update", "delete", "upsert", "insert",
|
||||||
|
"eq", "neq", "in", "is", "or", "not", "lt", "gt", "gte", "lte",
|
||||||
|
"filter", "order", "limit", "range", "contains",
|
||||||
|
];
|
||||||
|
for (const m of chain) q[m] = vi.fn(() => q);
|
||||||
|
q.single = vi.fn(() => Promise.resolve(resultForTable(table)));
|
||||||
|
q.maybeSingle = vi.fn(() => Promise.resolve(resultForTable(table)));
|
||||||
|
q.then = (
|
||||||
|
resolve: (v: unknown) => unknown,
|
||||||
|
reject?: (e: unknown) => unknown,
|
||||||
|
) => Promise.resolve(resultForTable(table)).then(resolve, reject);
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockSupabase() {
|
||||||
|
return {
|
||||||
|
from: vi.fn((table: string) => makeQuery(table)),
|
||||||
|
rpc: vi.fn(() => Promise.resolve({ data: null, error: null })),
|
||||||
|
auth: {
|
||||||
|
getUser: () =>
|
||||||
|
Promise.resolve({ data: { user: { id: "u1" } }, error: null }),
|
||||||
|
admin: {
|
||||||
|
getUserById: vi.fn(() =>
|
||||||
|
Promise.resolve(supabaseState.adminGetUserById),
|
||||||
|
),
|
||||||
|
deleteUser: vi.fn(() =>
|
||||||
|
Promise.resolve(supabaseState.adminDeleteUser),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock("../../lib/supabase", () => ({
|
||||||
|
createServerSupabase: vi.fn(() => mockSupabase()),
|
||||||
|
getUserIdFromRequest: vi.fn(async () => "u1"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// requireAuth always authenticates u1. requireMfaIfEnrolled is a reconfigurable
|
||||||
|
// guard so we can drive both the satisfied (next()) and rejected
|
||||||
|
// (403 mfa_verification_required) paths.
|
||||||
|
vi.mock("../../middleware/auth", () => ({
|
||||||
|
requireAuth: (
|
||||||
|
_req: unknown,
|
||||||
|
res: { locals: Record<string, unknown> },
|
||||||
|
next: () => void,
|
||||||
|
) => {
|
||||||
|
res.locals.userId = "u1";
|
||||||
|
res.locals.userEmail = "u1@test.local";
|
||||||
|
res.locals.token = "test-token";
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
requireMfaIfEnrolled: (req: unknown, res: unknown, next: () => void) =>
|
||||||
|
requireMfaIfEnrolled(req, res, next),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// API-key crypto boundary: the route must funnel writes through saveUserApiKey
|
||||||
|
// (which encrypts) and never echo plaintext — getUserApiKeyStatus returns
|
||||||
|
// presence-only booleans. getUserApiKeys must be exported too — lib/userSettings
|
||||||
|
// imports it at module load.
|
||||||
|
vi.mock("../../lib/userApiKeys", () => ({
|
||||||
|
getUserApiKeyStatus: (...args: unknown[]) => getUserApiKeyStatus(...args),
|
||||||
|
saveUserApiKey: (...args: unknown[]) => saveUserApiKey(...args),
|
||||||
|
hasEnvApiKey: (...args: unknown[]) => hasEnvApiKey(...args),
|
||||||
|
normalizeApiKeyProvider: (...args: unknown[]) =>
|
||||||
|
normalizeApiKeyProvider(...args),
|
||||||
|
getUserApiKeys: vi.fn(async () => ({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../lib/userDataCleanup", () => ({
|
||||||
|
deleteAllUserChats: (...args: unknown[]) => deleteAllUserChats(...args),
|
||||||
|
deleteAllUserTabularReviews: (...args: unknown[]) =>
|
||||||
|
deleteAllUserTabularReviews(...args),
|
||||||
|
deleteUserAccountData: (...args: unknown[]) =>
|
||||||
|
deleteUserAccountData(...args),
|
||||||
|
deleteUserProjects: (...args: unknown[]) => deleteUserProjects(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../lib/userDataExport", () => ({
|
||||||
|
buildUserAccountExport: (...args: unknown[]) =>
|
||||||
|
buildUserAccountExport(...args),
|
||||||
|
buildUserChatsExport: (...args: unknown[]) => buildUserChatsExport(...args),
|
||||||
|
buildUserTabularReviewsExport: (...args: unknown[]) =>
|
||||||
|
buildUserTabularReviewsExport(...args),
|
||||||
|
userExportFilename: (kind: string, userId: string) =>
|
||||||
|
`mike-${kind}-export-${userId.slice(0, 8)}.json`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { app } from "../../app";
|
||||||
|
|
||||||
|
const AUTH = ["Authorization", "Bearer test"] as const;
|
||||||
|
|
||||||
|
// A complete user_profiles row with credits_reset_date in the future so the
|
||||||
|
// monthly-reset branch in loadProfile is not triggered.
|
||||||
|
function profileRow(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
display_name: "Ada",
|
||||||
|
organisation: "Acme",
|
||||||
|
message_credits_used: 3,
|
||||||
|
credits_reset_date: "2999-01-01T00:00:00.000Z",
|
||||||
|
tier: "Pro",
|
||||||
|
title_model: null,
|
||||||
|
tabular_model: "gemini-3-flash-preview",
|
||||||
|
mfa_on_login: false,
|
||||||
|
legal_research_us: true,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS = { claude: true, openai: false, gemini: false, sources: {} };
|
||||||
|
|
||||||
|
// The exact 403 body the web client's MFA gate consumes (mirrors the real
|
||||||
|
// requireMfaIfEnrolled). Used by tests that simulate an unsatisfied factor.
|
||||||
|
function rejectMfa(_req: unknown, res: any) {
|
||||||
|
res.status(403).json({
|
||||||
|
code: "mfa_verification_required",
|
||||||
|
detail: "MFA verification required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("user.routes", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
resetSupabaseState();
|
||||||
|
// Default: MFA satisfied (guard passes through).
|
||||||
|
requireMfaIfEnrolled.mockImplementation(
|
||||||
|
(_req: unknown, _res: unknown, next: () => void) => next(),
|
||||||
|
);
|
||||||
|
getUserApiKeyStatus.mockResolvedValue(STATUS);
|
||||||
|
saveUserApiKey.mockResolvedValue(undefined);
|
||||||
|
hasEnvApiKey.mockReturnValue(false);
|
||||||
|
normalizeApiKeyProvider.mockImplementation((v: string) =>
|
||||||
|
["claude", "openai", "gemini"].includes(v) ? v : null,
|
||||||
|
);
|
||||||
|
deleteAllUserChats.mockResolvedValue(undefined);
|
||||||
|
deleteAllUserTabularReviews.mockResolvedValue(undefined);
|
||||||
|
deleteUserAccountData.mockResolvedValue(undefined);
|
||||||
|
deleteUserProjects.mockResolvedValue(undefined);
|
||||||
|
buildUserAccountExport.mockResolvedValue({ account: "data" });
|
||||||
|
buildUserChatsExport.mockResolvedValue({ chats: "data" });
|
||||||
|
buildUserTabularReviewsExport.mockResolvedValue({ reviews: "data" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /user/profile (MFA bootstrap path) ────────────────────────────
|
||||||
|
describe("GET /user/profile", () => {
|
||||||
|
it("returns the serialized profile plus apiKeyStatus", async () => {
|
||||||
|
supabaseState.tables.user_profiles = {
|
||||||
|
data: profileRow(),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await request(app).get("/user/profile").set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toMatchObject({
|
||||||
|
displayName: "Ada",
|
||||||
|
organisation: "Acme",
|
||||||
|
messageCreditsUsed: 3,
|
||||||
|
tier: "Pro",
|
||||||
|
legalResearchUs: true,
|
||||||
|
mfaOnLogin: false,
|
||||||
|
apiKeyStatus: STATUS,
|
||||||
|
});
|
||||||
|
// Presence-only key status — never plaintext.
|
||||||
|
expect(JSON.stringify(res.body)).not.toContain("sk-");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is NOT guarded by requireMfaIfEnrolled (bootstrap route)", async () => {
|
||||||
|
// Even if the MFA factor were unsatisfied, profile must remain
|
||||||
|
// reachable so the client can render the verification gate.
|
||||||
|
requireMfaIfEnrolled.mockImplementation(rejectMfa);
|
||||||
|
supabaseState.tables.user_profiles = {
|
||||||
|
data: profileRow(),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await request(app).get("/user/profile").set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(requireMfaIfEnrolled).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 with detail when the profile load errors", async () => {
|
||||||
|
supabaseState.tables.user_profiles = {
|
||||||
|
data: null,
|
||||||
|
error: { message: "db down" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await request(app).get("/user/profile").set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.detail).toBe("db down");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST /user/profile (bootstrap upsert) ─────────────────────────────
|
||||||
|
describe("POST /user/profile", () => {
|
||||||
|
it("ensures the profile row and returns ok", async () => {
|
||||||
|
const res = await request(app).post("/user/profile").set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ ok: true });
|
||||||
|
expect(requireMfaIfEnrolled).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /user/api-keys (presence without plaintext) ───────────────────
|
||||||
|
describe("GET /user/api-keys", () => {
|
||||||
|
it("returns the boolean key-status map", async () => {
|
||||||
|
const res = await request(app).get("/user/api-keys").set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual(STATUS);
|
||||||
|
expect(getUserApiKeyStatus).toHaveBeenCalledWith(
|
||||||
|
"u1",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── PUT /user/api-keys/:provider (crypto + MFA guard) ─────────────────
|
||||||
|
describe("PUT /user/api-keys/:provider", () => {
|
||||||
|
it("stores the key via the encryption helper and returns status", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.put("/user/api-keys/claude")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({ api_key: "sk-secret-value" });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual(STATUS);
|
||||||
|
// The plaintext key must go through saveUserApiKey (the encryption
|
||||||
|
// boundary), keyed by provider + value, never persisted by the route.
|
||||||
|
expect(saveUserApiKey).toHaveBeenCalledWith(
|
||||||
|
"u1",
|
||||||
|
"claude",
|
||||||
|
"sk-secret-value",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes the key when api_key is omitted (null value)", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.put("/user/api-keys/openai")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(saveUserApiKey).toHaveBeenCalledWith(
|
||||||
|
"u1",
|
||||||
|
"openai",
|
||||||
|
null,
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 for an unsupported provider", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.put("/user/api-keys/bogus")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({ api_key: "x" });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.detail).toBe("Unsupported provider");
|
||||||
|
expect(saveUserApiKey).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 409 when the provider is configured by the server env", async () => {
|
||||||
|
hasEnvApiKey.mockReturnValue(true);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put("/user/api-keys/claude")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({ api_key: "sk-x" });
|
||||||
|
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(saveUserApiKey).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when saving the key throws", async () => {
|
||||||
|
saveUserApiKey.mockRejectedValue(new Error("kms unavailable"));
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put("/user/api-keys/claude")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({ api_key: "sk-x" });
|
||||||
|
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.detail).toBe("kms unavailable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is rejected with 403 mfa_verification_required when MFA is unsatisfied", async () => {
|
||||||
|
requireMfaIfEnrolled.mockImplementation(rejectMfa);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put("/user/api-keys/claude")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({ api_key: "sk-x" });
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body).toEqual({
|
||||||
|
code: "mfa_verification_required",
|
||||||
|
detail: "MFA verification required",
|
||||||
|
});
|
||||||
|
// Guarded: the crypto path is never reached.
|
||||||
|
expect(saveUserApiKey).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Data export endpoints (MFA-guarded, attachment headers) ───────────
|
||||||
|
describe("data export endpoints", () => {
|
||||||
|
it("GET /user/export returns the account export as a JSON attachment", async () => {
|
||||||
|
const res = await request(app).get("/user/export").set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ account: "data" });
|
||||||
|
expect(res.headers["content-type"]).toContain("application/json");
|
||||||
|
expect(res.headers["content-disposition"]).toContain("attachment");
|
||||||
|
expect(res.headers["content-disposition"]).toContain(
|
||||||
|
"mike-account-export-u1.json",
|
||||||
|
);
|
||||||
|
expect(buildUserAccountExport).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
"u1",
|
||||||
|
"u1@test.local",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /user/chats/export returns the chats export", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get("/user/chats/export")
|
||||||
|
.set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ chats: "data" });
|
||||||
|
expect(res.headers["content-disposition"]).toContain(
|
||||||
|
"mike-chats-export-u1.json",
|
||||||
|
);
|
||||||
|
expect(buildUserChatsExport).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /user/tabular-reviews/export returns the reviews export", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get("/user/tabular-reviews/export")
|
||||||
|
.set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ reviews: "data" });
|
||||||
|
expect(res.headers["content-disposition"]).toContain(
|
||||||
|
"mike-tabular-reviews-export-u1.json",
|
||||||
|
);
|
||||||
|
expect(buildUserTabularReviewsExport).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /user/export returns 500 when the builder throws", async () => {
|
||||||
|
buildUserAccountExport.mockRejectedValue(new Error("export boom"));
|
||||||
|
|
||||||
|
const res = await request(app).get("/user/export").set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.detail).toBe("export boom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /user/export is rejected when MFA is unsatisfied", async () => {
|
||||||
|
requireMfaIfEnrolled.mockImplementation(rejectMfa);
|
||||||
|
|
||||||
|
const res = await request(app).get("/user/export").set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.code).toBe("mfa_verification_required");
|
||||||
|
expect(buildUserAccountExport).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Data deletion endpoints (MFA-guarded, cleanup helpers) ────────────
|
||||||
|
describe("data deletion endpoints", () => {
|
||||||
|
it("DELETE /user/chats invokes deleteAllUserChats and returns 204", async () => {
|
||||||
|
const res = await request(app).delete("/user/chats").set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
expect(deleteAllUserChats).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
"u1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DELETE /user/projects invokes deleteUserProjects and returns 204", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.delete("/user/projects")
|
||||||
|
.set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
expect(deleteUserProjects).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
"u1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DELETE /user/tabular-reviews invokes the cleanup helper and returns 204", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.delete("/user/tabular-reviews")
|
||||||
|
.set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
expect(deleteAllUserTabularReviews).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
"u1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DELETE /user/account purges data then deletes the auth user (204)", async () => {
|
||||||
|
const res = await request(app).delete("/user/account").set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
// Account purge runs the cleanup helper with id + email.
|
||||||
|
expect(deleteUserAccountData).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
"u1",
|
||||||
|
"u1@test.local",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DELETE /user/account returns 500 when the auth-user delete errors", async () => {
|
||||||
|
supabaseState.adminDeleteUser = { error: { message: "auth boom" } };
|
||||||
|
|
||||||
|
const res = await request(app).delete("/user/account").set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.detail).toBe("auth boom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DELETE /user/chats returns 500 when cleanup throws", async () => {
|
||||||
|
deleteAllUserChats.mockRejectedValue(new Error("cascade failed"));
|
||||||
|
|
||||||
|
const res = await request(app).delete("/user/chats").set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.detail).toBe("cascade failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DELETE /user/account is rejected when MFA is unsatisfied (no cleanup)", async () => {
|
||||||
|
requireMfaIfEnrolled.mockImplementation(rejectMfa);
|
||||||
|
|
||||||
|
const res = await request(app).delete("/user/account").set(...AUTH);
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.code).toBe("mfa_verification_required");
|
||||||
|
expect(deleteUserAccountData).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── PATCH /user/security/mfa-login (factor-gated, MFA-guarded) ────────
|
||||||
|
describe("PATCH /user/security/mfa-login", () => {
|
||||||
|
it("returns 400 when enabling without a verified TOTP factor", async () => {
|
||||||
|
supabaseState.adminGetUserById = {
|
||||||
|
data: { user: { id: "u1", factors: [] } },
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.patch("/user/security/mfa-login")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({ enabled: true });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.detail).toContain("authenticator app");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enables MFA-on-login when a verified TOTP factor exists", async () => {
|
||||||
|
supabaseState.adminGetUserById = {
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
id: "u1",
|
||||||
|
factors: [
|
||||||
|
{ factor_type: "totp", status: "verified" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
supabaseState.tables.user_profiles = {
|
||||||
|
data: profileRow({ mfa_on_login: true }),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.patch("/user/security/mfa-login")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({ enabled: true });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toMatchObject({ mfaOnLogin: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 on a non-boolean enabled field", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.patch("/user/security/mfa-login")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({ enabled: "yes" });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is rejected with 403 when MFA is unsatisfied", async () => {
|
||||||
|
requireMfaIfEnrolled.mockImplementation(rejectMfa);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.patch("/user/security/mfa-login")
|
||||||
|
.set(...AUTH)
|
||||||
|
.send({ enabled: false });
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.code).toBe("mfa_verification_required");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
161
backend/src/app.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
import "dotenv/config";
|
||||||
|
import express from "express";
|
||||||
|
import cors from "cors";
|
||||||
|
import helmet from "helmet";
|
||||||
|
import rateLimit from "express-rate-limit";
|
||||||
|
import { chatRouter } from "./routes/chat";
|
||||||
|
import { projectsRouter } from "./routes/projects";
|
||||||
|
import { projectChatRouter } from "./routes/projectChat";
|
||||||
|
import { documentsRouter } from "./routes/documents";
|
||||||
|
import { libraryRouter } from "./routes/library";
|
||||||
|
import { tabularRouter } from "./routes/tabular";
|
||||||
|
import { workflowsRouter } from "./routes/workflows";
|
||||||
|
import { userRouter } from "./routes/user";
|
||||||
|
import { downloadsRouter } from "./routes/downloads";
|
||||||
|
import { caseLawRouter } from "./routes/caseLaw";
|
||||||
|
|
||||||
|
export const app = express();
|
||||||
|
const isProduction = process.env.NODE_ENV === "production";
|
||||||
|
|
||||||
|
function envInt(name: string, fallback: number): number {
|
||||||
|
const raw = process.env[name];
|
||||||
|
if (!raw) return fallback;
|
||||||
|
const parsed = Number.parseInt(raw, 10);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function minutes(value: number): number {
|
||||||
|
return value * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hours(value: number): number {
|
||||||
|
return minutes(value * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeLimiter(options: {
|
||||||
|
windowMs: number;
|
||||||
|
max: number;
|
||||||
|
message?: string;
|
||||||
|
}) {
|
||||||
|
return rateLimit({
|
||||||
|
windowMs: options.windowMs,
|
||||||
|
max: options.max,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
skip: (req) => req.method === "OPTIONS",
|
||||||
|
message: {
|
||||||
|
detail:
|
||||||
|
options.message ?? "Too many requests. Please try again later.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const generalLimiter = makeLimiter({
|
||||||
|
windowMs: minutes(envInt("RATE_LIMIT_GENERAL_WINDOW_MINUTES", 15)),
|
||||||
|
max: envInt("RATE_LIMIT_GENERAL_MAX", 300),
|
||||||
|
});
|
||||||
|
|
||||||
|
const chatLimiter = makeLimiter({
|
||||||
|
windowMs: minutes(envInt("RATE_LIMIT_CHAT_WINDOW_MINUTES", 15)),
|
||||||
|
max: envInt("RATE_LIMIT_CHAT_MAX", 30),
|
||||||
|
message: "Too many chat requests. Please try again later.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const chatCreateLimiter = makeLimiter({
|
||||||
|
windowMs: minutes(envInt("RATE_LIMIT_CHAT_CREATE_WINDOW_MINUTES", 15)),
|
||||||
|
max: envInt("RATE_LIMIT_CHAT_CREATE_MAX", 60),
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadLimiter = makeLimiter({
|
||||||
|
windowMs: hours(envInt("RATE_LIMIT_UPLOAD_WINDOW_HOURS", 1)),
|
||||||
|
max: envInt("RATE_LIMIT_UPLOAD_MAX", 50),
|
||||||
|
message: "Too many upload requests. Please try again later.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportLimiter = makeLimiter({
|
||||||
|
windowMs: hours(envInt("RATE_LIMIT_EXPORT_WINDOW_HOURS", 1)),
|
||||||
|
max: envInt("RATE_LIMIT_EXPORT_MAX", 10),
|
||||||
|
message: "Too many export requests. Please try again later.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const dataDeleteLimiter = makeLimiter({
|
||||||
|
windowMs: hours(envInt("RATE_LIMIT_DATA_DELETE_WINDOW_HOURS", 1)),
|
||||||
|
max: envInt("RATE_LIMIT_DATA_DELETE_MAX", 20),
|
||||||
|
message: "Too many data deletion requests. Please try again later.",
|
||||||
|
});
|
||||||
|
|
||||||
|
function jsonLimitForPath(path: string): string {
|
||||||
|
return "50mb";
|
||||||
|
}
|
||||||
|
|
||||||
|
app.disable("x-powered-by");
|
||||||
|
app.set("trust proxy", envInt("TRUST_PROXY_HOPS", 1));
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'none'"],
|
||||||
|
baseUri: ["'none'"],
|
||||||
|
frameAncestors: ["'none'"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
crossOriginEmbedderPolicy: false,
|
||||||
|
hsts: isProduction
|
||||||
|
? {
|
||||||
|
maxAge: 15552000,
|
||||||
|
includeSubDomains: true,
|
||||||
|
}
|
||||||
|
: false,
|
||||||
|
referrerPolicy: { policy: "no-referrer" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin: process.env.FRONTEND_URL ?? "http://localhost:3000",
|
||||||
|
credentials: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.use(generalLimiter);
|
||||||
|
|
||||||
|
app.post("/chat", chatLimiter);
|
||||||
|
app.post("/projects/:projectId/chat", chatLimiter);
|
||||||
|
app.post("/tabular-review/:reviewId/chat", chatLimiter);
|
||||||
|
app.post("/tabular-review/:reviewId/generate", chatLimiter);
|
||||||
|
app.post("/chat/create", chatCreateLimiter);
|
||||||
|
app.post("/chat/:chatId/generate-title", chatCreateLimiter);
|
||||||
|
app.post("/single-documents", uploadLimiter);
|
||||||
|
app.post("/library/:kind/documents", uploadLimiter);
|
||||||
|
app.post("/single-documents/:documentId/versions", uploadLimiter);
|
||||||
|
app.put(
|
||||||
|
"/single-documents/:documentId/versions/:versionId/file",
|
||||||
|
uploadLimiter,
|
||||||
|
);
|
||||||
|
app.post("/projects/:projectId/documents", uploadLimiter);
|
||||||
|
app.get("/user/export", exportLimiter);
|
||||||
|
app.get("/user/chats/export", exportLimiter);
|
||||||
|
app.get("/user/tabular-reviews/export", exportLimiter);
|
||||||
|
app.delete("/user/account", dataDeleteLimiter);
|
||||||
|
app.delete("/user/chats", dataDeleteLimiter);
|
||||||
|
app.delete("/user/projects", dataDeleteLimiter);
|
||||||
|
app.delete("/user/tabular-reviews", dataDeleteLimiter);
|
||||||
|
|
||||||
|
app.use((req, res, next) =>
|
||||||
|
express.json({ limit: jsonLimitForPath(req.path) })(req, res, next),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.use("/chat", chatRouter);
|
||||||
|
app.use("/projects", projectsRouter);
|
||||||
|
app.use("/projects/:projectId/chat", projectChatRouter);
|
||||||
|
app.use("/single-documents", documentsRouter);
|
||||||
|
app.use("/library", libraryRouter);
|
||||||
|
app.use("/tabular-review", tabularRouter);
|
||||||
|
app.use("/workflows", workflowsRouter);
|
||||||
|
app.use("/user", userRouter);
|
||||||
|
app.use("/users", userRouter);
|
||||||
|
app.use("/download", downloadsRouter);
|
||||||
|
app.use("/case-law", caseLawRouter);
|
||||||
|
|
||||||
|
app.get("/health", (_req, res) => res.json({ ok: true }));
|
||||||
|
|
@ -1,162 +1,6 @@
|
||||||
import "dotenv/config";
|
import { app } from "./app";
|
||||||
import express from "express";
|
|
||||||
import cors from "cors";
|
|
||||||
import helmet from "helmet";
|
|
||||||
import rateLimit from "express-rate-limit";
|
|
||||||
import { chatRouter } from "./routes/chat";
|
|
||||||
import { projectsRouter } from "./routes/projects";
|
|
||||||
import { projectChatRouter } from "./routes/projectChat";
|
|
||||||
import { documentsRouter } from "./routes/documents";
|
|
||||||
import { tabularRouter } from "./routes/tabular";
|
|
||||||
import { workflowsRouter } from "./routes/workflows";
|
|
||||||
import { userRouter } from "./routes/user";
|
|
||||||
import { downloadsRouter } from "./routes/downloads";
|
|
||||||
import { caseLawRouter } from "./routes/caseLaw";
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
const PORT = process.env.PORT ?? 3001;
|
const PORT = process.env.PORT ?? 3001;
|
||||||
const isProduction = process.env.NODE_ENV === "production";
|
|
||||||
|
|
||||||
function envInt(name: string, fallback: number): number {
|
|
||||||
const raw = process.env[name];
|
|
||||||
if (!raw) return fallback;
|
|
||||||
const parsed = Number.parseInt(raw, 10);
|
|
||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function minutes(value: number): number {
|
|
||||||
return value * 60 * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hours(value: number): number {
|
|
||||||
return minutes(value * 60);
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeLimiter(options: {
|
|
||||||
windowMs: number;
|
|
||||||
max: number;
|
|
||||||
message?: string;
|
|
||||||
}) {
|
|
||||||
return rateLimit({
|
|
||||||
windowMs: options.windowMs,
|
|
||||||
max: options.max,
|
|
||||||
standardHeaders: true,
|
|
||||||
legacyHeaders: false,
|
|
||||||
skip: (req) => req.method === "OPTIONS",
|
|
||||||
message: {
|
|
||||||
detail:
|
|
||||||
options.message ?? "Too many requests. Please try again later.",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const generalLimiter = makeLimiter({
|
|
||||||
windowMs: minutes(envInt("RATE_LIMIT_GENERAL_WINDOW_MINUTES", 15)),
|
|
||||||
max: envInt("RATE_LIMIT_GENERAL_MAX", 300),
|
|
||||||
});
|
|
||||||
|
|
||||||
const chatLimiter = makeLimiter({
|
|
||||||
windowMs: minutes(envInt("RATE_LIMIT_CHAT_WINDOW_MINUTES", 15)),
|
|
||||||
max: envInt("RATE_LIMIT_CHAT_MAX", 30),
|
|
||||||
message: "Too many chat requests. Please try again later.",
|
|
||||||
});
|
|
||||||
|
|
||||||
const chatCreateLimiter = makeLimiter({
|
|
||||||
windowMs: minutes(envInt("RATE_LIMIT_CHAT_CREATE_WINDOW_MINUTES", 15)),
|
|
||||||
max: envInt("RATE_LIMIT_CHAT_CREATE_MAX", 60),
|
|
||||||
});
|
|
||||||
|
|
||||||
const uploadLimiter = makeLimiter({
|
|
||||||
windowMs: hours(envInt("RATE_LIMIT_UPLOAD_WINDOW_HOURS", 1)),
|
|
||||||
max: envInt("RATE_LIMIT_UPLOAD_MAX", 50),
|
|
||||||
message: "Too many upload requests. Please try again later.",
|
|
||||||
});
|
|
||||||
|
|
||||||
const exportLimiter = makeLimiter({
|
|
||||||
windowMs: hours(envInt("RATE_LIMIT_EXPORT_WINDOW_HOURS", 1)),
|
|
||||||
max: envInt("RATE_LIMIT_EXPORT_MAX", 10),
|
|
||||||
message: "Too many export requests. Please try again later.",
|
|
||||||
});
|
|
||||||
|
|
||||||
const dataDeleteLimiter = makeLimiter({
|
|
||||||
windowMs: hours(envInt("RATE_LIMIT_DATA_DELETE_WINDOW_HOURS", 1)),
|
|
||||||
max: envInt("RATE_LIMIT_DATA_DELETE_MAX", 20),
|
|
||||||
message: "Too many data deletion requests. Please try again later.",
|
|
||||||
});
|
|
||||||
|
|
||||||
function jsonLimitForPath(path: string): string {
|
|
||||||
return "50mb";
|
|
||||||
}
|
|
||||||
|
|
||||||
app.disable("x-powered-by");
|
|
||||||
app.set("trust proxy", envInt("TRUST_PROXY_HOPS", 1));
|
|
||||||
|
|
||||||
app.use(
|
|
||||||
helmet({
|
|
||||||
contentSecurityPolicy: {
|
|
||||||
directives: {
|
|
||||||
defaultSrc: ["'none'"],
|
|
||||||
baseUri: ["'none'"],
|
|
||||||
frameAncestors: ["'none'"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
crossOriginEmbedderPolicy: false,
|
|
||||||
hsts: isProduction
|
|
||||||
? {
|
|
||||||
maxAge: 15552000,
|
|
||||||
includeSubDomains: true,
|
|
||||||
}
|
|
||||||
: false,
|
|
||||||
referrerPolicy: { policy: "no-referrer" },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
app.use(
|
|
||||||
cors({
|
|
||||||
origin: process.env.FRONTEND_URL ?? "http://localhost:3000",
|
|
||||||
credentials: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
app.use(generalLimiter);
|
|
||||||
|
|
||||||
app.post("/chat", chatLimiter);
|
|
||||||
app.post("/projects/:projectId/chat", chatLimiter);
|
|
||||||
app.post("/tabular-review/:reviewId/chat", chatLimiter);
|
|
||||||
app.post("/tabular-review/:reviewId/generate", chatLimiter);
|
|
||||||
app.post("/chat/create", chatCreateLimiter);
|
|
||||||
app.post("/chat/:chatId/generate-title", chatCreateLimiter);
|
|
||||||
app.post("/single-documents", uploadLimiter);
|
|
||||||
app.post("/single-documents/:documentId/versions", uploadLimiter);
|
|
||||||
app.put(
|
|
||||||
"/single-documents/:documentId/versions/:versionId/file",
|
|
||||||
uploadLimiter,
|
|
||||||
);
|
|
||||||
app.post("/projects/:projectId/documents", uploadLimiter);
|
|
||||||
app.get("/user/export", exportLimiter);
|
|
||||||
app.get("/user/chats/export", exportLimiter);
|
|
||||||
app.get("/user/tabular-reviews/export", exportLimiter);
|
|
||||||
app.delete("/user/account", dataDeleteLimiter);
|
|
||||||
app.delete("/user/chats", dataDeleteLimiter);
|
|
||||||
app.delete("/user/projects", dataDeleteLimiter);
|
|
||||||
app.delete("/user/tabular-reviews", dataDeleteLimiter);
|
|
||||||
|
|
||||||
app.use((req, res, next) =>
|
|
||||||
express.json({ limit: jsonLimitForPath(req.path) })(req, res, next),
|
|
||||||
);
|
|
||||||
|
|
||||||
app.use("/chat", chatRouter);
|
|
||||||
app.use("/projects", projectsRouter);
|
|
||||||
app.use("/projects/:projectId/chat", projectChatRouter);
|
|
||||||
app.use("/single-documents", documentsRouter);
|
|
||||||
app.use("/tabular-review", tabularRouter);
|
|
||||||
app.use("/workflows", workflowsRouter);
|
|
||||||
app.use("/user", userRouter);
|
|
||||||
app.use("/users", userRouter);
|
|
||||||
app.use("/download", downloadsRouter);
|
|
||||||
app.use("/case-law", caseLawRouter);
|
|
||||||
|
|
||||||
app.get("/health", (_req, res) => res.json({ ok: true }));
|
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Mike backend running on port ${PORT}`);
|
console.log(`Mike backend running on port ${PORT}`);
|
||||||
|
|
|
||||||
163
backend/src/lib/__tests__/access.test.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
checkProjectAccess,
|
||||||
|
ensureDocAccess,
|
||||||
|
ensureReviewAccess,
|
||||||
|
filterAccessibleDocumentIds,
|
||||||
|
listAccessibleProjectIds,
|
||||||
|
} from "../access";
|
||||||
|
|
||||||
|
type Row = Record<string, unknown>;
|
||||||
|
|
||||||
|
function makeDb(tables: Record<string, Row[]>) {
|
||||||
|
return {
|
||||||
|
from(table: string) {
|
||||||
|
let rows = [...(tables[table] ?? [])];
|
||||||
|
const query = {
|
||||||
|
select: () => query,
|
||||||
|
eq: (column: string, value: unknown) => {
|
||||||
|
rows = rows.filter((row) => row[column] === value);
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
neq: (column: string, value: unknown) => {
|
||||||
|
rows = rows.filter((row) => row[column] !== value);
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
in: (column: string, values: unknown[]) => {
|
||||||
|
rows = rows.filter((row) => values.includes(row[column]));
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
filter: (column: string, operator: string, value: string) => {
|
||||||
|
if (operator !== "cs") return query;
|
||||||
|
const expected = (JSON.parse(value) as string[]).map((item) =>
|
||||||
|
item.toLowerCase(),
|
||||||
|
);
|
||||||
|
rows = rows.filter((row) => {
|
||||||
|
const actual = row[column];
|
||||||
|
const normalizedActual = Array.isArray(actual)
|
||||||
|
? actual.map((item) => String(item).toLowerCase())
|
||||||
|
: [];
|
||||||
|
return (
|
||||||
|
Array.isArray(actual) &&
|
||||||
|
expected.every((item) => normalizedActual.includes(item))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
single: async () => ({ data: rows[0] ?? null, error: null }),
|
||||||
|
then: (
|
||||||
|
resolve: (value: { data: Row[]; error: null }) => unknown,
|
||||||
|
reject?: (reason: unknown) => unknown,
|
||||||
|
) => Promise.resolve({ data: rows, error: null }).then(resolve, reject),
|
||||||
|
};
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("access helpers", () => {
|
||||||
|
const db = makeDb({
|
||||||
|
projects: [
|
||||||
|
{ id: "own-project", user_id: "owner", shared_with: [] },
|
||||||
|
{
|
||||||
|
id: "shared-project",
|
||||||
|
user_id: "other-owner",
|
||||||
|
shared_with: ["Reviewer@Example.com"],
|
||||||
|
},
|
||||||
|
{ id: "private-project", user_id: "other-owner", shared_with: [] },
|
||||||
|
],
|
||||||
|
documents: [
|
||||||
|
{ id: "own-doc", user_id: "owner", project_id: null },
|
||||||
|
{
|
||||||
|
id: "shared-doc",
|
||||||
|
user_id: "other-owner",
|
||||||
|
project_id: "shared-project",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "private-doc",
|
||||||
|
user_id: "other-owner",
|
||||||
|
project_id: "private-project",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows project owners", async () => {
|
||||||
|
await expect(
|
||||||
|
checkProjectAccess("own-project", "owner", "owner@example.com", db),
|
||||||
|
).resolves.toMatchObject({ ok: true, isOwner: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows shared project access case-insensitively", async () => {
|
||||||
|
await expect(
|
||||||
|
checkProjectAccess(
|
||||||
|
"shared-project",
|
||||||
|
"reviewer",
|
||||||
|
"reviewer@example.com",
|
||||||
|
db,
|
||||||
|
),
|
||||||
|
).resolves.toMatchObject({ ok: true, isOwner: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies private project access", async () => {
|
||||||
|
await expect(
|
||||||
|
checkProjectAccess(
|
||||||
|
"private-project",
|
||||||
|
"reviewer",
|
||||||
|
"reviewer@example.com",
|
||||||
|
db,
|
||||||
|
),
|
||||||
|
).resolves.toEqual({ ok: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows document owners and shared-project readers", async () => {
|
||||||
|
await expect(
|
||||||
|
ensureDocAccess(
|
||||||
|
{ user_id: "owner", project_id: null },
|
||||||
|
"owner",
|
||||||
|
"owner@example.com",
|
||||||
|
db,
|
||||||
|
),
|
||||||
|
).resolves.toMatchObject({ ok: true, isOwner: true });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ensureDocAccess(
|
||||||
|
{ user_id: "other-owner", project_id: "shared-project" },
|
||||||
|
"reviewer",
|
||||||
|
"reviewer@example.com",
|
||||||
|
db,
|
||||||
|
),
|
||||||
|
).resolves.toMatchObject({ ok: true, isOwner: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters user-supplied document IDs to accessible documents only", async () => {
|
||||||
|
await expect(
|
||||||
|
filterAccessibleDocumentIds(
|
||||||
|
["own-doc", "shared-doc", "private-doc", "missing-doc"],
|
||||||
|
"reviewer",
|
||||||
|
"reviewer@example.com",
|
||||||
|
db,
|
||||||
|
),
|
||||||
|
).resolves.toEqual(["shared-doc"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists own and directly shared projects", async () => {
|
||||||
|
await expect(
|
||||||
|
listAccessibleProjectIds("owner", "reviewer@example.com", db),
|
||||||
|
).resolves.toEqual(expect.arrayContaining(["own-project", "shared-project"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows direct review sharing without project access", async () => {
|
||||||
|
await expect(
|
||||||
|
ensureReviewAccess(
|
||||||
|
{
|
||||||
|
user_id: "other-owner",
|
||||||
|
project_id: null,
|
||||||
|
shared_with: ["Reviewer@Example.com"],
|
||||||
|
},
|
||||||
|
"reviewer",
|
||||||
|
"reviewer@example.com",
|
||||||
|
db,
|
||||||
|
),
|
||||||
|
).resolves.toMatchObject({ ok: true, isOwner: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
81
backend/src/lib/__tests__/chatTypes.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
resolveDoc,
|
||||||
|
resolveDocLabel,
|
||||||
|
type DocIndex,
|
||||||
|
type DocStore,
|
||||||
|
} from "../chat/types";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// resolveDoc
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("resolveDoc", () => {
|
||||||
|
const index: DocIndex = {
|
||||||
|
"doc-1": { document_id: "uuid-aaa", filename: "contract.pdf" },
|
||||||
|
"doc-2": { document_id: "uuid-bbb", filename: "nda.pdf" },
|
||||||
|
};
|
||||||
|
|
||||||
|
it("returns the doc entry for a known label", () => {
|
||||||
|
expect(resolveDoc("doc-1", index)).toEqual({
|
||||||
|
document_id: "uuid-aaa",
|
||||||
|
filename: "contract.pdf",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for an unknown label", () => {
|
||||||
|
expect(resolveDoc("doc-99", index)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for an empty string", () => {
|
||||||
|
expect(resolveDoc("", index)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// resolveDocLabel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("resolveDocLabel", () => {
|
||||||
|
const store: DocStore = new Map([
|
||||||
|
["doc-1", { storage_path: "path/a", file_type: "pdf", filename: "contract.pdf" }],
|
||||||
|
["doc-2", { storage_path: "path/b", file_type: "pdf", filename: "nda.pdf" }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const index: DocIndex = {
|
||||||
|
"doc-1": { document_id: "uuid-aaa", filename: "contract.pdf" },
|
||||||
|
"doc-2": { document_id: "uuid-bbb", filename: "nda.pdf" },
|
||||||
|
};
|
||||||
|
|
||||||
|
it("resolves by label when the label is in the store", () => {
|
||||||
|
expect(resolveDocLabel("doc-1", store, index)).toBe("doc-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves by filename when the filename matches a store entry", () => {
|
||||||
|
expect(resolveDocLabel("contract.pdf", store, index)).toBe("doc-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves by document UUID via the docIndex", () => {
|
||||||
|
expect(resolveDocLabel("uuid-bbb", store, index)).toBe("doc-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when nothing matches", () => {
|
||||||
|
expect(resolveDocLabel("unknown-id", store, index)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when docIndex is omitted and only UUID matches", () => {
|
||||||
|
// Without the index there is no fallback for raw UUIDs.
|
||||||
|
expect(resolveDocLabel("uuid-aaa", store)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prioritises exact label match over filename match", () => {
|
||||||
|
// If a label happens to equal a filename of a different doc,
|
||||||
|
// the label match wins.
|
||||||
|
const storeWithCrossMatch: DocStore = new Map([
|
||||||
|
["nda.pdf", { storage_path: "path/c", file_type: "pdf", filename: "contract.pdf" }],
|
||||||
|
]);
|
||||||
|
// "nda.pdf" is a label here, and it IS in the store, so it should
|
||||||
|
// be returned directly without the filename-fallback loop.
|
||||||
|
expect(resolveDocLabel("nda.pdf", storeWithCrossMatch)).toBe("nda.pdf");
|
||||||
|
});
|
||||||
|
});
|
||||||
397
backend/src/lib/__tests__/citations.test.ts
Normal file
|
|
@ -0,0 +1,397 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
parseCitations,
|
||||||
|
parseCitationsWithDiagnostics,
|
||||||
|
parsePartialCitationObjects,
|
||||||
|
createCitation,
|
||||||
|
CITATIONS_OPEN_TAG,
|
||||||
|
CITATIONS_CLOSE_TAG,
|
||||||
|
} from "../chat/citations";
|
||||||
|
import type { DocIndex } from "../chat/types";
|
||||||
|
|
||||||
|
function citationsBlock(json: string) {
|
||||||
|
return `Answer text.\n${CITATIONS_OPEN_TAG}\n${json}\n${CITATIONS_CLOSE_TAG}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// parseCitationsWithDiagnostics
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("parseCitationsWithDiagnostics", () => {
|
||||||
|
it("reports no block when the tags are absent", () => {
|
||||||
|
const { citations, diagnostics } =
|
||||||
|
parseCitationsWithDiagnostics("plain answer");
|
||||||
|
expect(citations).toEqual([]);
|
||||||
|
expect(diagnostics).toEqual({ hasBlock: false, rawLength: 0, error: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a JSON parse error for malformed block content", () => {
|
||||||
|
const { citations, diagnostics } = parseCitationsWithDiagnostics(
|
||||||
|
citationsBlock("[{not json"),
|
||||||
|
);
|
||||||
|
expect(citations).toEqual([]);
|
||||||
|
expect(diagnostics.hasBlock).toBe(true);
|
||||||
|
expect(diagnostics.rawLength).toBeGreaterThan(0);
|
||||||
|
expect(diagnostics.error).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an error when the block JSON is not an array", () => {
|
||||||
|
const { citations, diagnostics } = parseCitationsWithDiagnostics(
|
||||||
|
citationsBlock('{"ref": 1}'),
|
||||||
|
);
|
||||||
|
expect(citations).toEqual([]);
|
||||||
|
expect(diagnostics.error).toBe("CITATIONS block JSON was not an array.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// parseCitations — document citations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("parseCitations (document citations)", () => {
|
||||||
|
it("parses a minimal document citation", () => {
|
||||||
|
const [citation] = parseCitations(
|
||||||
|
citationsBlock(
|
||||||
|
'[{"ref": 1, "doc_id": "doc-1", "page": 3, "quote": "the term"}]',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(citation).toMatchObject({
|
||||||
|
kind: "document",
|
||||||
|
ref: 1,
|
||||||
|
doc_id: "doc-1",
|
||||||
|
page: 3,
|
||||||
|
quote: "the term",
|
||||||
|
});
|
||||||
|
expect(citation.quotes).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives ref from a [N] marker when ref is missing", () => {
|
||||||
|
const [citation] = parseCitations(
|
||||||
|
citationsBlock(
|
||||||
|
'[{"marker": "[7]", "doc_id": "doc-1", "page": 1, "quote": "q"}]',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(citation.ref).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops entries without a usable ref or marker", () => {
|
||||||
|
expect(
|
||||||
|
parseCitations(
|
||||||
|
citationsBlock('[{"doc_id": "doc-1", "quote": "q", "marker": "nope"}]'),
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops document entries without doc_id or quote", () => {
|
||||||
|
expect(
|
||||||
|
parseCitations(citationsBlock('[{"ref": 1, "doc_id": "doc-1"}]')),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
parseCitations(citationsBlock('[{"ref": 1, "quote": "q"}]')),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops non-object entries but keeps valid ones", () => {
|
||||||
|
const citations = parseCitations(
|
||||||
|
citationsBlock(
|
||||||
|
'[null, "junk", {"ref": 2, "doc_id": "doc-2", "page": 4, "quote": "kept"}]',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(citations).toHaveLength(1);
|
||||||
|
expect(citations[0]).toMatchObject({ ref: 2, doc_id: "doc-2" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a text field as a quote alias", () => {
|
||||||
|
const [citation] = parseCitations(
|
||||||
|
citationsBlock('[{"ref": 1, "doc_id": "doc-1", "page": 2, "text": "aliased"}]'),
|
||||||
|
);
|
||||||
|
expect(citation.kind).toBe("document");
|
||||||
|
expect((citation as { quote: string }).quote).toBe("aliased");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes pages: numbers kept, ranges kept, junk becomes 1", () => {
|
||||||
|
const citations = parseCitations(
|
||||||
|
citationsBlock(
|
||||||
|
'[{"ref": 1, "doc_id": "d", "page": 5, "quote": "a"},' +
|
||||||
|
'{"ref": 2, "doc_id": "d", "page": "3-5", "quote": "b"},' +
|
||||||
|
'{"ref": 3, "doc_id": "d", "page": "12", "quote": "c"},' +
|
||||||
|
'{"ref": 4, "doc_id": "d", "page": "unknown", "quote": "d"}]',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(citations.map((c) => (c as { page: number | string }).page)).toEqual([
|
||||||
|
5,
|
||||||
|
"3-5",
|
||||||
|
12,
|
||||||
|
1,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps at most 3 quotes and inherits top-level page/sheet/cell", () => {
|
||||||
|
const [citation] = parseCitations(
|
||||||
|
citationsBlock(
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
ref: 1,
|
||||||
|
doc_id: "doc-1",
|
||||||
|
page: 9,
|
||||||
|
sheet: "Summary",
|
||||||
|
cell: "B7",
|
||||||
|
quotes: [
|
||||||
|
{ quote: "one" },
|
||||||
|
{ quote: "two", page: 2 },
|
||||||
|
{ quote: "three", sheet: "Detail", cell: "C1" },
|
||||||
|
{ quote: "four" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(citation.kind).toBe("document");
|
||||||
|
const doc = citation as {
|
||||||
|
page: number | string;
|
||||||
|
quotes: { page: number | string; quote: string; sheet?: string; cell?: string }[];
|
||||||
|
};
|
||||||
|
expect(doc.quotes).toHaveLength(3);
|
||||||
|
expect(doc.quotes[0]).toEqual({
|
||||||
|
page: 9,
|
||||||
|
quote: "one",
|
||||||
|
sheet: "Summary",
|
||||||
|
cell: "B7",
|
||||||
|
});
|
||||||
|
expect(doc.quotes[1].page).toBe(2);
|
||||||
|
expect(doc.quotes[2]).toMatchObject({ sheet: "Detail", cell: "C1" });
|
||||||
|
// Top-level fields mirror the first quote.
|
||||||
|
expect(doc.page).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips quote rows without text", () => {
|
||||||
|
const [citation] = parseCitations(
|
||||||
|
citationsBlock(
|
||||||
|
'[{"ref": 1, "doc_id": "doc-1", "page": 1,' +
|
||||||
|
' "quotes": [{"page": 2}, {"quote": " "}, {"quote": "real"}]}]',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect((citation as { quotes: unknown[] }).quotes).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// parseCitations — case citations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("parseCitations (case citations)", () => {
|
||||||
|
it("parses a case citation from a numeric cluster_id", () => {
|
||||||
|
const [citation] = parseCitations(
|
||||||
|
citationsBlock('[{"ref": 1, "cluster_id": 12345, "quote": "held that"}]'),
|
||||||
|
);
|
||||||
|
expect(citation).toMatchObject({ kind: "case", ref: 1, cluster_id: 12345 });
|
||||||
|
expect((citation as { quotes: unknown[] }).quotes).toEqual([
|
||||||
|
{ opinionId: null, type: null, author: null, quote: "held that" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts clusterId camelCase and string cluster ids", () => {
|
||||||
|
const citations = parseCitations(
|
||||||
|
citationsBlock(
|
||||||
|
'[{"ref": 1, "clusterId": 7, "quote": "a"},' +
|
||||||
|
'{"ref": 2, "cluster_id": "42", "quote": "b"}]',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(citations.map((c) => (c as { cluster_id: number }).cluster_id)).toEqual([
|
||||||
|
7, 42,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("floors fractional cluster ids", () => {
|
||||||
|
const [citation] = parseCitations(
|
||||||
|
citationsBlock('[{"ref": 1, "cluster_id": 12.9, "quote": "q"}]'),
|
||||||
|
);
|
||||||
|
expect((citation as { cluster_id: number }).cluster_id).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats non-positive cluster ids as document citations", () => {
|
||||||
|
// cluster_id 0 fails the > 0 check, so the entry needs a doc_id.
|
||||||
|
expect(
|
||||||
|
parseCitations(citationsBlock('[{"ref": 1, "cluster_id": 0, "quote": "q"}]')),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes structured case quotes with opinion metadata", () => {
|
||||||
|
const [citation] = parseCitations(
|
||||||
|
citationsBlock(
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
ref: 3,
|
||||||
|
cluster_id: 99,
|
||||||
|
quotes: [
|
||||||
|
{
|
||||||
|
quote: "majority text",
|
||||||
|
opinion_id: 11.7,
|
||||||
|
type: "majority",
|
||||||
|
author: "Judge A",
|
||||||
|
},
|
||||||
|
{ text: "concurrence text", opinionId: 12 },
|
||||||
|
{ type: "no quote text, dropped" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect((citation as { quotes: unknown[] }).quotes).toEqual([
|
||||||
|
{
|
||||||
|
opinionId: 11,
|
||||||
|
type: "majority",
|
||||||
|
author: "Judge A",
|
||||||
|
quote: "majority text",
|
||||||
|
},
|
||||||
|
{ opinionId: 12, type: null, author: null, quote: "concurrence text" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops case citations with no quotes at all", () => {
|
||||||
|
expect(
|
||||||
|
parseCitations(citationsBlock('[{"ref": 1, "cluster_id": 5}]')),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// parsePartialCitationObjects
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("parsePartialCitationObjects", () => {
|
||||||
|
it("returns [] when no array has started", () => {
|
||||||
|
expect(parsePartialCitationObjects("streaming <CITATIONS>")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts complete objects and ignores a trailing incomplete one", () => {
|
||||||
|
const partial =
|
||||||
|
'<CITATIONS>[{"ref": 1, "doc_id": "doc-1", "page": 2, "quote": "done"},' +
|
||||||
|
' {"ref": 2, "doc_id": "doc-2", "page": 3, "quote": "still stream';
|
||||||
|
const citations = parsePartialCitationObjects(partial);
|
||||||
|
expect(citations).toHaveLength(1);
|
||||||
|
expect(citations[0]).toMatchObject({ ref: 1, doc_id: "doc-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles braces and escaped quotes inside string values", () => {
|
||||||
|
const partial =
|
||||||
|
'<CITATIONS>[{"ref": 1, "doc_id": "doc-1", "page": 1,' +
|
||||||
|
' "quote": "clause {a} says \\"stop\\""}';
|
||||||
|
const citations = parsePartialCitationObjects(partial);
|
||||||
|
expect(citations).toHaveLength(1);
|
||||||
|
expect((citations[0] as { quote: string }).quote).toBe(
|
||||||
|
'clause {a} says "stop"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores content after the closing tag", () => {
|
||||||
|
const text =
|
||||||
|
'<CITATIONS>[{"ref": 1, "doc_id": "a", "page": 1, "quote": "q"}]</CITATIONS>' +
|
||||||
|
'[{"ref": 9, "doc_id": "b", "page": 1, "quote": "after"}]';
|
||||||
|
const citations = parsePartialCitationObjects(text);
|
||||||
|
expect(citations).toHaveLength(1);
|
||||||
|
expect(citations[0]).toMatchObject({ ref: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops at the array close bracket", () => {
|
||||||
|
const text =
|
||||||
|
'[{"ref": 1, "doc_id": "a", "page": 1, "quote": "q"}] {"ref": 2, "doc_id": "b", "page": 1, "quote": "outside"}';
|
||||||
|
const citations = parsePartialCitationObjects(text);
|
||||||
|
expect(citations).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips malformed objects but keeps later valid ones", () => {
|
||||||
|
const text =
|
||||||
|
'[{"ref": bad}, {"ref": 2, "doc_id": "doc-2", "page": 1, "quote": "ok"}';
|
||||||
|
const citations = parsePartialCitationObjects(text);
|
||||||
|
expect(citations).toHaveLength(1);
|
||||||
|
expect(citations[0]).toMatchObject({ ref: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// createCitation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("createCitation", () => {
|
||||||
|
const docIndex: DocIndex = {
|
||||||
|
"doc-1": {
|
||||||
|
document_id: "uuid-aaa",
|
||||||
|
filename: "contract.pdf",
|
||||||
|
version_id: "ver-1",
|
||||||
|
version_number: 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it("builds a document citation payload from the doc index", () => {
|
||||||
|
const [parsed] = parseCitations(
|
||||||
|
citationsBlock('[{"ref": 1, "doc_id": "doc-1", "page": 4, "quote": "q"}]'),
|
||||||
|
);
|
||||||
|
expect(createCitation(parsed, docIndex)).toMatchObject({
|
||||||
|
type: "citation_data",
|
||||||
|
kind: "document",
|
||||||
|
ref: 1,
|
||||||
|
doc_id: "doc-1",
|
||||||
|
document_id: "uuid-aaa",
|
||||||
|
version_id: "ver-1",
|
||||||
|
version_number: 2,
|
||||||
|
filename: "contract.pdf",
|
||||||
|
page: 4,
|
||||||
|
quote: "q",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the raw doc_id as filename when unresolvable", () => {
|
||||||
|
const [parsed] = parseCitations(
|
||||||
|
citationsBlock('[{"ref": 1, "doc_id": "doc-9", "page": 1, "quote": "q"}]'),
|
||||||
|
);
|
||||||
|
const citation = createCitation(parsed, docIndex);
|
||||||
|
expect(citation).toMatchObject({
|
||||||
|
filename: "doc-9",
|
||||||
|
document_id: undefined,
|
||||||
|
version_id: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enriches a case citation from the cluster map", () => {
|
||||||
|
const [parsed] = parseCitations(
|
||||||
|
citationsBlock('[{"ref": 2, "cluster_id": 55, "quote": "held"}]'),
|
||||||
|
);
|
||||||
|
const cases = new Map([
|
||||||
|
[
|
||||||
|
55,
|
||||||
|
{
|
||||||
|
caseName: "Smith v. Jones",
|
||||||
|
citations: ["123 U.S. 456", "alt cite"],
|
||||||
|
url: "https://example.test/case",
|
||||||
|
pdfUrl: null,
|
||||||
|
dateFiled: "1990-01-02",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
expect(createCitation(parsed, docIndex, cases)).toMatchObject({
|
||||||
|
type: "citation_data",
|
||||||
|
kind: "case",
|
||||||
|
ref: 2,
|
||||||
|
cluster_id: 55,
|
||||||
|
case_name: "Smith v. Jones",
|
||||||
|
citation: "123 U.S. 456",
|
||||||
|
url: "https://example.test/case",
|
||||||
|
pdfUrl: null,
|
||||||
|
dateFiled: "1990-01-02",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nulls case metadata when the cluster map has no entry", () => {
|
||||||
|
const [parsed] = parseCitations(
|
||||||
|
citationsBlock('[{"ref": 2, "cluster_id": 55, "quote": "held"}]'),
|
||||||
|
);
|
||||||
|
expect(createCitation(parsed, docIndex)).toMatchObject({
|
||||||
|
case_name: null,
|
||||||
|
citation: null,
|
||||||
|
url: null,
|
||||||
|
pdfUrl: null,
|
||||||
|
dateFiled: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
315
backend/src/lib/__tests__/documentVersions.test.ts
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
loadActiveVersion,
|
||||||
|
attachActiveVersionPaths,
|
||||||
|
attachLatestVersionNumbers,
|
||||||
|
} from "../documentVersions";
|
||||||
|
|
||||||
|
type Row = Record<string, unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only Supabase mock covering the query chains documentVersions uses:
|
||||||
|
* select/eq/in/is/not filters plus single() and awaiting the builder.
|
||||||
|
*/
|
||||||
|
function makeDb(tables: Record<string, Row[]>) {
|
||||||
|
return {
|
||||||
|
from(table: string) {
|
||||||
|
let rows = [...(tables[table] ?? [])];
|
||||||
|
const query: any = {
|
||||||
|
select: () => query,
|
||||||
|
eq: (column: string, value: unknown) => {
|
||||||
|
rows = rows.filter((row) => row[column] === value);
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
in: (column: string, values: unknown[]) => {
|
||||||
|
rows = rows.filter((row) => values.includes(row[column]));
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
is: (column: string, value: unknown) => {
|
||||||
|
rows = rows.filter((row) => (row[column] ?? null) === value);
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
not: (column: string, operator: string, value: unknown) => {
|
||||||
|
if (operator === "is" && value === null) {
|
||||||
|
rows = rows.filter((row) => row[column] != null);
|
||||||
|
}
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
single: async () => ({ data: rows[0] ?? null, error: null }),
|
||||||
|
then: (
|
||||||
|
resolve: (value: { data: Row[]; error: null }) => unknown,
|
||||||
|
reject?: (reason: unknown) => unknown,
|
||||||
|
) => Promise.resolve({ data: rows, error: null }).then(resolve, reject),
|
||||||
|
};
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shape of the mutable doc rows the attach* helpers annotate in place. */
|
||||||
|
type TestDoc = {
|
||||||
|
id: string;
|
||||||
|
current_version_id?: string | null;
|
||||||
|
latest_version_number?: number | null;
|
||||||
|
[k: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FULL_VERSION = {
|
||||||
|
id: "ver-1",
|
||||||
|
document_id: "doc-1",
|
||||||
|
storage_path: "documents/u/doc-1/source.pdf",
|
||||||
|
pdf_storage_path: "documents/u/doc-1/converted.pdf",
|
||||||
|
version_number: 3,
|
||||||
|
filename: "contract.pdf",
|
||||||
|
source: "upload",
|
||||||
|
file_type: "application/pdf",
|
||||||
|
size_bytes: 1024,
|
||||||
|
page_count: 12,
|
||||||
|
deleted_at: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// loadActiveVersion
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("loadActiveVersion", () => {
|
||||||
|
it("resolves the document's current version by default", async () => {
|
||||||
|
const db = makeDb({
|
||||||
|
documents: [{ id: "doc-1", current_version_id: "ver-1" }],
|
||||||
|
document_versions: [FULL_VERSION],
|
||||||
|
});
|
||||||
|
await expect(loadActiveVersion("doc-1", db)).resolves.toEqual({
|
||||||
|
id: "ver-1",
|
||||||
|
storage_path: "documents/u/doc-1/source.pdf",
|
||||||
|
pdf_storage_path: "documents/u/doc-1/converted.pdf",
|
||||||
|
version_number: 3,
|
||||||
|
filename: "contract.pdf",
|
||||||
|
source: "upload",
|
||||||
|
file_type: "application/pdf",
|
||||||
|
size_bytes: 1024,
|
||||||
|
page_count: 12,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers an explicit versionId over current_version_id", async () => {
|
||||||
|
const db = makeDb({
|
||||||
|
documents: [{ id: "doc-1", current_version_id: "ver-1" }],
|
||||||
|
document_versions: [
|
||||||
|
FULL_VERSION,
|
||||||
|
{ ...FULL_VERSION, id: "ver-2", version_number: 2 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const version = await loadActiveVersion("doc-1", db, "ver-2");
|
||||||
|
expect(version?.id).toBe("ver-2");
|
||||||
|
expect(version?.version_number).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when neither versionId nor current_version_id exist", async () => {
|
||||||
|
const db = makeDb({
|
||||||
|
documents: [{ id: "doc-1", current_version_id: null }],
|
||||||
|
document_versions: [FULL_VERSION],
|
||||||
|
});
|
||||||
|
await expect(loadActiveVersion("doc-1", db)).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the version belongs to a different document", async () => {
|
||||||
|
const db = makeDb({
|
||||||
|
documents: [{ id: "doc-1", current_version_id: "ver-other" }],
|
||||||
|
document_versions: [
|
||||||
|
{ ...FULL_VERSION, id: "ver-other", document_id: "doc-2" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await expect(loadActiveVersion("doc-1", db)).resolves.toBeNull();
|
||||||
|
// Also guards against a spoofed explicit versionId.
|
||||||
|
await expect(
|
||||||
|
loadActiveVersion("doc-1", db, "ver-other"),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for soft-deleted versions", async () => {
|
||||||
|
const db = makeDb({
|
||||||
|
documents: [{ id: "doc-1", current_version_id: "ver-1" }],
|
||||||
|
document_versions: [
|
||||||
|
{ ...FULL_VERSION, deleted_at: "2026-01-01T00:00:00Z" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await expect(loadActiveVersion("doc-1", db)).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the version has no storage_path", async () => {
|
||||||
|
const db = makeDb({
|
||||||
|
documents: [{ id: "doc-1", current_version_id: "ver-1" }],
|
||||||
|
document_versions: [{ ...FULL_VERSION, storage_path: null }],
|
||||||
|
});
|
||||||
|
await expect(loadActiveVersion("doc-1", db)).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults optional metadata fields to null", async () => {
|
||||||
|
const db = makeDb({
|
||||||
|
documents: [{ id: "doc-1", current_version_id: "ver-1" }],
|
||||||
|
document_versions: [
|
||||||
|
{
|
||||||
|
id: "ver-1",
|
||||||
|
document_id: "doc-1",
|
||||||
|
storage_path: "documents/u/doc-1/source.docx",
|
||||||
|
deleted_at: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await expect(loadActiveVersion("doc-1", db)).resolves.toEqual({
|
||||||
|
id: "ver-1",
|
||||||
|
storage_path: "documents/u/doc-1/source.docx",
|
||||||
|
pdf_storage_path: null,
|
||||||
|
version_number: null,
|
||||||
|
filename: null,
|
||||||
|
source: null,
|
||||||
|
file_type: null,
|
||||||
|
size_bytes: null,
|
||||||
|
page_count: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// attachActiveVersionPaths
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("attachActiveVersionPaths", () => {
|
||||||
|
it("returns the same empty array untouched", async () => {
|
||||||
|
const db = makeDb({ document_versions: [] });
|
||||||
|
const docs: TestDoc[] = [];
|
||||||
|
await expect(attachActiveVersionPaths(db, docs)).resolves.toBe(docs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nulls all fields when no document has a current version", async () => {
|
||||||
|
const db = makeDb({ document_versions: [FULL_VERSION] });
|
||||||
|
const [doc] = await attachActiveVersionPaths<TestDoc>(db, [
|
||||||
|
{ id: "doc-1", current_version_id: null },
|
||||||
|
]);
|
||||||
|
expect(doc).toMatchObject({
|
||||||
|
filename: "Untitled document",
|
||||||
|
storage_path: null,
|
||||||
|
pdf_storage_path: null,
|
||||||
|
file_type: null,
|
||||||
|
size_bytes: null,
|
||||||
|
page_count: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges active-version metadata onto each row", async () => {
|
||||||
|
const db = makeDb({
|
||||||
|
document_versions: [
|
||||||
|
FULL_VERSION,
|
||||||
|
{
|
||||||
|
...FULL_VERSION,
|
||||||
|
id: "ver-2",
|
||||||
|
storage_path: "documents/u/doc-2/source.docx",
|
||||||
|
pdf_storage_path: null,
|
||||||
|
filename: "nda.docx",
|
||||||
|
version_number: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const docs = await attachActiveVersionPaths<TestDoc>(db, [
|
||||||
|
{ id: "doc-1", current_version_id: "ver-1" },
|
||||||
|
{ id: "doc-2", current_version_id: "ver-2" },
|
||||||
|
{ id: "doc-3", current_version_id: null },
|
||||||
|
]);
|
||||||
|
expect(docs[0]).toMatchObject({
|
||||||
|
storage_path: "documents/u/doc-1/source.pdf",
|
||||||
|
pdf_storage_path: "documents/u/doc-1/converted.pdf",
|
||||||
|
active_version_number: 3,
|
||||||
|
filename: "contract.pdf",
|
||||||
|
file_type: "application/pdf",
|
||||||
|
size_bytes: 1024,
|
||||||
|
page_count: 12,
|
||||||
|
});
|
||||||
|
expect(docs[1]).toMatchObject({
|
||||||
|
storage_path: "documents/u/doc-2/source.docx",
|
||||||
|
pdf_storage_path: null,
|
||||||
|
active_version_number: 1,
|
||||||
|
filename: "nda.docx",
|
||||||
|
});
|
||||||
|
// Mixed list: the version-less doc still gets explicit nulls.
|
||||||
|
expect(docs[2]).toMatchObject({
|
||||||
|
storage_path: null,
|
||||||
|
filename: "Untitled document",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to 'Untitled document' for blank filenames", async () => {
|
||||||
|
const db = makeDb({
|
||||||
|
document_versions: [{ ...FULL_VERSION, filename: " " }],
|
||||||
|
});
|
||||||
|
const [doc] = await attachActiveVersionPaths<TestDoc>(db, [
|
||||||
|
{ id: "doc-1", current_version_id: "ver-1" },
|
||||||
|
]);
|
||||||
|
expect(doc.filename).toBe("Untitled document");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores soft-deleted versions", async () => {
|
||||||
|
const db = makeDb({
|
||||||
|
document_versions: [
|
||||||
|
{ ...FULL_VERSION, deleted_at: "2026-01-01T00:00:00Z" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const [doc] = await attachActiveVersionPaths<TestDoc>(db, [
|
||||||
|
{ id: "doc-1", current_version_id: "ver-1" },
|
||||||
|
]);
|
||||||
|
expect(doc.storage_path).toBeNull();
|
||||||
|
expect(doc.filename).toBe("Untitled document");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// attachLatestVersionNumbers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("attachLatestVersionNumbers", () => {
|
||||||
|
const versionRow = (
|
||||||
|
document_id: string,
|
||||||
|
version_number: number | null,
|
||||||
|
overrides: Row = {},
|
||||||
|
) => ({
|
||||||
|
document_id,
|
||||||
|
version_number,
|
||||||
|
source: "assistant_edit",
|
||||||
|
deleted_at: null,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the same empty array untouched", async () => {
|
||||||
|
const db = makeDb({ document_versions: [] });
|
||||||
|
const docs: TestDoc[] = [];
|
||||||
|
await expect(attachLatestVersionNumbers(db, docs)).resolves.toBe(docs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches the max assistant_edit version number per document", async () => {
|
||||||
|
const db = makeDb({
|
||||||
|
document_versions: [
|
||||||
|
versionRow("doc-1", 1),
|
||||||
|
versionRow("doc-1", 4),
|
||||||
|
versionRow("doc-1", 2),
|
||||||
|
versionRow("doc-2", 7),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const docs = await attachLatestVersionNumbers<TestDoc>(db, [
|
||||||
|
{ id: "doc-1" },
|
||||||
|
{ id: "doc-2" },
|
||||||
|
{ id: "doc-3" },
|
||||||
|
]);
|
||||||
|
expect(docs.map((d) => d.latest_version_number)).toEqual([4, 7, null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-assistant_edit and soft-deleted versions", async () => {
|
||||||
|
const db = makeDb({
|
||||||
|
document_versions: [
|
||||||
|
versionRow("doc-1", 9, { source: "upload" }),
|
||||||
|
versionRow("doc-1", 8, { deleted_at: "2026-01-01T00:00:00Z" }),
|
||||||
|
versionRow("doc-1", 2),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const docs = await attachLatestVersionNumbers<TestDoc>(db, [{ id: "doc-1" }]);
|
||||||
|
expect(docs[0].latest_version_number).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
109
backend/src/lib/__tests__/downloadTokens.test.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import { signDownload, verifyDownload, buildDownloadUrl } from "../downloadTokens";
|
||||||
|
|
||||||
|
const SECRET = "test-secret-32-bytes-long-enough!!";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.DOWNLOAD_SIGNING_SECRET = SECRET;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
delete process.env.DOWNLOAD_SIGNING_SECRET;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("signDownload", () => {
|
||||||
|
it("returns a two-part dot-separated token", () => {
|
||||||
|
const token = signDownload("documents/user/doc.pdf", "contract.pdf");
|
||||||
|
const parts = token.split(".");
|
||||||
|
expect(parts).toHaveLength(2);
|
||||||
|
expect(parts[0].length).toBeGreaterThan(0);
|
||||||
|
expect(parts[1].length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces different tokens for different paths", () => {
|
||||||
|
const t1 = signDownload("documents/a/file.pdf", "a.pdf");
|
||||||
|
const t2 = signDownload("documents/b/file.pdf", "b.pdf");
|
||||||
|
expect(t1).not.toBe(t2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses base64url characters only (no +, /, =)", () => {
|
||||||
|
const token = signDownload("documents/user/file.pdf", "file.pdf");
|
||||||
|
expect(token).not.toMatch(/[+/=]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("verifyDownload", () => {
|
||||||
|
it("round-trips a valid token", () => {
|
||||||
|
const path = "documents/user123/doc456/source.pdf";
|
||||||
|
const filename = "Contract Final v2.pdf";
|
||||||
|
const token = signDownload(path, filename);
|
||||||
|
const result = verifyDownload(token);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.path).toBe(path);
|
||||||
|
expect(result!.filename).toBe(filename);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a tampered payload", () => {
|
||||||
|
const token = signDownload("documents/user/file.pdf", "file.pdf");
|
||||||
|
const [, sig] = token.split(".");
|
||||||
|
const fakePayload = Buffer.from(
|
||||||
|
JSON.stringify({ p: "documents/attacker/file.pdf", f: "file.pdf" }),
|
||||||
|
)
|
||||||
|
.toString("base64")
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/g, "");
|
||||||
|
expect(verifyDownload(`${fakePayload}.${sig}`)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a tampered signature", () => {
|
||||||
|
const token = signDownload("documents/user/file.pdf", "file.pdf");
|
||||||
|
const [enc] = token.split(".");
|
||||||
|
const fakeSig = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||||
|
expect(verifyDownload(`${enc}.${fakeSig}`)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a token with too many parts", () => {
|
||||||
|
expect(verifyDownload("a.b.c")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a token with too few parts", () => {
|
||||||
|
expect(verifyDownload("onlyonepart")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when payload JSON is missing required fields", () => {
|
||||||
|
const bad = Buffer.from(JSON.stringify({ x: 1 }))
|
||||||
|
.toString("base64")
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/g, "");
|
||||||
|
const sig = Buffer.alloc(32).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||||
|
expect(verifyDownload(`${bad}.${sig}`)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when signed with a different secret", () => {
|
||||||
|
const token = signDownload("documents/user/file.pdf", "file.pdf");
|
||||||
|
process.env.DOWNLOAD_SIGNING_SECRET = "different-secret-value-!!";
|
||||||
|
const result = verifyDownload(token);
|
||||||
|
process.env.DOWNLOAD_SIGNING_SECRET = SECRET;
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildDownloadUrl", () => {
|
||||||
|
it("returns a path starting with /download/", () => {
|
||||||
|
const url = buildDownloadUrl("documents/user/file.pdf", "file.pdf");
|
||||||
|
expect(url).toMatch(/^\/download\//);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("embeds a verifiable token in the URL", () => {
|
||||||
|
const path = "documents/user/file.pdf";
|
||||||
|
const filename = "file.pdf";
|
||||||
|
const url = buildDownloadUrl(path, filename);
|
||||||
|
const token = url.replace("/download/", "");
|
||||||
|
const result = verifyDownload(token);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.path).toBe(path);
|
||||||
|
expect(result!.filename).toBe(filename);
|
||||||
|
});
|
||||||
|
});
|
||||||
119
backend/src/lib/__tests__/llmModels.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
CLAUDE_MAIN_MODELS,
|
||||||
|
GEMINI_MAIN_MODELS,
|
||||||
|
OPENAI_MAIN_MODELS,
|
||||||
|
CLAUDE_MID_MODELS,
|
||||||
|
GEMINI_MID_MODELS,
|
||||||
|
OPENAI_MID_MODELS,
|
||||||
|
CLAUDE_LOW_MODELS,
|
||||||
|
GEMINI_LOW_MODELS,
|
||||||
|
OPENAI_LOW_MODELS,
|
||||||
|
DEFAULT_MAIN_MODEL,
|
||||||
|
DEFAULT_TITLE_MODEL,
|
||||||
|
DEFAULT_TABULAR_MODEL,
|
||||||
|
providerForModel,
|
||||||
|
resolveModel,
|
||||||
|
} from "../llm/models";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// providerForModel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("providerForModel", () => {
|
||||||
|
it("maps claude-* ids to the claude provider", () => {
|
||||||
|
for (const model of [...CLAUDE_MAIN_MODELS, ...CLAUDE_MID_MODELS, ...CLAUDE_LOW_MODELS]) {
|
||||||
|
expect(providerForModel(model)).toBe("claude");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps gemini-* ids to the gemini provider", () => {
|
||||||
|
for (const model of [...GEMINI_MAIN_MODELS, ...GEMINI_MID_MODELS, ...GEMINI_LOW_MODELS]) {
|
||||||
|
expect(providerForModel(model)).toBe("gemini");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps gpt-* ids to the openai provider", () => {
|
||||||
|
for (const model of [...OPENAI_MAIN_MODELS, ...OPENAI_MID_MODELS, ...OPENAI_LOW_MODELS]) {
|
||||||
|
expect(providerForModel(model)).toBe("openai");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on an unknown model id", () => {
|
||||||
|
expect(() => providerForModel("llama-3")).toThrow(/Unknown model id/);
|
||||||
|
expect(() => providerForModel("")).toThrow(/Unknown model id/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("infers by prefix only, without validating against the catalog", () => {
|
||||||
|
// Documents current behavior: any claude-/gemini-/gpt- prefix is
|
||||||
|
// accepted even if the id is not a canonical model.
|
||||||
|
expect(providerForModel("claude-nonexistent")).toBe("claude");
|
||||||
|
expect(providerForModel("gpt-nonexistent")).toBe("openai");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// resolveModel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("resolveModel", () => {
|
||||||
|
it("returns a known model id unchanged", () => {
|
||||||
|
expect(resolveModel("claude-sonnet-4-6", DEFAULT_MAIN_MODEL)).toBe(
|
||||||
|
"claude-sonnet-4-6",
|
||||||
|
);
|
||||||
|
expect(resolveModel("gpt-5.4-lite", DEFAULT_TITLE_MODEL)).toBe(
|
||||||
|
"gpt-5.4-lite",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back for unknown model ids", () => {
|
||||||
|
expect(resolveModel("gpt-3.5-turbo", DEFAULT_MAIN_MODEL)).toBe(
|
||||||
|
DEFAULT_MAIN_MODEL,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back for null, undefined, and empty ids", () => {
|
||||||
|
expect(resolveModel(null, DEFAULT_MAIN_MODEL)).toBe(DEFAULT_MAIN_MODEL);
|
||||||
|
expect(resolveModel(undefined, DEFAULT_TABULAR_MODEL)).toBe(
|
||||||
|
DEFAULT_TABULAR_MODEL,
|
||||||
|
);
|
||||||
|
expect(resolveModel("", DEFAULT_TITLE_MODEL)).toBe(DEFAULT_TITLE_MODEL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts models from every tier of the catalog", () => {
|
||||||
|
const catalog = [
|
||||||
|
...CLAUDE_MAIN_MODELS,
|
||||||
|
...GEMINI_MAIN_MODELS,
|
||||||
|
...OPENAI_MAIN_MODELS,
|
||||||
|
...CLAUDE_MID_MODELS,
|
||||||
|
...GEMINI_MID_MODELS,
|
||||||
|
...OPENAI_MID_MODELS,
|
||||||
|
...CLAUDE_LOW_MODELS,
|
||||||
|
...GEMINI_LOW_MODELS,
|
||||||
|
...OPENAI_LOW_MODELS,
|
||||||
|
];
|
||||||
|
for (const model of catalog) {
|
||||||
|
expect(resolveModel(model, "fallback-model")).toBe(model);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Default model sanity
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("default models", () => {
|
||||||
|
it("every default resolves to itself (defaults are in the catalog)", () => {
|
||||||
|
expect(resolveModel(DEFAULT_MAIN_MODEL, "x")).toBe(DEFAULT_MAIN_MODEL);
|
||||||
|
expect(resolveModel(DEFAULT_TITLE_MODEL, "x")).toBe(DEFAULT_TITLE_MODEL);
|
||||||
|
expect(resolveModel(DEFAULT_TABULAR_MODEL, "x")).toBe(
|
||||||
|
DEFAULT_TABULAR_MODEL,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every default has a resolvable provider", () => {
|
||||||
|
expect(providerForModel(DEFAULT_MAIN_MODEL)).toBe("gemini");
|
||||||
|
expect(providerForModel(DEFAULT_TITLE_MODEL)).toBe("gemini");
|
||||||
|
expect(providerForModel(DEFAULT_TABULAR_MODEL)).toBe("gemini");
|
||||||
|
});
|
||||||
|
});
|
||||||
154
backend/src/lib/__tests__/safeError.test.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
redactSensitiveText,
|
||||||
|
safeErrorMessage,
|
||||||
|
safeErrorLog,
|
||||||
|
} from "../safeError";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// redactSensitiveText
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("redactSensitiveText", () => {
|
||||||
|
it('redacts the OpenAI "Incorrect API key provided" message', () => {
|
||||||
|
expect(
|
||||||
|
redactSensitiveText(
|
||||||
|
"Incorrect API key provided: sk-proj-abc123def456ghi789.",
|
||||||
|
),
|
||||||
|
).toBe("Incorrect API key provided: [redacted].");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the trailing period optional in the incorrect-key message", () => {
|
||||||
|
expect(
|
||||||
|
redactSensitiveText("Incorrect API key provided: badkey123"),
|
||||||
|
).toBe("Incorrect API key provided: [redacted]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts secrets after api_key labels", () => {
|
||||||
|
expect(redactSensitiveText("api_key: mysecret123")).toBe(
|
||||||
|
"api_key: [redacted]",
|
||||||
|
);
|
||||||
|
expect(redactSensitiveText("api key = mysecret123")).toBe(
|
||||||
|
"api key = [redacted]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts secrets after token/secret/authorization labels", () => {
|
||||||
|
expect(redactSensitiveText("token: abcdef123456")).toBe(
|
||||||
|
"token: [redacted]",
|
||||||
|
);
|
||||||
|
expect(redactSensitiveText("secret is abcdef123456")).toBe(
|
||||||
|
"secret is [redacted]",
|
||||||
|
);
|
||||||
|
expect(redactSensitiveText("authorization: abcdef123456")).toBe(
|
||||||
|
"authorization: [redacted]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves short values after labels alone (below 6 chars)", () => {
|
||||||
|
expect(redactSensitiveText("token: abc")).toBe("token: abc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts bare OpenAI-style sk- keys anywhere in the text", () => {
|
||||||
|
expect(
|
||||||
|
redactSensitiveText("request failed for sk-abc123def456ghi789 today"),
|
||||||
|
).toBe("request failed for [redacted] today");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts bare Anthropic-style sk-ant- keys", () => {
|
||||||
|
expect(
|
||||||
|
redactSensitiveText("used sk-ant-api03-abc123def456"),
|
||||||
|
).toBe("used [redacted]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts bare Google AIza keys", () => {
|
||||||
|
expect(
|
||||||
|
redactSensitiveText("key AIzaSyA1234567890abcdefghij failed"),
|
||||||
|
).toBe("key [redacted] failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts multiple secrets in one string", () => {
|
||||||
|
const result = redactSensitiveText(
|
||||||
|
"first sk-abc123def456ghi789 then AIzaSyA1234567890abcdefghij",
|
||||||
|
);
|
||||||
|
expect(result).toBe("first [redacted] then [redacted]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves ordinary text unchanged", () => {
|
||||||
|
expect(redactSensitiveText("Document not found")).toBe(
|
||||||
|
"Document not found",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// safeErrorMessage
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("safeErrorMessage", () => {
|
||||||
|
it("uses the message of an Error instance", () => {
|
||||||
|
expect(safeErrorMessage(new Error("boom"))).toBe("boom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts secrets inside an Error message", () => {
|
||||||
|
expect(
|
||||||
|
safeErrorMessage(new Error("bad key sk-abc123def456ghi789")),
|
||||||
|
).toBe("bad key [redacted]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes plain strings through (redacted)", () => {
|
||||||
|
expect(safeErrorMessage("token: abcdef123456")).toBe(
|
||||||
|
"token: [redacted]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back for non-Error, non-string values", () => {
|
||||||
|
expect(safeErrorMessage(42)).toBe("Unexpected error");
|
||||||
|
expect(safeErrorMessage(null)).toBe("Unexpected error");
|
||||||
|
expect(safeErrorMessage({ message: "obj" })).toBe("Unexpected error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back for an Error with an empty message", () => {
|
||||||
|
expect(safeErrorMessage(new Error(""))).toBe("Unexpected error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors a custom fallback", () => {
|
||||||
|
expect(safeErrorMessage(undefined, "Chat failed")).toBe("Chat failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// safeErrorLog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("safeErrorLog", () => {
|
||||||
|
it("captures name, message, and stack for an Error", () => {
|
||||||
|
const error = new Error("boom");
|
||||||
|
const log = safeErrorLog(error);
|
||||||
|
expect(log.name).toBe("Error");
|
||||||
|
expect(log.message).toBe("boom");
|
||||||
|
expect(log.stack).toContain("boom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts secrets in the message and stack", () => {
|
||||||
|
const error = new Error("bad key sk-abc123def456ghi789");
|
||||||
|
const log = safeErrorLog(error);
|
||||||
|
expect(log.message).toBe("bad key [redacted]");
|
||||||
|
expect(log.stack).not.toContain("sk-abc123def456ghi789");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to 'Unexpected error' for an empty Error message", () => {
|
||||||
|
expect(safeErrorLog(new Error("")).message).toBe("Unexpected error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the stack when the Error has none", () => {
|
||||||
|
const error = new Error("boom");
|
||||||
|
error.stack = undefined;
|
||||||
|
expect(safeErrorLog(error).stack).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles non-Error values with a null name and no stack", () => {
|
||||||
|
const log = safeErrorLog("plain failure");
|
||||||
|
expect(log).toEqual({ name: null, message: "plain failure" });
|
||||||
|
});
|
||||||
|
});
|
||||||
149
backend/src/lib/__tests__/storage.test.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
normalizeDownloadFilename,
|
||||||
|
sanitizeDispositionFilename,
|
||||||
|
encodeRFC5987,
|
||||||
|
buildContentDisposition,
|
||||||
|
storageKey,
|
||||||
|
pdfStorageKey,
|
||||||
|
generatedDocKey,
|
||||||
|
versionStorageKey,
|
||||||
|
} from "../storage";
|
||||||
|
|
||||||
|
describe("normalizeDownloadFilename", () => {
|
||||||
|
it("trims surrounding whitespace", () => {
|
||||||
|
expect(normalizeDownloadFilename(" file.pdf ")).toBe("file.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to 'download' for empty string", () => {
|
||||||
|
expect(normalizeDownloadFilename("")).toBe("download");
|
||||||
|
expect(normalizeDownloadFilename(" ")).toBe("download");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces control characters with underscore", () => {
|
||||||
|
expect(normalizeDownloadFilename("file\x00name.pdf")).toBe("file_name.pdf");
|
||||||
|
expect(normalizeDownloadFilename("file\x1fname.pdf")).toBe("file_name.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces forward and backward slashes with underscore", () => {
|
||||||
|
expect(normalizeDownloadFilename("dir/file.pdf")).toBe("dir_file.pdf");
|
||||||
|
expect(normalizeDownloadFilename("dir\\file.pdf")).toBe("dir_file.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves normal filenames unchanged", () => {
|
||||||
|
expect(normalizeDownloadFilename("Contract v2 (Final).pdf")).toBe(
|
||||||
|
"Contract v2 (Final).pdf",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeDispositionFilename", () => {
|
||||||
|
it("strips double-quote characters", () => {
|
||||||
|
expect(sanitizeDispositionFilename('file"name.pdf')).toBe("file_name.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips backslash characters", () => {
|
||||||
|
expect(sanitizeDispositionFilename("file\\name.pdf")).toBe("file_name.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips non-ASCII characters", () => {
|
||||||
|
expect(sanitizeDispositionFilename("filéname.pdf")).toBe("fil_name.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still applies normalizeDownloadFilename rules first", () => {
|
||||||
|
expect(sanitizeDispositionFilename(" ")).toBe("download");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("encodeRFC5987", () => {
|
||||||
|
it("encodes spaces as %20", () => {
|
||||||
|
expect(encodeRFC5987("hello world")).toBe("hello%20world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes single-quote as %27", () => {
|
||||||
|
expect(encodeRFC5987("it's")).toContain("%27");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes ( and ) as %28 and %29", () => {
|
||||||
|
const result = encodeRFC5987("a(b)c");
|
||||||
|
expect(result).toContain("%28");
|
||||||
|
expect(result).toContain("%29");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes * as %2A", () => {
|
||||||
|
expect(encodeRFC5987("a*b")).toContain("%2A");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves safe ASCII characters unencoded", () => {
|
||||||
|
expect(encodeRFC5987("file.pdf")).toBe("file.pdf");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildContentDisposition", () => {
|
||||||
|
it("produces an attachment header with ASCII filename", () => {
|
||||||
|
const header = buildContentDisposition("attachment", "contract.pdf");
|
||||||
|
expect(header).toMatch(/^attachment;/);
|
||||||
|
expect(header).toContain('filename="contract.pdf"');
|
||||||
|
expect(header).toContain("filename*=UTF-8''contract.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces an inline header", () => {
|
||||||
|
const header = buildContentDisposition("inline", "preview.pdf");
|
||||||
|
expect(header).toMatch(/^inline;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes unicode filename in filename* param", () => {
|
||||||
|
const header = buildContentDisposition("attachment", "Ünïcödé.pdf");
|
||||||
|
expect(header).toContain("filename*=UTF-8''");
|
||||||
|
expect(header).not.toContain("Ü");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("storageKey", () => {
|
||||||
|
it("includes userId, docId, and correct extension", () => {
|
||||||
|
const key = storageKey("user1", "doc1", "contract.pdf");
|
||||||
|
expect(key).toBe("documents/user1/doc1/source.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to .bin for extensions longer than 16 chars", () => {
|
||||||
|
const key = storageKey("user1", "doc1", "file.toolongextension1234");
|
||||||
|
expect(key).toBe("documents/user1/doc1/source.bin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to .bin when no extension", () => {
|
||||||
|
const key = storageKey("user1", "doc1", "noextension");
|
||||||
|
expect(key).toBe("documents/user1/doc1/source.bin");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pdfStorageKey", () => {
|
||||||
|
it("places PDF in the correct path with stem", () => {
|
||||||
|
const key = pdfStorageKey("user1", "doc1", "contract");
|
||||||
|
expect(key).toBe("documents/user1/doc1/contract.pdf");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generatedDocKey", () => {
|
||||||
|
it("uses generated/ prefix and .docx extension for docx files", () => {
|
||||||
|
const key = generatedDocKey("user1", "doc1", "output.docx");
|
||||||
|
expect(key).toBe("generated/user1/doc1/generated.docx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to .docx for extensions longer than 16 chars", () => {
|
||||||
|
const key = generatedDocKey("user1", "doc1", "output.toolongextension1234");
|
||||||
|
expect(key).toBe("generated/user1/doc1/generated.docx");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("versionStorageKey", () => {
|
||||||
|
it("includes userId, docId, versionSlug, and extension", () => {
|
||||||
|
const key = versionStorageKey("user1", "doc1", "v2", "contract.pdf");
|
||||||
|
expect(key).toBe("documents/user1/doc1/versions/v2.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to .bin for unknown extensions", () => {
|
||||||
|
const key = versionStorageKey("user1", "doc1", "v2", "file");
|
||||||
|
expect(key).toBe("documents/user1/doc1/versions/v2.bin");
|
||||||
|
});
|
||||||
|
});
|
||||||
73
backend/src/lib/__tests__/userApiKeys.test.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { normalizeApiKeyProvider, hasEnvApiKey } from "../userApiKeys";
|
||||||
|
|
||||||
|
describe("normalizeApiKeyProvider", () => {
|
||||||
|
it('returns "claude" for "claude"', () => {
|
||||||
|
expect(normalizeApiKeyProvider("claude")).toBe("claude");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "openai" for "openai"', () => {
|
||||||
|
expect(normalizeApiKeyProvider("openai")).toBe("openai");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "gemini" for "gemini"', () => {
|
||||||
|
expect(normalizeApiKeyProvider("gemini")).toBe("gemini");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for unknown provider strings", () => {
|
||||||
|
expect(normalizeApiKeyProvider("unknown")).toBeNull();
|
||||||
|
expect(normalizeApiKeyProvider("")).toBeNull();
|
||||||
|
expect(normalizeApiKeyProvider("Claude")).toBeNull();
|
||||||
|
expect(normalizeApiKeyProvider("OPENAI")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasEnvApiKey", () => {
|
||||||
|
const envVars = [
|
||||||
|
"ANTHROPIC_API_KEY",
|
||||||
|
"CLAUDE_API_KEY",
|
||||||
|
"OPENAI_API_KEY",
|
||||||
|
"GEMINI_API_KEY",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Clear before AND after each test so keys exported in the developer's
|
||||||
|
// shell (or CI) can't leak into assertions.
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const v of envVars) delete process.env[v];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const v of envVars) delete process.env[v];
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for claude when ANTHROPIC_API_KEY is set", () => {
|
||||||
|
process.env.ANTHROPIC_API_KEY = "sk-ant-test";
|
||||||
|
expect(hasEnvApiKey("claude")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for claude when CLAUDE_API_KEY is set as fallback", () => {
|
||||||
|
process.env.CLAUDE_API_KEY = "sk-claude-test";
|
||||||
|
expect(hasEnvApiKey("claude")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for openai when OPENAI_API_KEY is set", () => {
|
||||||
|
process.env.OPENAI_API_KEY = "sk-openai-test";
|
||||||
|
expect(hasEnvApiKey("openai")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for gemini when GEMINI_API_KEY is set", () => {
|
||||||
|
process.env.GEMINI_API_KEY = "gemini-key-test";
|
||||||
|
expect(hasEnvApiKey("gemini")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when no env key is set for the provider", () => {
|
||||||
|
expect(hasEnvApiKey("claude")).toBe(false);
|
||||||
|
expect(hasEnvApiKey("openai")).toBe(false);
|
||||||
|
expect(hasEnvApiKey("gemini")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores whitespace-only env values", () => {
|
||||||
|
process.env.ANTHROPIC_API_KEY = " ";
|
||||||
|
expect(hasEnvApiKey("claude")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
432
backend/src/lib/__tests__/userDataCleanup.test.ts
Normal file
|
|
@ -0,0 +1,432 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("../storage", () => ({
|
||||||
|
deleteFile: vi.fn(async () => {}),
|
||||||
|
listFiles: vi.fn(async () => [] as string[]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { deleteFile, listFiles } from "../storage";
|
||||||
|
import {
|
||||||
|
deleteAllUserChats,
|
||||||
|
deleteAllUserTabularReviews,
|
||||||
|
deleteUserProjects,
|
||||||
|
deleteUserAccountData,
|
||||||
|
} from "../userDataCleanup";
|
||||||
|
|
||||||
|
const deleteFileMock = vi.mocked(deleteFile);
|
||||||
|
const listFilesMock = vi.mocked(listFiles);
|
||||||
|
|
||||||
|
type Row = Record<string, unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stateful Supabase mock: deletes and updates mutate `tables`, so tests can
|
||||||
|
* assert on exactly which rows survived a cleanup call. Supports the chains
|
||||||
|
* userDataCleanup uses (select/delete/update + eq/in/filter-cs) and can
|
||||||
|
* inject a delete error per table to exercise error propagation.
|
||||||
|
*/
|
||||||
|
function makeDb(
|
||||||
|
initialTables: Record<string, Row[]>,
|
||||||
|
options: { deleteErrors?: Record<string, string> } = {},
|
||||||
|
) {
|
||||||
|
const tables: Record<string, Row[]> = {};
|
||||||
|
for (const [name, rows] of Object.entries(initialTables)) {
|
||||||
|
tables[name] = rows.map((row) => ({ ...row }));
|
||||||
|
}
|
||||||
|
const db = {
|
||||||
|
from(table: string) {
|
||||||
|
const rowsOf = () => tables[table] ?? (tables[table] = []);
|
||||||
|
let predicate: (row: Row) => boolean = () => true;
|
||||||
|
let mode: "select" | "delete" | "update" = "select";
|
||||||
|
let patch: Row = {};
|
||||||
|
const narrow = (next: (row: Row) => boolean) => {
|
||||||
|
const prev = predicate;
|
||||||
|
predicate = (row) => prev(row) && next(row);
|
||||||
|
};
|
||||||
|
const query: any = {
|
||||||
|
select: () => query,
|
||||||
|
delete: () => {
|
||||||
|
mode = "delete";
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
update: (value: Row) => {
|
||||||
|
mode = "update";
|
||||||
|
patch = value;
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
eq: (column: string, value: unknown) => {
|
||||||
|
narrow((row) => row[column] === value);
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
in: (column: string, values: unknown[]) => {
|
||||||
|
narrow((row) => values.includes(row[column]));
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
filter: (column: string, operator: string, value: string) => {
|
||||||
|
if (operator !== "cs") return query;
|
||||||
|
const expected = (JSON.parse(value) as string[]).map((item) =>
|
||||||
|
item.toLowerCase(),
|
||||||
|
);
|
||||||
|
narrow((row) => {
|
||||||
|
const actual = row[column];
|
||||||
|
if (!Array.isArray(actual)) return false;
|
||||||
|
const normalized = actual.map((item) =>
|
||||||
|
String(item).toLowerCase(),
|
||||||
|
);
|
||||||
|
return expected.every((item) => normalized.includes(item));
|
||||||
|
});
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
then: (
|
||||||
|
resolve: (value: { data: Row[] | null; error: unknown }) => unknown,
|
||||||
|
reject?: (reason: unknown) => unknown,
|
||||||
|
) => {
|
||||||
|
let result: { data: Row[] | null; error: unknown };
|
||||||
|
if (mode === "delete") {
|
||||||
|
const message = options.deleteErrors?.[table];
|
||||||
|
if (message) {
|
||||||
|
result = { data: null, error: { message } };
|
||||||
|
} else {
|
||||||
|
tables[table] = rowsOf().filter((row) => !predicate(row));
|
||||||
|
result = { data: null, error: null };
|
||||||
|
}
|
||||||
|
} else if (mode === "update") {
|
||||||
|
for (const row of rowsOf().filter(predicate)) {
|
||||||
|
Object.assign(row, patch);
|
||||||
|
}
|
||||||
|
result = { data: null, error: null };
|
||||||
|
} else {
|
||||||
|
result = {
|
||||||
|
data: rowsOf().filter(predicate).map((row) => ({ ...row })),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return Promise.resolve(result).then(resolve, reject);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { db: db as any, tables };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = (rows: Row[] | undefined) => (rows ?? []).map((row) => row.id);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
deleteFileMock.mockClear();
|
||||||
|
deleteFileMock.mockResolvedValue(undefined as never);
|
||||||
|
listFilesMock.mockClear();
|
||||||
|
listFilesMock.mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// deleteAllUserChats
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("deleteAllUserChats", () => {
|
||||||
|
it("deletes only the target user's assistant and tabular chats", async () => {
|
||||||
|
const { db, tables } = makeDb({
|
||||||
|
chats: [
|
||||||
|
{ id: "c1", user_id: "u1" },
|
||||||
|
{ id: "c2", user_id: "u2" },
|
||||||
|
],
|
||||||
|
tabular_review_chats: [
|
||||||
|
{ id: "tc1", user_id: "u1" },
|
||||||
|
{ id: "tc2", user_id: "u2" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await deleteAllUserChats(db, "u1");
|
||||||
|
expect(ids(tables.chats)).toEqual(["c2"]);
|
||||||
|
expect(ids(tables.tabular_review_chats)).toEqual(["tc2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces delete failures with context", async () => {
|
||||||
|
const { db } = makeDb(
|
||||||
|
{ chats: [{ id: "c1", user_id: "u1" }], tabular_review_chats: [] },
|
||||||
|
{ deleteErrors: { chats: "boom" } },
|
||||||
|
);
|
||||||
|
await expect(deleteAllUserChats(db, "u1")).rejects.toThrow(
|
||||||
|
"Failed to delete assistant chats: boom",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// deleteAllUserTabularReviews
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("deleteAllUserTabularReviews", () => {
|
||||||
|
const fixture = () =>
|
||||||
|
makeDb({
|
||||||
|
tabular_reviews: [
|
||||||
|
{ id: "r1", user_id: "u1" },
|
||||||
|
{ id: "r2", user_id: "u1" },
|
||||||
|
{ id: "r-other", user_id: "u2" },
|
||||||
|
],
|
||||||
|
tabular_review_chats: [
|
||||||
|
{ id: "rc1", review_id: "r1" },
|
||||||
|
{ id: "rc-other", review_id: "r-other" },
|
||||||
|
],
|
||||||
|
tabular_review_chat_messages: [
|
||||||
|
{ id: "rm1", chat_id: "rc1" },
|
||||||
|
{ id: "rm-other", chat_id: "rc-other" },
|
||||||
|
],
|
||||||
|
tabular_cells: [
|
||||||
|
{ id: "cell1", review_id: "r1" },
|
||||||
|
{ id: "cell-other", review_id: "r-other" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cascades messages, chats, and cells before the reviews", async () => {
|
||||||
|
const { db, tables } = fixture();
|
||||||
|
await expect(deleteAllUserTabularReviews(db, "u1")).resolves.toBe(2);
|
||||||
|
expect(ids(tables.tabular_reviews)).toEqual(["r-other"]);
|
||||||
|
expect(ids(tables.tabular_review_chats)).toEqual(["rc-other"]);
|
||||||
|
expect(ids(tables.tabular_review_chat_messages)).toEqual(["rm-other"]);
|
||||||
|
expect(ids(tables.tabular_cells)).toEqual(["cell-other"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 and deletes nothing for a user with no reviews", async () => {
|
||||||
|
const { db, tables } = fixture();
|
||||||
|
await expect(deleteAllUserTabularReviews(db, "u3")).resolves.toBe(0);
|
||||||
|
expect(tables.tabular_reviews).toHaveLength(3);
|
||||||
|
expect(tables.tabular_cells).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// deleteUserProjects
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("deleteUserProjects", () => {
|
||||||
|
const fixture = () =>
|
||||||
|
makeDb({
|
||||||
|
projects: [
|
||||||
|
{ id: "p1", user_id: "u1" },
|
||||||
|
{ id: "p2", user_id: "u1" },
|
||||||
|
{ id: "p-other", user_id: "u2" },
|
||||||
|
],
|
||||||
|
documents: [
|
||||||
|
{ id: "d1", user_id: "u1", project_id: "p1" },
|
||||||
|
{ id: "d-loose", user_id: "u1", project_id: null },
|
||||||
|
{ id: "d-other", user_id: "u2", project_id: "p-other" },
|
||||||
|
],
|
||||||
|
document_versions: [
|
||||||
|
{
|
||||||
|
id: "v1",
|
||||||
|
document_id: "d1",
|
||||||
|
storage_path: "documents/u1/d1/source.pdf",
|
||||||
|
pdf_storage_path: "documents/u1/d1/converted.pdf",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "v-other",
|
||||||
|
document_id: "d-other",
|
||||||
|
storage_path: "documents/u2/d-other/source.pdf",
|
||||||
|
pdf_storage_path: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
chats: [
|
||||||
|
{ id: "c1", project_id: "p1" },
|
||||||
|
{ id: "c-other", project_id: "p-other" },
|
||||||
|
],
|
||||||
|
chat_messages: [
|
||||||
|
{ id: "m1", chat_id: "c1" },
|
||||||
|
{ id: "m-other", chat_id: "c-other" },
|
||||||
|
],
|
||||||
|
tabular_reviews: [
|
||||||
|
{ id: "r1", project_id: "p1" },
|
||||||
|
{ id: "r-other", project_id: "p-other" },
|
||||||
|
],
|
||||||
|
tabular_review_chats: [
|
||||||
|
{ id: "rc1", review_id: "r1" },
|
||||||
|
{ id: "rc-other", review_id: "r-other" },
|
||||||
|
],
|
||||||
|
tabular_review_chat_messages: [
|
||||||
|
{ id: "rm1", chat_id: "rc1" },
|
||||||
|
{ id: "rm-other", chat_id: "rc-other" },
|
||||||
|
],
|
||||||
|
tabular_cells: [
|
||||||
|
{ id: "cell1", review_id: "r1" },
|
||||||
|
{ id: "cell-other", review_id: "r-other" },
|
||||||
|
],
|
||||||
|
project_subfolders: [
|
||||||
|
{ id: "f1", project_id: "p1" },
|
||||||
|
{ id: "f-other", project_id: "p-other" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cascades project contents and storage files for owned projects", async () => {
|
||||||
|
const { db, tables } = fixture();
|
||||||
|
await expect(deleteUserProjects(db, "u1")).resolves.toBe(2);
|
||||||
|
|
||||||
|
expect(ids(tables.projects)).toEqual(["p-other"]);
|
||||||
|
expect(ids(tables.documents)).toEqual(["d-loose", "d-other"]);
|
||||||
|
expect(ids(tables.chats)).toEqual(["c-other"]);
|
||||||
|
expect(ids(tables.chat_messages)).toEqual(["m-other"]);
|
||||||
|
expect(ids(tables.tabular_reviews)).toEqual(["r-other"]);
|
||||||
|
expect(ids(tables.tabular_review_chats)).toEqual(["rc-other"]);
|
||||||
|
expect(ids(tables.tabular_review_chat_messages)).toEqual(["rm-other"]);
|
||||||
|
expect(ids(tables.tabular_cells)).toEqual(["cell-other"]);
|
||||||
|
expect(ids(tables.project_subfolders)).toEqual(["f-other"]);
|
||||||
|
|
||||||
|
const deletedPaths = deleteFileMock.mock.calls.map(([path]) => path);
|
||||||
|
expect(deletedPaths.sort()).toEqual([
|
||||||
|
"documents/u1/d1/converted.pdf",
|
||||||
|
"documents/u1/d1/source.pdf",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restricts deletion to the requested owned projects", async () => {
|
||||||
|
const { db, tables } = fixture();
|
||||||
|
// p-other belongs to u2, so requesting it must not delete anything of theirs.
|
||||||
|
await expect(
|
||||||
|
deleteUserProjects(db, "u1", ["p2", "p-other", "p2"]),
|
||||||
|
).resolves.toBe(1);
|
||||||
|
expect(ids(tables.projects)).toEqual(["p1", "p-other"]);
|
||||||
|
expect(ids(tables.documents)).toEqual(["d1", "d-loose", "d-other"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 for an explicitly empty project list", async () => {
|
||||||
|
const { db, tables } = fixture();
|
||||||
|
await expect(deleteUserProjects(db, "u1", [])).resolves.toBe(0);
|
||||||
|
expect(tables.projects).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 when the user owns no projects", async () => {
|
||||||
|
const { db, tables } = fixture();
|
||||||
|
await expect(deleteUserProjects(db, "u3")).resolves.toBe(0);
|
||||||
|
expect(tables.projects).toHaveLength(3);
|
||||||
|
expect(deleteFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// deleteUserAccountData
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("deleteUserAccountData", () => {
|
||||||
|
const fixture = () =>
|
||||||
|
makeDb({
|
||||||
|
projects: [
|
||||||
|
{ id: "p1", user_id: "u1", shared_with: [] },
|
||||||
|
{
|
||||||
|
id: "p-other",
|
||||||
|
user_id: "u2",
|
||||||
|
shared_with: ["u1@example.com", " U1@Example.com ", "keep@example.com"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tabular_reviews: [
|
||||||
|
{ id: "r1", user_id: "u1", shared_with: [] },
|
||||||
|
{ id: "r-other", user_id: "u2", shared_with: ["u1@example.com"] },
|
||||||
|
],
|
||||||
|
documents: [
|
||||||
|
{ id: "d1", user_id: "u1", project_id: null },
|
||||||
|
// Guest doc uploaded by another user into u1's project: deleted too.
|
||||||
|
{ id: "d-guest", user_id: "u2", project_id: "p1" },
|
||||||
|
{ id: "d-other", user_id: "u2", project_id: "p-other" },
|
||||||
|
],
|
||||||
|
document_versions: [
|
||||||
|
{
|
||||||
|
id: "v1",
|
||||||
|
document_id: "d1",
|
||||||
|
storage_path: "documents/u1/d1/source.pdf",
|
||||||
|
pdf_storage_path: "documents/u1/d1/converted.pdf",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "v-guest",
|
||||||
|
document_id: "d-guest",
|
||||||
|
storage_path: "documents/u2/d-guest/source.docx",
|
||||||
|
pdf_storage_path: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "v-other",
|
||||||
|
document_id: "d-other",
|
||||||
|
storage_path: "documents/u2/d-other/source.pdf",
|
||||||
|
pdf_storage_path: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
chats: [
|
||||||
|
{ id: "c1", user_id: "u1" },
|
||||||
|
{ id: "c-other", user_id: "u2" },
|
||||||
|
],
|
||||||
|
tabular_review_chats: [{ id: "rc1", user_id: "u1" }],
|
||||||
|
project_subfolders: [{ id: "f1", user_id: "u1" }],
|
||||||
|
hidden_workflows: [{ id: "h1", user_id: "u1" }],
|
||||||
|
workflow_open_source_submissions: [
|
||||||
|
{ id: "s1", submitted_by_user_id: "u1" },
|
||||||
|
],
|
||||||
|
workflow_shares: [
|
||||||
|
{ id: "ws-by", shared_by_user_id: "u1", shared_with_email: "x@y.z" },
|
||||||
|
{
|
||||||
|
id: "ws-to",
|
||||||
|
shared_by_user_id: "u2",
|
||||||
|
shared_with_email: "u1@example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ws-keep",
|
||||||
|
shared_by_user_id: "u2",
|
||||||
|
shared_with_email: "keep@example.com",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
workflows: [
|
||||||
|
{ id: "w1", user_id: "u1" },
|
||||||
|
{ id: "w-other", user_id: "u2" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the user's rows, files, and share references everywhere", async () => {
|
||||||
|
const { db, tables } = fixture();
|
||||||
|
listFilesMock.mockResolvedValue(["documents/u1/orphan.bin"]);
|
||||||
|
|
||||||
|
await deleteUserAccountData(db, "u1", " U1@Example.COM ");
|
||||||
|
|
||||||
|
// Owned docs and guest docs inside owned projects are gone.
|
||||||
|
expect(ids(tables.documents)).toEqual(["d-other"]);
|
||||||
|
expect(ids(tables.projects)).toEqual(["p-other"]);
|
||||||
|
expect(ids(tables.chats)).toEqual(["c-other"]);
|
||||||
|
expect(tables.tabular_review_chats).toEqual([]);
|
||||||
|
expect(ids(tables.tabular_reviews)).toEqual(["r-other"]);
|
||||||
|
expect(tables.project_subfolders).toEqual([]);
|
||||||
|
expect(tables.hidden_workflows).toEqual([]);
|
||||||
|
expect(tables.workflow_open_source_submissions).toEqual([]);
|
||||||
|
expect(ids(tables.workflows)).toEqual(["w-other"]);
|
||||||
|
|
||||||
|
// Shares by the user and shares to the user's email are both removed.
|
||||||
|
expect(ids(tables.workflow_shares)).toEqual(["ws-keep"]);
|
||||||
|
|
||||||
|
// The email is scrubbed from other users' shared_with lists
|
||||||
|
// (case-insensitively), preserving other collaborators.
|
||||||
|
expect(tables.projects[0].shared_with).toEqual(["keep@example.com"]);
|
||||||
|
expect(tables.tabular_reviews[0].shared_with).toEqual([]);
|
||||||
|
|
||||||
|
// Version files for deleted docs plus orphans under the user's prefix.
|
||||||
|
const deletedPaths = deleteFileMock.mock.calls.map(([path]) => path);
|
||||||
|
expect(deletedPaths.sort()).toEqual([
|
||||||
|
"documents/u1/d1/converted.pdf",
|
||||||
|
"documents/u1/d1/source.pdf",
|
||||||
|
"documents/u1/orphan.bin",
|
||||||
|
"documents/u2/d-guest/source.docx",
|
||||||
|
]);
|
||||||
|
expect(listFilesMock).toHaveBeenCalledWith("documents/u1/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats storage prefix cleanup as best-effort", async () => {
|
||||||
|
const { db, tables } = fixture();
|
||||||
|
listFilesMock.mockRejectedValue(new Error("storage unavailable"));
|
||||||
|
await expect(
|
||||||
|
deleteUserAccountData(db, "u1", "u1@example.com"),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(ids(tables.documents)).toEqual(["d-other"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips shared_with scrubbing when no email is known", async () => {
|
||||||
|
const { db, tables } = fixture();
|
||||||
|
await deleteUserAccountData(db, "u1", null);
|
||||||
|
// Rows referencing the email by value are left in place...
|
||||||
|
expect(tables.projects.find((row) => row.id === "p-other")?.shared_with)
|
||||||
|
.toContain("u1@example.com");
|
||||||
|
expect(ids(tables.workflow_shares)).toEqual(["ws-to", "ws-keep"]);
|
||||||
|
// ...but the user's own data is still deleted.
|
||||||
|
expect(ids(tables.documents)).toEqual(["d-other"]);
|
||||||
|
expect(tables.workflows.map((row) => row.id)).toEqual(["w-other"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
267
backend/src/lib/__tests__/userLookup.test.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
normalizeEmail,
|
||||||
|
normalizeDisplayName,
|
||||||
|
loadProfileUsersByEmail,
|
||||||
|
findProfileUserByEmail,
|
||||||
|
findMissingUserEmails,
|
||||||
|
syncProfileEmail,
|
||||||
|
} from "../userLookup";
|
||||||
|
|
||||||
|
type Row = Record<string, unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal user_profiles-shaped Supabase mock. Supports the query chains
|
||||||
|
* userLookup uses (select/eq/in/not + single-row readers) plus insert and
|
||||||
|
* update so syncProfileEmail can be exercised end to end.
|
||||||
|
*/
|
||||||
|
function makeDb(initialRows: Row[]) {
|
||||||
|
const tables: Record<string, Row[]> = {
|
||||||
|
user_profiles: initialRows.map((row) => ({ ...row })),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
tables,
|
||||||
|
from(table: string) {
|
||||||
|
const all = () => tables[table] ?? [];
|
||||||
|
let predicate: (row: Row) => boolean = () => true;
|
||||||
|
let mode: "select" | "insert" | "update" = "select";
|
||||||
|
let pendingRow: Row = {};
|
||||||
|
const narrow = (next: (row: Row) => boolean) => {
|
||||||
|
const prev = predicate;
|
||||||
|
predicate = (row) => prev(row) && next(row);
|
||||||
|
};
|
||||||
|
const query: any = {
|
||||||
|
select: () => query,
|
||||||
|
insert: (row: Row) => {
|
||||||
|
mode = "insert";
|
||||||
|
pendingRow = row;
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
update: (patch: Row) => {
|
||||||
|
mode = "update";
|
||||||
|
pendingRow = patch;
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
eq: (column: string, value: unknown) => {
|
||||||
|
narrow((row) => row[column] === value);
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
in: (column: string, values: unknown[]) => {
|
||||||
|
narrow((row) => values.includes(row[column]));
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
not: (column: string, operator: string, value: unknown) => {
|
||||||
|
if (operator === "is" && value === null) {
|
||||||
|
narrow((row) => row[column] != null);
|
||||||
|
}
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
maybeSingle: async () => ({
|
||||||
|
data: all().filter(predicate)[0] ?? null,
|
||||||
|
error: null,
|
||||||
|
}),
|
||||||
|
then: (
|
||||||
|
resolve: (value: { data: Row[] | null; error: null }) => unknown,
|
||||||
|
reject?: (reason: unknown) => unknown,
|
||||||
|
) => {
|
||||||
|
if (mode === "insert") {
|
||||||
|
all().push({ ...pendingRow });
|
||||||
|
return Promise.resolve({ data: null, error: null }).then(
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mode === "update") {
|
||||||
|
for (const row of all().filter(predicate)) {
|
||||||
|
Object.assign(row, pendingRow);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null, error: null }).then(
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
data: all().filter(predicate),
|
||||||
|
error: null,
|
||||||
|
}).then(resolve, reject);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// normalizeEmail / normalizeDisplayName
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("normalizeEmail", () => {
|
||||||
|
it("trims and lowercases", () => {
|
||||||
|
expect(normalizeEmail(" User@Example.COM ")).toBe("user@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for non-strings", () => {
|
||||||
|
expect(normalizeEmail(null)).toBe("");
|
||||||
|
expect(normalizeEmail(undefined)).toBe("");
|
||||||
|
expect(normalizeEmail(42)).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeDisplayName", () => {
|
||||||
|
it("trims usable names", () => {
|
||||||
|
expect(normalizeDisplayName(" Ada Lovelace ")).toBe("Ada Lovelace");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for empty or non-string values", () => {
|
||||||
|
expect(normalizeDisplayName(" ")).toBeNull();
|
||||||
|
expect(normalizeDisplayName("")).toBeNull();
|
||||||
|
expect(normalizeDisplayName(null)).toBeNull();
|
||||||
|
expect(normalizeDisplayName(7)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// loadProfileUsersByEmail
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("loadProfileUsersByEmail", () => {
|
||||||
|
it("indexes profiles by normalized email and by id", async () => {
|
||||||
|
const db = makeDb([
|
||||||
|
{ user_id: "u1", email: "Alice@Example.com", display_name: " Alice " },
|
||||||
|
{ user_id: "u2", email: "bob@example.com", display_name: null },
|
||||||
|
]);
|
||||||
|
const { userByEmail, userById } = await loadProfileUsersByEmail(
|
||||||
|
db as any,
|
||||||
|
);
|
||||||
|
expect(userByEmail.get("alice@example.com")).toEqual({
|
||||||
|
id: "u1",
|
||||||
|
email: "alice@example.com",
|
||||||
|
display_name: "Alice",
|
||||||
|
});
|
||||||
|
expect(userById.get("u2")).toEqual({
|
||||||
|
id: "u2",
|
||||||
|
email: "bob@example.com",
|
||||||
|
display_name: null,
|
||||||
|
});
|
||||||
|
expect(userByEmail.size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips rows whose email normalizes to empty", async () => {
|
||||||
|
const db = makeDb([
|
||||||
|
{ user_id: "u1", email: " ", display_name: null },
|
||||||
|
{ user_id: "u2", email: "ok@example.com", display_name: null },
|
||||||
|
]);
|
||||||
|
const { userByEmail } = await loadProfileUsersByEmail(db as any);
|
||||||
|
expect(userByEmail.size).toBe(1);
|
||||||
|
expect(userByEmail.has("ok@example.com")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// findProfileUserByEmail
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("findProfileUserByEmail", () => {
|
||||||
|
const rows = [
|
||||||
|
{ user_id: "u1", email: "alice@example.com", display_name: "Alice" },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("finds a profile by normalized email", async () => {
|
||||||
|
const db = makeDb(rows);
|
||||||
|
await expect(
|
||||||
|
findProfileUserByEmail(db as any, " ALICE@example.com "),
|
||||||
|
).resolves.toEqual({
|
||||||
|
id: "u1",
|
||||||
|
email: "alice@example.com",
|
||||||
|
display_name: "Alice",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when no profile matches", async () => {
|
||||||
|
const db = makeDb(rows);
|
||||||
|
await expect(
|
||||||
|
findProfileUserByEmail(db as any, "missing@example.com"),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null without querying for empty input", async () => {
|
||||||
|
const db = makeDb(rows);
|
||||||
|
await expect(findProfileUserByEmail(db as any, " ")).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// findMissingUserEmails
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("findMissingUserEmails", () => {
|
||||||
|
const db = makeDb([
|
||||||
|
{ user_id: "u1", email: "alice@example.com", display_name: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
it("returns only emails with no matching profile", async () => {
|
||||||
|
await expect(
|
||||||
|
findMissingUserEmails(db as any, [
|
||||||
|
"Alice@Example.com",
|
||||||
|
"carol@example.com",
|
||||||
|
]),
|
||||||
|
).resolves.toEqual(["carol@example.com"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes and drops empty entries before querying", async () => {
|
||||||
|
await expect(
|
||||||
|
findMissingUserEmails(db as any, [
|
||||||
|
"carol@example.com",
|
||||||
|
" CAROL@example.com ",
|
||||||
|
"",
|
||||||
|
" ",
|
||||||
|
]),
|
||||||
|
).resolves.toEqual(["carol@example.com"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [] for an empty input list", async () => {
|
||||||
|
await expect(findMissingUserEmails(db as any, [])).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// syncProfileEmail
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("syncProfileEmail", () => {
|
||||||
|
it("inserts a profile row when none exists", async () => {
|
||||||
|
const db = makeDb([]);
|
||||||
|
const result = await syncProfileEmail(db as any, "u1", "New@Example.com");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(db.tables.user_profiles).toEqual([
|
||||||
|
{ user_id: "u1", email: "new@example.com" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op when the stored email already matches (case-insensitive)", async () => {
|
||||||
|
const db = makeDb([
|
||||||
|
{ user_id: "u1", email: "Same@Example.com", display_name: null },
|
||||||
|
]);
|
||||||
|
const result = await syncProfileEmail(db as any, "u1", "same@example.com");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(db.tables.user_profiles[0].email).toBe("Same@Example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the stored email when it changed", async () => {
|
||||||
|
const db = makeDb([
|
||||||
|
{ user_id: "u1", email: "old@example.com", display_name: null },
|
||||||
|
]);
|
||||||
|
const result = await syncProfileEmail(db as any, "u1", "New@Example.com");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(db.tables.user_profiles[0].email).toBe("new@example.com");
|
||||||
|
expect(db.tables.user_profiles[0].updated_at).toEqual(expect.any(String));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null without touching the table for missing inputs", async () => {
|
||||||
|
const db = makeDb([]);
|
||||||
|
await expect(syncProfileEmail(db as any, "", "a@b.com")).resolves.toBeNull();
|
||||||
|
await expect(syncProfileEmail(db as any, "u1", null)).resolves.toBeNull();
|
||||||
|
await expect(syncProfileEmail(db as any, "u1", " ")).resolves.toBeNull();
|
||||||
|
expect(db.tables.user_profiles).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
export const BUILTIN_WORKFLOWS: { id: string; title: string; prompt_md: string }[] = [
|
|
||||||
{
|
|
||||||
id: "builtin-cp-checklist",
|
|
||||||
title: "Generate CP Checklist",
|
|
||||||
prompt_md:
|
|
||||||
"## Generate Conditions Precedent Checklist\n\n" +
|
|
||||||
"Review the uploaded credit agreement or financing document and generate a comprehensive " +
|
|
||||||
"Conditions Precedent (CP) checklist.\n\n" +
|
|
||||||
"You MUST use the generate_docx tool to produce the checklist as a downloadable Word document. " +
|
|
||||||
"You MUST pass landscape: true to the generate_docx tool — the document must be in landscape orientation. " +
|
|
||||||
"Do not display the checklist inline — generate the .docx file and provide the download link.\n\n" +
|
|
||||||
"Structure the document as follows:\n" +
|
|
||||||
"- For each category of conditions (e.g. Corporate, Financial, Legal, Security), add a section with a heading\n" +
|
|
||||||
"- Under each category heading, include a table with exactly these four columns in this order:\n" +
|
|
||||||
" 1. Index — sequential number within the category (1, 2, 3…)\n" +
|
|
||||||
" 2. Clause Number — the clause or schedule reference from the agreement\n" +
|
|
||||||
" 3. Clause — a concise description of the condition precedent\n" +
|
|
||||||
" 4. Status — leave blank (empty string) for the user to fill in\n\n" +
|
|
||||||
"Use the table field in the section object (not content) for each category's rows.\n\n" +
|
|
||||||
"Before finalizing, double-check that every table is formatted correctly: each table must have exactly the four columns above in the same order, headers must match exactly (Index, Clause Number, Clause, Status), every row must have the same number of cells as the headers, the Index column must be sequential starting from 1 within each category, and no cells should contain stray markdown, newlines, or placeholder text (use an empty string for Status).",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "builtin-credit-summary",
|
|
||||||
title: "Credit Agreement Summary",
|
|
||||||
prompt_md:
|
|
||||||
"## Credit Agreement Summary\n\n" +
|
|
||||||
"Review the uploaded credit agreement and produce a comprehensive legal summary covering the following topics. " +
|
|
||||||
"For each section, identify the key provisions, quote the relevant clause or schedule references, and flag any unusual, onerous, or non-market terms.\n\n" +
|
|
||||||
"1. **Lenders** — All lenders or members of the lender syndicate, including their full legal name and role (e.g. mandated lead arranger, original lender, agent bank)\n" +
|
|
||||||
"2. **Borrowers** — All borrowers, including their full legal name and jurisdiction of incorporation\n" +
|
|
||||||
"3. **Guarantors** — All guarantors, including their full legal name and the scope of their guarantee obligation\n" +
|
|
||||||
"4. **Other Parties** — Any other material parties (e.g. facility agent, security agent, hedge counterparties, issuing bank) and their roles\n" +
|
|
||||||
"5. **Date of Agreement** — Date of the credit agreement\n" +
|
|
||||||
"6. **Facilities** — Each facility available (e.g. Revolving Credit Facility, Term Loan A, Term Loan B, Term Loan C), the facility type, tranche name, and any key structural features\n" +
|
|
||||||
"7. **Amount** — Total committed amount across all facilities, the currency, and breakdown by tranche if applicable\n" +
|
|
||||||
"8. **Purpose** — Stated purpose for which borrowings may be used and any restrictions on use of proceeds\n" +
|
|
||||||
"9. **Interest** — Applicable reference rate (e.g. SOFR, EURIBOR, base rate), the margin, any margin ratchet mechanism, and how interest periods are structured\n" +
|
|
||||||
"10. **Commitment Fee** — Commitment or utilisation fees, the applicable rate, how they are calculated, and the basis (e.g. undrawn commitment, average utilisation)\n" +
|
|
||||||
"11. **Repayment Schedule** — Repayment profile for each facility, whether by scheduled instalments or bullet repayment, and the repayment dates and amounts\n" +
|
|
||||||
"12. **Maturity** — Final maturity date for each facility\n" +
|
|
||||||
"13. **Security** — Each class of security granted or required (e.g. share pledges, fixed and floating charges, real estate mortgages, account pledges) and the assets or entities over which security is taken\n" +
|
|
||||||
"14. **Guarantees** — Guarantee obligations, the guarantors, the scope of the guarantee, and any limitations (e.g. up-stream guarantee limitations, guarantor coverage test)\n" +
|
|
||||||
"15. **Financial Covenants** — Each financial covenant, the metric (e.g. leverage ratio, interest cover, cashflow cover), the applicable test, testing frequency, and any equity cure rights\n" +
|
|
||||||
"16. **Events of Default** — Each event of default, noting any grace periods, materiality thresholds, or cross-default provisions\n" +
|
|
||||||
"17. **Assignment** — Restrictions or permissions on assignment or transfer (e.g. white/blacklists, borrower consent for lender transfers; restrictions on borrower assignment)\n" +
|
|
||||||
"18. **Change of Control** — What constitutes a change of control, what obligations it triggers (e.g. mandatory prepayment, cancellation, lender consent), and any cure period\n" +
|
|
||||||
"19. **Prepayment Fee** — Any prepayment fees, make-whole premiums, or soft-call protections, the applicable fee, the period during which it applies, and any exceptions (e.g. prepayment from insurance proceeds or asset disposals)\n" +
|
|
||||||
"20. **Governing Law** — Governing law of the agreement\n" +
|
|
||||||
"21. **Dispute Resolution** — Whether disputes go to litigation or arbitration, the chosen forum or seat, and any submission to jurisdiction provisions\n\n" +
|
|
||||||
"Deliver the summary inline in your chat response — do NOT call generate_docx. Only produce a downloadable Word document if the user explicitly asks for one.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "builtin-sha-summary",
|
|
||||||
title: "Shareholder Agreement Summary",
|
|
||||||
prompt_md:
|
|
||||||
"## Shareholder Agreement Summary\n\n" +
|
|
||||||
"Review the uploaded shareholder agreement and produce a comprehensive legal summary covering the following topics. " +
|
|
||||||
"For each section, identify the key provisions, quote the relevant clause references, and flag any unusual, onerous, or market-standard deviations.\n\n" +
|
|
||||||
"1. **Parties & Shareholdings** — Full legal names, roles, share classes held, and percentage interests (on a fully diluted basis if stated)\n" +
|
|
||||||
"2. **Share Classes & Rights** — For each class: voting rights, dividend rights, liquidation preference, conversion or redemption features\n" +
|
|
||||||
"3. **Board Composition & Governance** — Board size, director appointment rights (and the shareholding thresholds required to maintain them), quorum, and casting vote\n" +
|
|
||||||
"4. **Reserved Matters** — Decisions requiring a special majority, unanimity, or a specific shareholder's consent; note the threshold and whose consent is required for each\n" +
|
|
||||||
"5. **Pre-emption on New Shares** — Who holds pre-emption rights, procedure, timeline, and any carve-outs (e.g. employee option schemes)\n" +
|
|
||||||
"6. **Transfer Restrictions** — Lock-up periods, prohibited transfers, permitted transfers (e.g. to affiliates), and any board or shareholder approval requirements\n" +
|
|
||||||
"7. **Right of First Refusal / Pre-emption on Transfer** — Trigger, procedure, pricing mechanics, and any exceptions\n" +
|
|
||||||
"8. **Drag-Along Rights** — Who holds the right, threshold to trigger, conditions (e.g. minimum price, independent valuation), and minority protections\n" +
|
|
||||||
"9. **Tag-Along Rights** — Who holds the right, triggering threshold, exercise procedure, and price terms\n" +
|
|
||||||
"10. **Anti-Dilution Protections** — Type (full ratchet, weighted average), trigger events, calculation mechanics, and exceptions\n" +
|
|
||||||
"11. **Dividend Policy** — Any obligation or target to pay dividends, preferential dividend rights, and restrictions on distributions\n" +
|
|
||||||
"12. **Exit & Liquidity** — Agreed exit routes (trade sale, IPO, drag sale), timelines, and liquidation preferences on exit\n" +
|
|
||||||
"13. **Deadlock** — Deadlock definition, escalation and resolution mechanisms (e.g. Russian roulette, put/call options), and consequences if unresolved\n" +
|
|
||||||
"14. **Non-Compete & Non-Solicitation** — Who is bound, scope of activities and geography, duration, and carve-outs\n" +
|
|
||||||
"15. **Governing Law & Dispute Resolution** — Applicable law, forum, arbitration or litigation, and any mandatory escalation steps\n\n" +
|
|
||||||
"Generate the summary as a downloadable Word document.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
310
backend/src/lib/chat/citations.ts
Normal file
|
|
@ -0,0 +1,310 @@
|
||||||
|
import { type DocIndex, resolveDoc } from "./types";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal citation parse types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type DocumentQuote = {
|
||||||
|
page: number | string;
|
||||||
|
quote: string;
|
||||||
|
// Spreadsheet sources are located by cell instead of page: `sheet` is the
|
||||||
|
// worksheet name and `cell` is an A1 address or range (e.g. "B7" or "B7:C9").
|
||||||
|
sheet?: string;
|
||||||
|
cell?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ParsedDocumentCitation = {
|
||||||
|
kind: "document";
|
||||||
|
ref: number;
|
||||||
|
doc_id: string;
|
||||||
|
page: number | string;
|
||||||
|
quote: string;
|
||||||
|
sheet?: string;
|
||||||
|
cell?: string;
|
||||||
|
quotes: DocumentQuote[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ParsedCaseCitation = {
|
||||||
|
kind: "case";
|
||||||
|
ref: number;
|
||||||
|
cluster_id: number;
|
||||||
|
quotes: {
|
||||||
|
opinionId: number | null;
|
||||||
|
type: string | null;
|
||||||
|
author: string | null;
|
||||||
|
quote: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ParsedCitation = ParsedDocumentCitation | ParsedCaseCitation;
|
||||||
|
|
||||||
|
function normalizeCitation(raw: unknown): ParsedCitation | null {
|
||||||
|
if (!raw || typeof raw !== "object") return null;
|
||||||
|
const c = raw as Record<string, unknown>;
|
||||||
|
const markerRef =
|
||||||
|
typeof c.marker === "string"
|
||||||
|
? Number(c.marker.match(/^\[(\d+)\]$/)?.[1])
|
||||||
|
: NaN;
|
||||||
|
const ref =
|
||||||
|
typeof c.ref === "number"
|
||||||
|
? c.ref
|
||||||
|
: Number.isFinite(markerRef)
|
||||||
|
? markerRef
|
||||||
|
: null;
|
||||||
|
if (typeof ref !== "number") return null;
|
||||||
|
const quote = typeof c.quote === "string" ? c.quote : c.text;
|
||||||
|
|
||||||
|
const rawClusterId =
|
||||||
|
typeof c.cluster_id === "number"
|
||||||
|
? c.cluster_id
|
||||||
|
: typeof c.clusterId === "number"
|
||||||
|
? c.clusterId
|
||||||
|
: typeof c.cluster_id === "string"
|
||||||
|
? Number.parseInt(c.cluster_id, 10)
|
||||||
|
: typeof c.clusterId === "string"
|
||||||
|
? Number.parseInt(c.clusterId, 10)
|
||||||
|
: NaN;
|
||||||
|
if (Number.isFinite(rawClusterId) && rawClusterId > 0) {
|
||||||
|
const quotes = normalizeCaseCitationQuotes(c);
|
||||||
|
if (!quotes.length) {
|
||||||
|
if (typeof quote !== "string" || !quote) return null;
|
||||||
|
quotes.push({ opinionId: null, type: null, author: null, quote });
|
||||||
|
}
|
||||||
|
return { kind: "case", ref, cluster_id: Math.floor(rawClusterId), quotes };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof c.doc_id !== "string") return null;
|
||||||
|
const quotes = normalizeDocumentCitationQuotes(c);
|
||||||
|
if (!quotes.length) {
|
||||||
|
if (typeof quote !== "string" || !quote) return null;
|
||||||
|
quotes.push({
|
||||||
|
page: normalizeCitationPage(c.page),
|
||||||
|
quote,
|
||||||
|
...normalizeCellLocator(c),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "document",
|
||||||
|
ref,
|
||||||
|
doc_id: c.doc_id,
|
||||||
|
page: quotes[0].page,
|
||||||
|
quote: quotes[0].quote,
|
||||||
|
sheet: quotes[0].sheet,
|
||||||
|
cell: quotes[0].cell,
|
||||||
|
quotes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull an optional spreadsheet `{sheet, cell}` locator off a raw object. */
|
||||||
|
function normalizeCellLocator(
|
||||||
|
c: Record<string, unknown>,
|
||||||
|
): { sheet?: string; cell?: string } {
|
||||||
|
const out: { sheet?: string; cell?: string } = {};
|
||||||
|
if (typeof c.sheet === "string" && c.sheet.trim()) out.sheet = c.sheet.trim();
|
||||||
|
if (typeof c.cell === "string" && c.cell.trim()) out.cell = c.cell.trim();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCitationPage(value: unknown): number | string {
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return value;
|
||||||
|
} else if (typeof value === "string" && /^\d+\s*-\s*\d+$/.test(value)) {
|
||||||
|
return value;
|
||||||
|
} else {
|
||||||
|
const n = parseInt(String(value ?? ""), 10);
|
||||||
|
if (!Number.isFinite(n)) return 1;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDocumentCitationQuotes(
|
||||||
|
c: Record<string, unknown>,
|
||||||
|
): DocumentQuote[] {
|
||||||
|
if (!Array.isArray(c.quotes)) return [];
|
||||||
|
return c.quotes
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((raw): DocumentQuote | null => {
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||||
|
const row = raw as Record<string, unknown>;
|
||||||
|
const text = typeof row.quote === "string" ? row.quote : row.text;
|
||||||
|
if (typeof text !== "string" || !text.trim()) return null;
|
||||||
|
// Fall back to the top-level sheet/cell so a citation can set them once.
|
||||||
|
return {
|
||||||
|
page: normalizeCitationPage(row.page ?? c.page),
|
||||||
|
quote: text,
|
||||||
|
...normalizeCellLocator({
|
||||||
|
sheet: row.sheet ?? c.sheet,
|
||||||
|
cell: row.cell ?? c.cell,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((quote): quote is DocumentQuote => !!quote);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCaseCitationQuotes(c: Record<string, unknown>) {
|
||||||
|
if (!Array.isArray(c.quotes)) return [];
|
||||||
|
return c.quotes
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((raw) => {
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||||
|
const row = raw as Record<string, unknown>;
|
||||||
|
const text = typeof row.quote === "string" ? row.quote : row.text;
|
||||||
|
if (typeof text !== "string" || !text.trim()) return null;
|
||||||
|
const opinionId =
|
||||||
|
typeof row.opinion_id === "number" && Number.isFinite(row.opinion_id)
|
||||||
|
? Math.floor(row.opinion_id)
|
||||||
|
: typeof row.opinionId === "number" && Number.isFinite(row.opinionId)
|
||||||
|
? Math.floor(row.opinionId)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
opinionId,
|
||||||
|
type: typeof row.type === "string" ? row.type : null,
|
||||||
|
author: typeof row.author === "string" ? row.author : null,
|
||||||
|
quote: text,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(quote): quote is {
|
||||||
|
opinionId: number | null;
|
||||||
|
type: string | null;
|
||||||
|
author: string | null;
|
||||||
|
quote: string;
|
||||||
|
} => !!quote,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Citation block constants and parsers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const CITATIONS_BLOCK_RE = /<CITATIONS>\s*([\s\S]*?)\s*<\/CITATIONS>/;
|
||||||
|
export const CITATIONS_OPEN_TAG = "<CITATIONS>";
|
||||||
|
export const CITATIONS_CLOSE_TAG = "</CITATIONS>";
|
||||||
|
|
||||||
|
type CitationParseDiagnostics = {
|
||||||
|
hasBlock: boolean;
|
||||||
|
rawLength: number;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseCitationsWithDiagnostics(text: string): {
|
||||||
|
citations: ParsedCitation[];
|
||||||
|
diagnostics: CitationParseDiagnostics;
|
||||||
|
} {
|
||||||
|
const match = text.match(CITATIONS_BLOCK_RE);
|
||||||
|
if (!match) {
|
||||||
|
return { citations: [], diagnostics: { hasBlock: false, rawLength: 0, error: null } };
|
||||||
|
}
|
||||||
|
const raw = match[1] ?? "";
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return {
|
||||||
|
citations: [],
|
||||||
|
diagnostics: { hasBlock: true, rawLength: raw.length, error: "CITATIONS block JSON was not an array." },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
citations: parsed.map(normalizeCitation).filter((c): c is ParsedCitation => c !== null),
|
||||||
|
diagnostics: { hasBlock: true, rawLength: raw.length, error: null },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
citations: [],
|
||||||
|
diagnostics: {
|
||||||
|
hasBlock: true,
|
||||||
|
rawLength: raw.length,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCitations(text: string): ParsedCitation[] {
|
||||||
|
return parseCitationsWithDiagnostics(text).citations;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePartialCitationObjects(text: string): ParsedCitation[] {
|
||||||
|
const beforeClose = text.split(CITATIONS_CLOSE_TAG)[0] ?? text;
|
||||||
|
const arrayStart = beforeClose.indexOf("[");
|
||||||
|
if (arrayStart < 0) return [];
|
||||||
|
|
||||||
|
const parsed: ParsedCitation[] = [];
|
||||||
|
let inString = false;
|
||||||
|
let escaped = false;
|
||||||
|
let depth = 0;
|
||||||
|
let objectStart = -1;
|
||||||
|
|
||||||
|
for (let i = arrayStart + 1; i < beforeClose.length; i += 1) {
|
||||||
|
const char = beforeClose[i];
|
||||||
|
if (escaped) { escaped = false; continue; }
|
||||||
|
if (char === "\\") { escaped = inString; continue; }
|
||||||
|
if (char === '"') { inString = !inString; continue; }
|
||||||
|
if (inString) continue;
|
||||||
|
if (char === "{") {
|
||||||
|
if (depth === 0) objectStart = i;
|
||||||
|
depth += 1;
|
||||||
|
} else if (char === "}") {
|
||||||
|
if (depth === 0) continue;
|
||||||
|
depth -= 1;
|
||||||
|
if (depth === 0 && objectStart >= 0) {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(beforeClose.slice(objectStart, i + 1));
|
||||||
|
const citation = normalizeCitation(raw);
|
||||||
|
if (citation) parsed.push(citation);
|
||||||
|
} catch { /* ignore incomplete/malformed partial object */ }
|
||||||
|
objectStart = -1;
|
||||||
|
}
|
||||||
|
} else if (char === "]" && depth === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CasesByClusterId = Map<number, {
|
||||||
|
caseName: string | null;
|
||||||
|
citations: string[];
|
||||||
|
url: string | null;
|
||||||
|
pdfUrl: string | null;
|
||||||
|
dateFiled: string | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export function createCitation(
|
||||||
|
citation: ParsedCitation,
|
||||||
|
docIndex: DocIndex,
|
||||||
|
casesByClusterId?: CasesByClusterId,
|
||||||
|
) {
|
||||||
|
if (citation.kind === "case") {
|
||||||
|
const caseRecord = casesByClusterId?.get(citation.cluster_id);
|
||||||
|
return {
|
||||||
|
type: "citation_data",
|
||||||
|
kind: "case",
|
||||||
|
ref: citation.ref,
|
||||||
|
cluster_id: citation.cluster_id,
|
||||||
|
case_name: caseRecord?.caseName ?? null,
|
||||||
|
citation: caseRecord?.citations[0] ?? null,
|
||||||
|
url: caseRecord?.url ?? null,
|
||||||
|
pdfUrl: caseRecord?.pdfUrl ?? null,
|
||||||
|
dateFiled: caseRecord?.dateFiled ?? null,
|
||||||
|
quotes: citation.quotes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const docInfo = resolveDoc(citation.doc_id, docIndex);
|
||||||
|
return {
|
||||||
|
type: "citation_data",
|
||||||
|
kind: "document",
|
||||||
|
ref: citation.ref,
|
||||||
|
doc_id: citation.doc_id,
|
||||||
|
document_id: docInfo?.document_id,
|
||||||
|
version_id: docInfo?.version_id ?? null,
|
||||||
|
version_number: docInfo?.version_number ?? null,
|
||||||
|
filename: docInfo?.filename ?? citation.doc_id,
|
||||||
|
page: citation.page,
|
||||||
|
quote: citation.quote,
|
||||||
|
sheet: citation.sheet,
|
||||||
|
cell: citation.cell,
|
||||||
|
quotes: citation.quotes,
|
||||||
|
};
|
||||||
|
}
|
||||||
583
backend/src/lib/chat/contextBuilders.ts
Normal file
|
|
@ -0,0 +1,583 @@
|
||||||
|
import { createServerSupabase } from "../supabase";
|
||||||
|
import {
|
||||||
|
attachActiveVersionPaths,
|
||||||
|
} from "../documentVersions";
|
||||||
|
import {
|
||||||
|
type DocStore,
|
||||||
|
type DocIndex,
|
||||||
|
type WorkflowStore,
|
||||||
|
type ChatMessage,
|
||||||
|
type AskInputsResponseRequest,
|
||||||
|
type AskInputResponseItem,
|
||||||
|
devLog,
|
||||||
|
} from "./types";
|
||||||
|
import { buildSystemPrompt } from "./prompts";
|
||||||
|
import { parseCitations, createCitation } from "./citations";
|
||||||
|
import type { AssistantEvent } from "./streaming";
|
||||||
|
|
||||||
|
|
||||||
|
export async function enrichWithPriorEvents(
|
||||||
|
messages: ChatMessage[],
|
||||||
|
chatId: string | null | undefined,
|
||||||
|
db: ReturnType<typeof createServerSupabase>,
|
||||||
|
docIndex: DocIndex,
|
||||||
|
): Promise<ChatMessage[]> {
|
||||||
|
if (!chatId) return messages;
|
||||||
|
const { data: rows } = await db
|
||||||
|
.from("chat_messages")
|
||||||
|
.select("content, created_at")
|
||||||
|
.eq("chat_id", chatId)
|
||||||
|
.eq("role", "assistant")
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const lastRow = rows?.[0] as { content?: unknown } | undefined;
|
||||||
|
const content = lastRow?.content;
|
||||||
|
if (!Array.isArray(content)) return messages;
|
||||||
|
|
||||||
|
const slugByDocumentId = new Map<string, string>();
|
||||||
|
for (const [slug, info] of Object.entries(docIndex)) {
|
||||||
|
if (info.document_id) slugByDocumentId.set(info.document_id, slug);
|
||||||
|
}
|
||||||
|
const refFor = (documentId: unknown, filename: unknown) => {
|
||||||
|
const slug =
|
||||||
|
typeof documentId === "string"
|
||||||
|
? slugByDocumentId.get(documentId)
|
||||||
|
: undefined;
|
||||||
|
return slug ? `${slug} ("${filename}")` : `"${filename}"`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const ev of content as Record<string, unknown>[]) {
|
||||||
|
if (ev?.type === "doc_created") {
|
||||||
|
lines.push(`- generated_document → ${refFor(ev.document_id, ev.filename)}`);
|
||||||
|
} else if (ev?.type === "doc_edited") {
|
||||||
|
lines.push(`- edit_document → ${refFor(ev.document_id, ev.filename)}`);
|
||||||
|
} else if (ev?.type === "doc_read") {
|
||||||
|
lines.push(`- read_document → ${refFor(ev.document_id, ev.filename)}`);
|
||||||
|
} else if (ev?.type === "doc_replicated") {
|
||||||
|
// The model needs to know what each copy resolved to so it
|
||||||
|
// can call edit_document / read_document on them. Emit one
|
||||||
|
// line per copy, all attributed back to the same source.
|
||||||
|
const srcLabel =
|
||||||
|
typeof ev.filename === "string" ? `"${ev.filename}"` : "";
|
||||||
|
const copies = Array.isArray(ev.copies)
|
||||||
|
? (ev.copies as {
|
||||||
|
new_filename?: unknown;
|
||||||
|
document_id?: unknown;
|
||||||
|
}[])
|
||||||
|
: [];
|
||||||
|
for (const c of copies) {
|
||||||
|
const ref = refFor(c.document_id, c.new_filename);
|
||||||
|
lines.push(
|
||||||
|
srcLabel
|
||||||
|
? `- replicate_document → ${ref} (copy of ${srcLabel})`
|
||||||
|
: `- replicate_document → ${ref}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (ev?.type === "workflow_applied") {
|
||||||
|
lines.push(`- applied workflow: "${ev.title}"`);
|
||||||
|
} else if (ev?.type === "ask_inputs") {
|
||||||
|
const count = Array.isArray(ev.items) ? ev.items.length : 0;
|
||||||
|
lines.push(`- asked user for ${count} input${count === 1 ? "" : "s"}`);
|
||||||
|
} else if (ev?.type === "ask_inputs_response") {
|
||||||
|
const responses = Array.isArray(ev.responses) ? ev.responses : [];
|
||||||
|
for (const response of responses) {
|
||||||
|
if (!response || typeof response !== "object") continue;
|
||||||
|
const row = response as Record<string, unknown>;
|
||||||
|
if (row.skipped) {
|
||||||
|
lines.push("- user skipped an input");
|
||||||
|
} else if (row.kind === "choice" && typeof row.answer === "string") {
|
||||||
|
lines.push(`- user answered: "${row.answer}"`);
|
||||||
|
} else if (
|
||||||
|
row.kind === "documents" &&
|
||||||
|
Array.isArray(row.filenames)
|
||||||
|
) {
|
||||||
|
lines.push(
|
||||||
|
`- user attached documents: ${row.filenames.join(", ") || "none"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lines.length === 0) return messages;
|
||||||
|
const summary = `\n\n[Tool activity in your previous turn]\n${lines.join("\n")}`;
|
||||||
|
|
||||||
|
// Find the index of the last assistant message and attach the
|
||||||
|
// summary there only.
|
||||||
|
let lastAssistantIdx = -1;
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
if (messages[i].role === "assistant") {
|
||||||
|
lastAssistantIdx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastAssistantIdx < 0) return messages;
|
||||||
|
const enriched = messages.slice();
|
||||||
|
const target = enriched[lastAssistantIdx];
|
||||||
|
enriched[lastAssistantIdx] = {
|
||||||
|
...target,
|
||||||
|
content: (target.content ?? "") + summary,
|
||||||
|
};
|
||||||
|
return enriched;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMessages(
|
||||||
|
messages: ChatMessage[],
|
||||||
|
docAvailability: {
|
||||||
|
doc_id: string;
|
||||||
|
filename: string;
|
||||||
|
folder_path?: string;
|
||||||
|
}[],
|
||||||
|
systemPromptExtra?: string,
|
||||||
|
docIndex?: DocIndex,
|
||||||
|
includeResearchTools = true,
|
||||||
|
) {
|
||||||
|
const formatted: unknown[] = [];
|
||||||
|
let systemContent = buildSystemPrompt(includeResearchTools);
|
||||||
|
|
||||||
|
if (systemPromptExtra) {
|
||||||
|
systemContent += `\n\n${systemPromptExtra.trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (docAvailability.length) {
|
||||||
|
systemContent += "\n\n---\nAVAILABLE DOCUMENTS:\n";
|
||||||
|
for (const doc of docAvailability) {
|
||||||
|
const label = doc.folder_path
|
||||||
|
? `${doc.folder_path} / ${doc.filename}`
|
||||||
|
: doc.filename;
|
||||||
|
systemContent += `- ${doc.doc_id}: ${label}\n`;
|
||||||
|
}
|
||||||
|
systemContent +=
|
||||||
|
"\nYou do NOT retain document content between conversation turns. You MUST call read_document (or fetch_documents) once at the start of every response that involves a document's content, even if you have read it in a previous turn. Within the same response, do not call read_document or fetch_documents again for a document/version that has already been read; use the prior tool result, find_in_document for targeted checks, or proceed to the next required tool. Failure to read once per turn will result in hallucinated or stale content.\n---\n";
|
||||||
|
}
|
||||||
|
formatted.push({ role: "system", content: systemContent });
|
||||||
|
|
||||||
|
// Map document_id (UUID) → current-turn doc_id slug, so when we
|
||||||
|
// inline a user attachment we hand the model the same handle it
|
||||||
|
// would use to call read_document / fetch_documents.
|
||||||
|
const slugByDocumentId = new Map<string, string>();
|
||||||
|
if (docIndex) {
|
||||||
|
for (const [slug, info] of Object.entries(docIndex)) {
|
||||||
|
if (info.document_id) slugByDocumentId.set(info.document_id, slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
let content = msg.content ?? "";
|
||||||
|
if (msg.role === "user" && msg.workflow) {
|
||||||
|
content = `[Workflow: ${msg.workflow.title} (id: ${msg.workflow.id})]\n\n${content}`;
|
||||||
|
}
|
||||||
|
if (msg.role === "user" && msg.files?.length) {
|
||||||
|
const lines = msg.files.map((f) => {
|
||||||
|
const slug = f.document_id
|
||||||
|
? slugByDocumentId.get(f.document_id)
|
||||||
|
: undefined;
|
||||||
|
return slug ? `- ${slug}: ${f.filename}` : `- ${f.filename}`;
|
||||||
|
});
|
||||||
|
content = `[The user attached the following document(s) to this message:\n${lines.join("\n")}]\n\n${content}`;
|
||||||
|
}
|
||||||
|
formatted.push({ role: msg.role, content });
|
||||||
|
}
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractCitations(
|
||||||
|
fullText: string,
|
||||||
|
docIndex: DocIndex,
|
||||||
|
_events?: ({ type: string } & Record<string, unknown>[]) | unknown[],
|
||||||
|
): unknown[] {
|
||||||
|
return parseCitations(fullText).map((c) =>
|
||||||
|
createCitation(c, docIndex),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripTransientAssistantEvents(events: AssistantEvent[]) {
|
||||||
|
return events.filter((event) => event.type !== "case_opinions");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanAskInputResponseId(value: unknown) {
|
||||||
|
const id = typeof value === "string" ? value.trim() : "";
|
||||||
|
return id.slice(0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAskInputsResponsePayload(
|
||||||
|
value: unknown,
|
||||||
|
): AskInputsResponseRequest | null {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||||
|
const row = value as Record<string, unknown>;
|
||||||
|
const rawResponses = Array.isArray(row.responses) ? row.responses : [];
|
||||||
|
const responses = rawResponses
|
||||||
|
.map((item): AskInputResponseItem | null => {
|
||||||
|
if (!item || typeof item !== "object" || Array.isArray(item)) return null;
|
||||||
|
const current = item as Record<string, unknown>;
|
||||||
|
const id = cleanAskInputResponseId(current.id);
|
||||||
|
const kind = current.kind;
|
||||||
|
const skipped = current.skipped === true;
|
||||||
|
if (!id || (kind !== "choice" && kind !== "documents")) return null;
|
||||||
|
if (kind === "choice") {
|
||||||
|
const question =
|
||||||
|
typeof current.question === "string"
|
||||||
|
? current.question.trim().slice(0, 500)
|
||||||
|
: "";
|
||||||
|
const answer =
|
||||||
|
typeof current.answer === "string"
|
||||||
|
? current.answer.trim().slice(0, 1000)
|
||||||
|
: "";
|
||||||
|
if (!question || (!answer && !skipped)) return null;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
kind,
|
||||||
|
question,
|
||||||
|
...(answer ? { answer } : {}),
|
||||||
|
...(skipped ? { skipped: true } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const rawFilenames = Array.isArray(current.filenames)
|
||||||
|
? current.filenames
|
||||||
|
: [];
|
||||||
|
const filenames = rawFilenames
|
||||||
|
.filter((f): f is string => typeof f === "string")
|
||||||
|
.map((f) => f.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 50);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
kind,
|
||||||
|
filenames,
|
||||||
|
...(skipped ? { skipped: true } : {}),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is AskInputResponseItem => !!item)
|
||||||
|
.slice(0, 20);
|
||||||
|
return responses.length > 0 ? { responses } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appendAskInputsResponseToLastAssistantMessage(
|
||||||
|
db: ReturnType<typeof createServerSupabase>,
|
||||||
|
chatId: string,
|
||||||
|
response: AskInputsResponseRequest,
|
||||||
|
) {
|
||||||
|
await appendAssistantEventsToLastAssistantMessage(db, chatId, [
|
||||||
|
{
|
||||||
|
type: "ask_inputs_response" as const,
|
||||||
|
responses: response.responses,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appendAssistantEventsToLastAssistantMessage(
|
||||||
|
db: ReturnType<typeof createServerSupabase>,
|
||||||
|
chatId: string,
|
||||||
|
events: AssistantEvent[],
|
||||||
|
citations?: unknown[],
|
||||||
|
) {
|
||||||
|
if (events.length === 0 && (!citations || citations.length === 0)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { data: rows, error: selectError } = await db
|
||||||
|
.from("chat_messages")
|
||||||
|
.select("id, content, citations")
|
||||||
|
.eq("chat_id", chatId)
|
||||||
|
.eq("role", "assistant")
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.limit(1);
|
||||||
|
if (selectError || !rows?.[0]) {
|
||||||
|
if (selectError) {
|
||||||
|
console.error(
|
||||||
|
"[assistant-events] failed to load assistant message",
|
||||||
|
selectError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = rows[0] as {
|
||||||
|
id: string;
|
||||||
|
content: unknown;
|
||||||
|
citations?: unknown;
|
||||||
|
};
|
||||||
|
const existing = Array.isArray(row.content)
|
||||||
|
? row.content
|
||||||
|
: [];
|
||||||
|
const next = [...existing, ...events];
|
||||||
|
const existingCitations = Array.isArray(row.citations)
|
||||||
|
? row.citations
|
||||||
|
: [];
|
||||||
|
const nextCitations =
|
||||||
|
citations && citations.length > 0
|
||||||
|
? [...existingCitations, ...citations]
|
||||||
|
: existingCitations;
|
||||||
|
const { error: updateError } = await db
|
||||||
|
.from("chat_messages")
|
||||||
|
.update({
|
||||||
|
content: next.length ? next : null,
|
||||||
|
citations: nextCitations.length ? nextCitations : null,
|
||||||
|
})
|
||||||
|
.eq("id", row.id);
|
||||||
|
if (updateError) {
|
||||||
|
console.error(
|
||||||
|
"[assistant-events] failed to update assistant message",
|
||||||
|
updateError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendCancelledAssistantEvent(events: AssistantEvent[]) {
|
||||||
|
return [...events, { type: "content" as const, text: "Cancelled by user." }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCancelledAssistantMessage(args: {
|
||||||
|
fullText: string;
|
||||||
|
events: AssistantEvent[];
|
||||||
|
buildCitations: (fullText: string, events: AssistantEvent[]) => unknown[];
|
||||||
|
}) {
|
||||||
|
const events = appendCancelledAssistantEvent(
|
||||||
|
stripTransientAssistantEvents(args.events),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
events,
|
||||||
|
citations: args.buildCitations(args.fullText, events),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Document context builder (from message file attachments)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function buildDocContext(
|
||||||
|
messages: ChatMessage[],
|
||||||
|
userId: string,
|
||||||
|
db: ReturnType<typeof createServerSupabase>,
|
||||||
|
chatId?: string | null,
|
||||||
|
): Promise<{ docIndex: DocIndex; docStore: DocStore }> {
|
||||||
|
const docIndex: DocIndex = {};
|
||||||
|
const docStore: DocStore = new Map();
|
||||||
|
|
||||||
|
const documentIds = new Set<string>();
|
||||||
|
for (const m of messages) {
|
||||||
|
for (const f of m.files ?? []) {
|
||||||
|
if (f.document_id) documentIds.add(f.document_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also pull in document_ids from prior assistant events in this chat —
|
||||||
|
// generated docs (generate_docx) and tracked-change edits (edit_document)
|
||||||
|
// aren't attached to user messages as files, so they only live in the
|
||||||
|
// assistant's `doc_created` / `doc_edited` events. Without this sweep
|
||||||
|
// the model loses access to generated docs after the turn that created
|
||||||
|
// them, and can't call edit_document / read_document on them.
|
||||||
|
if (chatId) {
|
||||||
|
const { data: rows } = await db
|
||||||
|
.from("chat_messages")
|
||||||
|
.select("content")
|
||||||
|
.eq("chat_id", chatId)
|
||||||
|
.eq("role", "assistant");
|
||||||
|
for (const row of rows ?? []) {
|
||||||
|
const content = (row as { content?: unknown }).content;
|
||||||
|
if (!Array.isArray(content)) continue;
|
||||||
|
for (const ev of content as Record<string, unknown>[]) {
|
||||||
|
if (
|
||||||
|
(ev?.type === "doc_created" || ev?.type === "doc_edited") &&
|
||||||
|
typeof ev.document_id === "string"
|
||||||
|
) {
|
||||||
|
documentIds.add(ev.document_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = [...documentIds];
|
||||||
|
if (ids.length > 0) {
|
||||||
|
const { data: docs } = await db
|
||||||
|
.from("documents")
|
||||||
|
.select("id, current_version_id, status")
|
||||||
|
.in("id", ids)
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.eq("status", "ready");
|
||||||
|
|
||||||
|
const docList = (docs ?? []) as unknown as {
|
||||||
|
id: string;
|
||||||
|
filename?: string | null;
|
||||||
|
file_type?: string | null;
|
||||||
|
current_version_id?: string | null;
|
||||||
|
active_version_number?: number | null;
|
||||||
|
storage_path?: string | null;
|
||||||
|
}[];
|
||||||
|
await attachActiveVersionPaths(db, docList);
|
||||||
|
for (let i = 0; i < docList.length; i++) {
|
||||||
|
const doc = docList[i];
|
||||||
|
if (!doc.storage_path) continue;
|
||||||
|
const docLabel = `doc-${i}`;
|
||||||
|
const filename = doc.filename?.trim() || "Untitled document";
|
||||||
|
docIndex[docLabel] = {
|
||||||
|
document_id: doc.id,
|
||||||
|
filename,
|
||||||
|
version_id: doc.current_version_id ?? null,
|
||||||
|
version_number: doc.active_version_number ?? null,
|
||||||
|
};
|
||||||
|
docStore.set(docLabel, {
|
||||||
|
storage_path: doc.storage_path,
|
||||||
|
file_type: doc.file_type ?? "",
|
||||||
|
filename,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
devLog(
|
||||||
|
"[buildDocContext] available docs:",
|
||||||
|
Object.entries(docIndex).map(([label, info]) => ({
|
||||||
|
label,
|
||||||
|
filename: info.filename,
|
||||||
|
document_id: info.document_id,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return { docIndex, docStore };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildProjectDocContext(
|
||||||
|
projectId: string,
|
||||||
|
_userId: string,
|
||||||
|
db: ReturnType<typeof createServerSupabase>,
|
||||||
|
): Promise<{
|
||||||
|
docIndex: DocIndex;
|
||||||
|
docStore: DocStore;
|
||||||
|
folderPaths: Map<string, string>;
|
||||||
|
}> {
|
||||||
|
const docIndex: DocIndex = {};
|
||||||
|
const docStore: DocStore = new Map();
|
||||||
|
|
||||||
|
const [{ data: docs }, { data: folders }] = await Promise.all([
|
||||||
|
db
|
||||||
|
.from("documents")
|
||||||
|
.select("id, current_version_id, status, folder_id")
|
||||||
|
.eq("project_id", projectId)
|
||||||
|
.eq("status", "ready")
|
||||||
|
.order("created_at", { ascending: true }),
|
||||||
|
db
|
||||||
|
.from("project_subfolders")
|
||||||
|
.select("id, name, parent_folder_id")
|
||||||
|
.eq("project_id", projectId),
|
||||||
|
]);
|
||||||
|
const docList = (docs ?? []) as unknown as {
|
||||||
|
id: string;
|
||||||
|
filename?: string | null;
|
||||||
|
file_type?: string | null;
|
||||||
|
current_version_id?: string | null;
|
||||||
|
active_version_number?: number | null;
|
||||||
|
folder_id?: string | null;
|
||||||
|
storage_path?: string | null;
|
||||||
|
}[];
|
||||||
|
await attachActiveVersionPaths(db, docList);
|
||||||
|
|
||||||
|
// Build folder id → full path map
|
||||||
|
const folderMap = new Map<
|
||||||
|
string,
|
||||||
|
{ name: string; parent_folder_id: string | null }
|
||||||
|
>();
|
||||||
|
for (const f of folders ?? [])
|
||||||
|
folderMap.set(f.id, {
|
||||||
|
name: f.name,
|
||||||
|
parent_folder_id: f.parent_folder_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
function resolvePath(folderId: string | null): string {
|
||||||
|
if (!folderId) return "";
|
||||||
|
const parts: string[] = [];
|
||||||
|
let cur: string | null = folderId;
|
||||||
|
while (cur) {
|
||||||
|
const f = folderMap.get(cur);
|
||||||
|
if (!f) break;
|
||||||
|
parts.unshift(f.name);
|
||||||
|
cur = f.parent_folder_id;
|
||||||
|
}
|
||||||
|
return parts.join(" / ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderPaths = new Map<string, string>(); // doc label → folder path
|
||||||
|
|
||||||
|
for (let i = 0; i < docList.length; i++) {
|
||||||
|
const doc = docList[i];
|
||||||
|
if (!doc.storage_path) continue;
|
||||||
|
const docLabel = `doc-${i}`;
|
||||||
|
const filename = doc.filename?.trim() || "Untitled document";
|
||||||
|
docIndex[docLabel] = {
|
||||||
|
document_id: doc.id,
|
||||||
|
filename,
|
||||||
|
version_id: doc.current_version_id ?? null,
|
||||||
|
version_number: doc.active_version_number ?? null,
|
||||||
|
};
|
||||||
|
docStore.set(docLabel, {
|
||||||
|
storage_path: doc.storage_path,
|
||||||
|
file_type: doc.file_type ?? "",
|
||||||
|
filename,
|
||||||
|
});
|
||||||
|
const path = resolvePath(doc.folder_id ?? null);
|
||||||
|
if (path) folderPaths.set(docLabel, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
devLog(
|
||||||
|
"[buildProjectDocContext] available docs:",
|
||||||
|
Object.entries(docIndex).map(([label, info]) => ({
|
||||||
|
label,
|
||||||
|
filename: info.filename,
|
||||||
|
document_id: info.document_id,
|
||||||
|
folder: folderPaths.get(label) ?? null,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return { docIndex, docStore, folderPaths };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildWorkflowStore(
|
||||||
|
userId: string,
|
||||||
|
userEmail: string | null | undefined,
|
||||||
|
db: ReturnType<typeof createServerSupabase>,
|
||||||
|
): Promise<WorkflowStore> {
|
||||||
|
const { SYSTEM_ASSISTANT_WORKFLOWS } = await import("../systemWorkflows");
|
||||||
|
const store: WorkflowStore = new Map();
|
||||||
|
const normalizedUserEmail = (userEmail ?? "").trim().toLowerCase();
|
||||||
|
|
||||||
|
// Seed system workflows first.
|
||||||
|
for (const wf of SYSTEM_ASSISTANT_WORKFLOWS) {
|
||||||
|
store.set(wf.id, { title: wf.title, skill_md: wf.skill_md });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then overlay user-owned assistant workflows.
|
||||||
|
const { data: workflows } = await db
|
||||||
|
.from("workflows")
|
||||||
|
.select("id, title, prompt_md")
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.eq("type", "assistant");
|
||||||
|
for (const wf of workflows ?? []) {
|
||||||
|
if (wf.prompt_md) {
|
||||||
|
store.set(wf.id, { title: wf.title, skill_md: wf.prompt_md });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared assistant workflows must also be readable by workflow tools.
|
||||||
|
if (normalizedUserEmail) {
|
||||||
|
const { data: shares } = await db
|
||||||
|
.from("workflow_shares")
|
||||||
|
.select("workflow_id")
|
||||||
|
.eq("shared_with_email", normalizedUserEmail);
|
||||||
|
const sharedIds = [
|
||||||
|
...new Set((shares ?? []).map((share) => share.workflow_id)),
|
||||||
|
];
|
||||||
|
if (sharedIds.length > 0) {
|
||||||
|
const { data: sharedWorkflows } = await db
|
||||||
|
.from("workflows")
|
||||||
|
.select("id, title, prompt_md")
|
||||||
|
.in("id", sharedIds)
|
||||||
|
.eq("type", "assistant");
|
||||||
|
for (const wf of sharedWorkflows ?? []) {
|
||||||
|
if (wf.prompt_md) {
|
||||||
|
store.set(wf.id, {
|
||||||
|
title: wf.title,
|
||||||
|
skill_md: wf.prompt_md,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return store;
|
||||||
|
}
|
||||||
8
backend/src/lib/chat/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
export * from "./types";
|
||||||
|
export * from "./prompts";
|
||||||
|
export * from "./tools/toolSchemas";
|
||||||
|
export * from "./citations";
|
||||||
|
export * from "./tools/documentOps";
|
||||||
|
export * from "./tools/toolDispatcher";
|
||||||
|
export * from "./streaming";
|
||||||
|
export * from "./contextBuilders";
|
||||||
88
backend/src/lib/chat/prompts.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { COURTLISTENER_SYSTEM_PROMPT } from "./tools/courtlistenerTools";
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT_BEFORE_RESEARCH = `You are Mike, an AI legal assistant for lawyers and legal professionals. Help analyze documents, answer legal questions, and draft legal documents.
|
||||||
|
|
||||||
|
CORE RULES:
|
||||||
|
- Be precise, professional, and evidence-aware.
|
||||||
|
- Do not fabricate document content.
|
||||||
|
- Use at most 10 tool-use rounds per response. Batch independent tool calls and leave room for the final answer.
|
||||||
|
- Read each relevant document/version at most once per response. After read_document or fetch_documents returns a document's full text, do not call either tool again for that same document/version in the same response; use the prior result, call find_in_document for targeted checks, or proceed to the next required tool.
|
||||||
|
- If the user selects a workflow with [Workflow: <title> (id: <id>)], immediately call read_workflow with that id and follow the workflow before doing anything else.
|
||||||
|
- If you need the user to choose between options, clarify a missing premise, or attach one or more documents before you can continue, call ask_inputs with all needed choice and document-upload items in a single tool call. For document-upload items, include a document_types array with short labels for the specific categories of documents you need. After asking, do not continue the substantive task until the user responds in a later message.
|
||||||
|
|
||||||
|
DOCUMENT CITATIONS:
|
||||||
|
Use document citations only for verbatim evidence from uploaded or generated documents.
|
||||||
|
|
||||||
|
In prose, put sequential markers [1], [2], etc. exactly where the cited claim appears. Assign citation refs in first-appearance order and increment by exactly 1 each time: [1], [2], [3], never [1], [2], [3], [4], [5], [8], [9]. The marker number is the citation "ref" value, not a page, footnote, section, clause, or document number.
|
||||||
|
|
||||||
|
At the very end of the response, append:
|
||||||
|
<CITATIONS>
|
||||||
|
[
|
||||||
|
{"ref": 1, "doc_id": "doc-0", "quotes": [{"page": 3, "quote": "exact verbatim text"}]},
|
||||||
|
{"ref": 2, "doc_id": "doc-1", "quotes": [{"page": "41-42", "quote": "text before page break [[PAGE_BREAK]] text after page break"}]}
|
||||||
|
]
|
||||||
|
</CITATIONS>
|
||||||
|
|
||||||
|
Citation rules:
|
||||||
|
- Every [N] marker must have exactly one matching entry with "ref": N.
|
||||||
|
- Citation refs must be contiguous with no skipped numbers. If the response uses N citations, the refs must be exactly 1 through N, and the <CITATIONS> array should list them in that order.
|
||||||
|
- Bracketed numbers like [1] are only citation annotation markers. Do not add brackets to section, clause, schedule, exhibit, paragraph, or list numbering.
|
||||||
|
- "doc_id" must be the exact chat-local label you were given, such as "doc-0". Never use a filename or document UUID in "doc_id".
|
||||||
|
- Use one citation entry per marker. If one marker needs several passages, use "quotes" with 1 quote by default and at most 3.
|
||||||
|
- Keep quotes short, ideally 25 words or fewer, and tightly matched to the claim.
|
||||||
|
- "page" means the sequential [Page N] marker in the provided text, not printed page numbers inside the document. Non-spreadsheet unpaginated files may have no [Page N] markers; omit "page" (or use 1) when none is present.
|
||||||
|
- For spreadsheet sources (content shown as "## Sheet: <name>" markdown tables with a "Row" column and column-letter headers), cite by cell instead of page: set "sheet" to the sheet name and "cell" to the A1 address or range you are quoting (e.g. "B7" or "B7:C9", combining the column-letter header with the "Row" number). Put the plain cell value in "quote" with no "Row"/column-letter labels or "|" separators. Omit "page" for spreadsheet citations.
|
||||||
|
- A cell tagged "⟨merged A1:C1⟩" spans that whole range: its value belongs to the anchor cell and the other covered cells are shown blank. When citing anything in a merged range, set "cell" to the full range from the tag (e.g. "A1:C1"), not a covered cell like "B1". Do not include the "⟨merged ...⟩" tag text in "quote".
|
||||||
|
- For a continuous quote crossing two pages, set "page" to "N-M" and include [[PAGE_BREAK]] at the page break. Otherwise, use separate quote objects.
|
||||||
|
- For legacy compatibility, you may also include top-level "page" and "quote" matching the first quote.
|
||||||
|
- Omit the <CITATIONS> block when there are no citations.
|
||||||
|
|
||||||
|
DOCX GENERATION:
|
||||||
|
- If the user asks you to create or draft a document, call generate_docx and provide the downloadable Word document rather than only displaying text inline.
|
||||||
|
- If the user asks for a spreadsheet, table workbook, tracker, checklist matrix, or Excel file, call generate_excel.
|
||||||
|
- If the user asks for slides, a presentation, pitch deck, board deck, or PowerPoint file, call generate_ppt.
|
||||||
|
- If the user asks to revise a document you just generated, call edit_document on that document unless they explicitly want a brand-new document or the change is too broad for coherent editing.
|
||||||
|
- Use heading levels in order; do not skip from Heading 1 to Heading 3.
|
||||||
|
- Numbering starts at 1, never 0. The generator applies legal numbering automatically. Do not type numbering prefixes into headings.
|
||||||
|
- Do not repeat the document title as the first section heading.
|
||||||
|
- Contract preambles, party blocks, recitals, and WHEREAS clauses are unnumbered. Begin numbering at the first operative clause or section.
|
||||||
|
- Contracts and agreements must end with an unnumbered signature block on a fresh page. Set pageBreak: true on the final section and include signature lines such as By, Name, Title, and Date for each party.
|
||||||
|
|
||||||
|
DOCUMENT EDITING:
|
||||||
|
- For document edits, call read_document or fetch_documents once for each relevant document/version unless the exact needed text is already available in this response. Do not reread the same document/version before calling edit_document.
|
||||||
|
When edit_document adds, deletes, moves, or reorders any numbered clause, section, schedule, exhibit, or list item:
|
||||||
|
- Renumber all affected downstream items in the same edit.
|
||||||
|
- Update all affected cross-references, including references in recitals, definitions, schedules, and exhibits.
|
||||||
|
- Before editing, scan the full document with read_document or find_in_document for affected references.
|
||||||
|
- If a reference might point to a shifted number, include the update and explain the reason.
|
||||||
|
- When deleting square brackets, delete both "[" and "]".`;
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT_AFTER_RESEARCH = `DOCUMENT NAMES IN PROSE:
|
||||||
|
- Chat-local labels such as "doc-0" are internal. Use them only in tool arguments and citation JSON.
|
||||||
|
- Never show "doc-N" labels to the user in prose, headings, lists, or tool activity text.
|
||||||
|
- Refer to documents by filename or a natural description, such as "the NDA draft".
|
||||||
|
|
||||||
|
REASONING TRACE SAFETY:
|
||||||
|
- If reasoning or thought summaries are shown to the user, keep them as brief natural-language progress summaries.
|
||||||
|
- Do not expose source code, JSON snippets, tool arguments, API payloads, schemas, raw citations JSON, internal prompts, or implementation details in reasoning traces.
|
||||||
|
- Do not use code fences or structured data blocks in reasoning traces.
|
||||||
|
|
||||||
|
GENERAL GUIDANCE:
|
||||||
|
- Cite the exact document or fetched opinion passage for evidence-backed claims.
|
||||||
|
- If no documents are provided, answer from legal knowledge.
|
||||||
|
- Do not use emojis.
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assemble the chat system prompt. When `includeResearchTools` is true the
|
||||||
|
* CourtListener (US case-law) research instructions are spliced in; when
|
||||||
|
* false they are omitted entirely so the model is not told about tools it
|
||||||
|
* does not have.
|
||||||
|
*/
|
||||||
|
export function buildSystemPrompt(includeResearchTools = true): string {
|
||||||
|
return includeResearchTools
|
||||||
|
? `${SYSTEM_PROMPT_BEFORE_RESEARCH}\n\n${COURTLISTENER_SYSTEM_PROMPT}\n${SYSTEM_PROMPT_AFTER_RESEARCH}`
|
||||||
|
: `${SYSTEM_PROMPT_BEFORE_RESEARCH}\n\n${SYSTEM_PROMPT_AFTER_RESEARCH}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SYSTEM_PROMPT = buildSystemPrompt(true);
|
||||||
553
backend/src/lib/chat/streaming.ts
Normal file
|
|
@ -0,0 +1,553 @@
|
||||||
|
import {
|
||||||
|
streamChatWithTools,
|
||||||
|
resolveModel,
|
||||||
|
DEFAULT_MAIN_MODEL,
|
||||||
|
type LlmMessage,
|
||||||
|
type OpenAIToolSchema,
|
||||||
|
} from "../llm";
|
||||||
|
import { safeErrorMessage } from "../safeError";
|
||||||
|
import { createServerSupabase } from "../supabase";
|
||||||
|
import {
|
||||||
|
buildUserMcpTools,
|
||||||
|
type McpToolEvent,
|
||||||
|
} from "../mcpConnectors";
|
||||||
|
import {
|
||||||
|
COURTLISTENER_TOOLS,
|
||||||
|
type CaseCitationEvent,
|
||||||
|
type CourtlistenerToolEvent,
|
||||||
|
} from "./tools/courtlistenerTools";
|
||||||
|
import {
|
||||||
|
type DocStore,
|
||||||
|
type DocIndex,
|
||||||
|
type TabularCellStore,
|
||||||
|
type WorkflowStore,
|
||||||
|
type ToolCall,
|
||||||
|
type AskInputsEvent,
|
||||||
|
type EditAnnotation,
|
||||||
|
devLog,
|
||||||
|
} from "./types";
|
||||||
|
import { TOOLS, WORKFLOW_TOOLS } from "./tools/toolSchemas";
|
||||||
|
import {
|
||||||
|
parseCitationsWithDiagnostics,
|
||||||
|
parsePartialCitationObjects,
|
||||||
|
createCitation,
|
||||||
|
CITATIONS_OPEN_TAG,
|
||||||
|
} from "./citations";
|
||||||
|
import {
|
||||||
|
runToolCalls,
|
||||||
|
type CourtlistenerTurnState,
|
||||||
|
} from "./tools/toolDispatcher";
|
||||||
|
import {
|
||||||
|
type TurnEditState,
|
||||||
|
type TurnReadState,
|
||||||
|
} from "./tools/documentOps";
|
||||||
|
|
||||||
|
|
||||||
|
export type AssistantEvent =
|
||||||
|
| { type: "reasoning"; text: string }
|
||||||
|
| AskInputsEvent
|
||||||
|
| {
|
||||||
|
type: "ask_inputs_response";
|
||||||
|
responses: {
|
||||||
|
id: string;
|
||||||
|
kind: "choice" | "documents";
|
||||||
|
question?: string;
|
||||||
|
answer?: string;
|
||||||
|
filenames?: string[];
|
||||||
|
skipped?: boolean;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
| { type: "doc_read"; filename: string; document_id?: string }
|
||||||
|
| {
|
||||||
|
type: "doc_find";
|
||||||
|
filename: string;
|
||||||
|
query: string;
|
||||||
|
total_matches: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "doc_created";
|
||||||
|
filename: string;
|
||||||
|
download_url: string;
|
||||||
|
document_id?: string;
|
||||||
|
version_id?: string;
|
||||||
|
version_number?: number | null;
|
||||||
|
}
|
||||||
|
| { type: "doc_download"; filename: string; download_url: string }
|
||||||
|
| {
|
||||||
|
type: "doc_replicated";
|
||||||
|
/** Source document being copied. */
|
||||||
|
filename: string;
|
||||||
|
count: number;
|
||||||
|
copies: {
|
||||||
|
new_filename: string;
|
||||||
|
document_id: string;
|
||||||
|
version_id: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
| { type: "workflow_applied"; workflow_id: string; title: string }
|
||||||
|
| {
|
||||||
|
type: "doc_edited";
|
||||||
|
filename: string;
|
||||||
|
document_id: string;
|
||||||
|
version_id: string;
|
||||||
|
/** Per-document monotonic Vn; null if backend couldn't determine it. */
|
||||||
|
version_number: number | null;
|
||||||
|
download_url: string;
|
||||||
|
annotations: EditAnnotation[];
|
||||||
|
}
|
||||||
|
| CaseCitationEvent
|
||||||
|
| CourtlistenerToolEvent
|
||||||
|
| McpToolEvent
|
||||||
|
| { type: "case_opinions"; cluster_id: number; case: unknown }
|
||||||
|
| { type: "content"; text: string }
|
||||||
|
| { type: "error"; message: string };
|
||||||
|
|
||||||
|
export class AssistantStreamError extends Error {
|
||||||
|
fullText: string;
|
||||||
|
events: AssistantEvent[];
|
||||||
|
|
||||||
|
constructor(message: string, fullText: string, events: AssistantEvent[]) {
|
||||||
|
super(message);
|
||||||
|
this.name = "AssistantStreamError";
|
||||||
|
this.fullText = fullText;
|
||||||
|
this.events = events;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AssistantStreamAbortError extends AssistantStreamError {
|
||||||
|
constructor(fullText: string, events: AssistantEvent[]) {
|
||||||
|
super("Stream aborted.", fullText, events);
|
||||||
|
this.name = "AbortError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AssistantStreamAskInputsPause extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("Waiting for user input.");
|
||||||
|
this.name = "AssistantStreamAskInputsPause";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAbortError(error: unknown): boolean {
|
||||||
|
if (!error || typeof error !== "object") return false;
|
||||||
|
const record = error as { name?: unknown; message?: unknown };
|
||||||
|
return (
|
||||||
|
record.name === "AbortError" || record.message === "Stream aborted."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function throwIfAborted(signal?: AbortSignal) {
|
||||||
|
if (!signal?.aborted) return;
|
||||||
|
const err = new Error("Stream aborted.");
|
||||||
|
err.name = "AbortError";
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runLLMStream(params: {
|
||||||
|
apiMessages: unknown[];
|
||||||
|
docStore: DocStore;
|
||||||
|
docIndex: DocIndex;
|
||||||
|
userId: string;
|
||||||
|
db: ReturnType<typeof createServerSupabase>;
|
||||||
|
write: (s: string) => void;
|
||||||
|
extraTools?: unknown[];
|
||||||
|
includeResearchTools?: boolean;
|
||||||
|
workflowStore?: WorkflowStore;
|
||||||
|
tabularStore?: TabularCellStore;
|
||||||
|
buildCitations?: (fullText: string) => unknown[];
|
||||||
|
model?: string;
|
||||||
|
apiKeys?: import("../llm").UserApiKeys;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
/**
|
||||||
|
* If set, generate_docx will attach created docs to this project so
|
||||||
|
* they appear in the project sidebar. Leave null for general chats —
|
||||||
|
* generated docs still get persisted, but as standalone documents.
|
||||||
|
*/
|
||||||
|
projectId?: string | null;
|
||||||
|
}): Promise<{
|
||||||
|
fullText: string;
|
||||||
|
events: AssistantEvent[];
|
||||||
|
citations: unknown[];
|
||||||
|
}> {
|
||||||
|
const {
|
||||||
|
apiMessages,
|
||||||
|
docStore,
|
||||||
|
docIndex,
|
||||||
|
userId,
|
||||||
|
db,
|
||||||
|
write,
|
||||||
|
extraTools,
|
||||||
|
includeResearchTools = true,
|
||||||
|
workflowStore,
|
||||||
|
tabularStore,
|
||||||
|
buildCitations,
|
||||||
|
model,
|
||||||
|
apiKeys,
|
||||||
|
signal,
|
||||||
|
projectId,
|
||||||
|
} = params;
|
||||||
|
const researchTools = includeResearchTools ? COURTLISTENER_TOOLS : [];
|
||||||
|
const mcpTools = await buildUserMcpTools(userId, db);
|
||||||
|
const baseTools = [...TOOLS, ...researchTools, ...WORKFLOW_TOOLS];
|
||||||
|
const activeTools = extraTools?.length
|
||||||
|
? [...baseTools, ...mcpTools, ...extraTools]
|
||||||
|
: [...baseTools, ...mcpTools];
|
||||||
|
|
||||||
|
// Extract system prompt; pass remaining turns to the adapter as
|
||||||
|
// plain user/assistant messages.
|
||||||
|
const rawMsgs = apiMessages as { role: string; content: string | null }[];
|
||||||
|
const systemPrompt =
|
||||||
|
rawMsgs[0]?.role === "system" ? (rawMsgs[0].content ?? "") : "";
|
||||||
|
const chatMessages: LlmMessage[] = rawMsgs
|
||||||
|
.filter((m) => m.role !== "system")
|
||||||
|
.map((m) => ({
|
||||||
|
role: m.role === "assistant" ? "assistant" : "user",
|
||||||
|
content: m.content ?? "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const events: AssistantEvent[] = [];
|
||||||
|
// One assistant turn produces at most one document_versions row per
|
||||||
|
// edited doc. `runToolCalls` fires once per tool-call batch; the model
|
||||||
|
// may emit multiple batches in a single turn, so this map persists
|
||||||
|
// across batches to let subsequent edit_document calls overwrite the
|
||||||
|
// turn's existing version instead of creating a new one.
|
||||||
|
const turnEditState: TurnEditState = new Map();
|
||||||
|
// Suppress repeated full-document reads for the same document/version in
|
||||||
|
// one assistant response. The guard is invalidated when edit_document
|
||||||
|
// changes that document so a post-edit verification read can still happen.
|
||||||
|
const turnReadState: TurnReadState = new Map();
|
||||||
|
const courtlistenerTurnState: CourtlistenerTurnState = {
|
||||||
|
casesByClusterId: new Map(),
|
||||||
|
};
|
||||||
|
let fullText = "";
|
||||||
|
let iterText = "";
|
||||||
|
let iterVisibleText = "";
|
||||||
|
let iterReasoning = "";
|
||||||
|
let visibleTailBuffer = "";
|
||||||
|
let citationsOpenSeen = false;
|
||||||
|
let streamingCitationsBuffer = "";
|
||||||
|
let streamedCitationCount = 0;
|
||||||
|
|
||||||
|
const emitCitationStreamSnapshot = (
|
||||||
|
status: "started" | "partial",
|
||||||
|
citations: unknown[],
|
||||||
|
) => {
|
||||||
|
if (buildCitations) return;
|
||||||
|
write(`data: ${JSON.stringify({ type: "citations", status, citations })}\n\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const streamHiddenCitationContent = (delta: string) => {
|
||||||
|
if (buildCitations || !delta) return;
|
||||||
|
streamingCitationsBuffer += delta;
|
||||||
|
const partial = parsePartialCitationObjects(streamingCitationsBuffer);
|
||||||
|
if (partial.length <= streamedCitationCount) return;
|
||||||
|
streamedCitationCount = partial.length;
|
||||||
|
const citations = partial.map((c) =>
|
||||||
|
createCitation(
|
||||||
|
c,
|
||||||
|
docIndex,
|
||||||
|
courtlistenerTurnState.casesByClusterId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
emitCitationStreamSnapshot("partial", citations);
|
||||||
|
};
|
||||||
|
|
||||||
|
const streamVisibleContent = (delta: string) => {
|
||||||
|
if (!delta) return;
|
||||||
|
if (citationsOpenSeen) {
|
||||||
|
streamHiddenCitationContent(delta);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const combined = visibleTailBuffer + delta;
|
||||||
|
const markerIdx = combined.indexOf(CITATIONS_OPEN_TAG);
|
||||||
|
if (markerIdx >= 0) {
|
||||||
|
const visible = combined.slice(0, markerIdx);
|
||||||
|
if (visible) {
|
||||||
|
iterVisibleText += visible;
|
||||||
|
write(
|
||||||
|
`data: ${JSON.stringify({ type: "content_delta", text: visible })}\n\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
visibleTailBuffer = "";
|
||||||
|
citationsOpenSeen = true;
|
||||||
|
streamingCitationsBuffer = "";
|
||||||
|
streamedCitationCount = 0;
|
||||||
|
emitCitationStreamSnapshot("started", []);
|
||||||
|
streamHiddenCitationContent(
|
||||||
|
combined.slice(markerIdx + CITATIONS_OPEN_TAG.length),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keep = Math.min(CITATIONS_OPEN_TAG.length - 1, combined.length);
|
||||||
|
const visible = combined.slice(0, combined.length - keep);
|
||||||
|
visibleTailBuffer = combined.slice(combined.length - keep);
|
||||||
|
if (visible) {
|
||||||
|
iterVisibleText += visible;
|
||||||
|
write(
|
||||||
|
`data: ${JSON.stringify({ type: "content_delta", text: visible })}\n\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushVisibleTail = (opts: { emit?: boolean } = {}) => {
|
||||||
|
const emit = opts.emit ?? true;
|
||||||
|
if (citationsOpenSeen || !visibleTailBuffer) {
|
||||||
|
visibleTailBuffer = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
iterVisibleText += visibleTailBuffer;
|
||||||
|
if (emit) {
|
||||||
|
write(
|
||||||
|
`data: ${JSON.stringify({ type: "content_delta", text: visibleTailBuffer })}\n\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
visibleTailBuffer = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushText = (opts: { emit?: boolean } = {}) => {
|
||||||
|
if (!iterText) return;
|
||||||
|
fullText += iterText;
|
||||||
|
flushVisibleTail(opts);
|
||||||
|
if (iterVisibleText) {
|
||||||
|
events.push({ type: "content", text: iterVisibleText });
|
||||||
|
}
|
||||||
|
iterText = "";
|
||||||
|
iterVisibleText = "";
|
||||||
|
visibleTailBuffer = "";
|
||||||
|
citationsOpenSeen = false;
|
||||||
|
streamingCitationsBuffer = "";
|
||||||
|
streamedCitationCount = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushPartialTurn = (opts: { emit?: boolean } = {}) => {
|
||||||
|
flushText(opts);
|
||||||
|
if (iterReasoning) {
|
||||||
|
events.push({ type: "reasoning", text: iterReasoning });
|
||||||
|
iterReasoning = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedModel = resolveModel(model, DEFAULT_MAIN_MODEL);
|
||||||
|
|
||||||
|
try {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
await streamChatWithTools({
|
||||||
|
model: selectedModel,
|
||||||
|
systemPrompt,
|
||||||
|
messages: chatMessages,
|
||||||
|
tools: activeTools as OpenAIToolSchema[],
|
||||||
|
maxIterations: 10,
|
||||||
|
apiKeys,
|
||||||
|
enableThinking: true,
|
||||||
|
abortSignal: signal,
|
||||||
|
callbacks: {
|
||||||
|
onContentDelta: (delta) => {
|
||||||
|
iterText += delta;
|
||||||
|
streamVisibleContent(delta);
|
||||||
|
},
|
||||||
|
onReasoningDelta: (delta) => {
|
||||||
|
iterReasoning += delta;
|
||||||
|
write(
|
||||||
|
`data: ${JSON.stringify({ type: "reasoning_delta", text: delta })}\n\n`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onReasoningBlockEnd: () => {
|
||||||
|
if (!iterReasoning) return;
|
||||||
|
events.push({ type: "reasoning", text: iterReasoning });
|
||||||
|
write(`data: ${JSON.stringify({ type: "reasoning_block_end" })}\n\n`);
|
||||||
|
iterReasoning = "";
|
||||||
|
},
|
||||||
|
// Fires after Claude's turn ends with stop_reason=tool_use, before
|
||||||
|
// the tool actually runs. Flushes any buffered assistant text so
|
||||||
|
// it's emitted in chronological order, then signals the client so
|
||||||
|
// it can open a fresh PreResponseWrapper (shows "Working…") while
|
||||||
|
// the tool executes — avoids the dead gap between message_stop
|
||||||
|
// and the first tool-specific event.
|
||||||
|
onToolCallStart: (call) => {
|
||||||
|
flushText();
|
||||||
|
write(
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
type: "tool_call_start",
|
||||||
|
name: call.name,
|
||||||
|
})}\n\n`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
runTools: async (calls) => {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
// Emit any text the model produced before this tool turn so the
|
||||||
|
// UI sees it before the tool results stream in.
|
||||||
|
flushText();
|
||||||
|
|
||||||
|
const toolCalls: ToolCall[] = calls.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
function: {
|
||||||
|
name: c.name,
|
||||||
|
arguments: JSON.stringify(c.input),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const {
|
||||||
|
toolResults,
|
||||||
|
docsRead,
|
||||||
|
docsFound,
|
||||||
|
docsCreated,
|
||||||
|
docsReplicated,
|
||||||
|
workflowsApplied,
|
||||||
|
docsEdited,
|
||||||
|
askInputsEvents,
|
||||||
|
courtlistenerEvents,
|
||||||
|
caseCitationEvents,
|
||||||
|
mcpEvents,
|
||||||
|
} = await runToolCalls(
|
||||||
|
toolCalls,
|
||||||
|
docStore,
|
||||||
|
userId,
|
||||||
|
db,
|
||||||
|
write,
|
||||||
|
workflowStore,
|
||||||
|
tabularStore,
|
||||||
|
docIndex,
|
||||||
|
turnEditState,
|
||||||
|
turnReadState,
|
||||||
|
projectId,
|
||||||
|
courtlistenerTurnState,
|
||||||
|
apiKeys,
|
||||||
|
);
|
||||||
|
throwIfAborted(signal);
|
||||||
|
for (const r of docsRead) {
|
||||||
|
events.push({
|
||||||
|
type: "doc_read",
|
||||||
|
filename: r.filename,
|
||||||
|
document_id: r.document_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const f of docsFound) {
|
||||||
|
events.push({
|
||||||
|
type: "doc_find",
|
||||||
|
filename: f.filename,
|
||||||
|
query: f.query,
|
||||||
|
total_matches: f.total_matches,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const dl of docsCreated) {
|
||||||
|
events.push({
|
||||||
|
type: "doc_created",
|
||||||
|
filename: dl.filename,
|
||||||
|
download_url: dl.download_url,
|
||||||
|
document_id: dl.document_id,
|
||||||
|
version_id: dl.version_id,
|
||||||
|
version_number: dl.version_number ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const r of docsReplicated) {
|
||||||
|
events.push({
|
||||||
|
type: "doc_replicated",
|
||||||
|
filename: r.filename,
|
||||||
|
count: r.count,
|
||||||
|
copies: r.copies,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const wf of workflowsApplied) {
|
||||||
|
events.push({
|
||||||
|
type: "workflow_applied",
|
||||||
|
workflow_id: wf.workflow_id,
|
||||||
|
title: wf.title,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const e of docsEdited) {
|
||||||
|
events.push({
|
||||||
|
type: "doc_edited",
|
||||||
|
filename: e.filename,
|
||||||
|
document_id: e.document_id,
|
||||||
|
version_id: e.version_id,
|
||||||
|
version_number: e.version_number,
|
||||||
|
download_url: e.download_url,
|
||||||
|
annotations: e.annotations,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const askInputsEvent of askInputsEvents) {
|
||||||
|
write(`data: ${JSON.stringify(askInputsEvent)}\n\n`);
|
||||||
|
events.push(askInputsEvent);
|
||||||
|
}
|
||||||
|
for (const event of courtlistenerEvents) {
|
||||||
|
events.push(event);
|
||||||
|
}
|
||||||
|
for (const event of mcpEvents) {
|
||||||
|
events.push(event);
|
||||||
|
}
|
||||||
|
for (const event of caseCitationEvents) {
|
||||||
|
events.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (askInputsEvents.length > 0) {
|
||||||
|
throw new AssistantStreamAskInputsPause();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index alignment would break if any tool branch skips its
|
||||||
|
// push (unhandled tool name, disabled store, guard failure).
|
||||||
|
// Each tool_result already carries its tool_call_id, so key off
|
||||||
|
// that directly — and fall back to an error result for any
|
||||||
|
// tool_use that didn't produce one, so Claude's next request
|
||||||
|
// has a tool_result for every tool_use it sent.
|
||||||
|
const resultByCallId = new Map<string, string>();
|
||||||
|
for (const r of toolResults) {
|
||||||
|
const row = r as { tool_call_id: string; content?: unknown };
|
||||||
|
resultByCallId.set(row.tool_call_id, String(row.content ?? ""));
|
||||||
|
}
|
||||||
|
return toolCalls.map((c) => ({
|
||||||
|
tool_use_id: c.id,
|
||||||
|
content:
|
||||||
|
resultByCallId.get(c.id) ??
|
||||||
|
JSON.stringify({
|
||||||
|
error: `Tool '${c.function.name}' is not available.`,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AssistantStreamAskInputsPause) {
|
||||||
|
// The ask_inputs event has already been emitted and persisted in `events`.
|
||||||
|
// Stop this assistant turn here so the model does not add redundant
|
||||||
|
// prose telling the user to answer the picker or attach documents.
|
||||||
|
} else if (isAbortError(err)) {
|
||||||
|
flushPartialTurn({ emit: false });
|
||||||
|
throw new AssistantStreamAbortError(fullText, events);
|
||||||
|
} else {
|
||||||
|
flushPartialTurn();
|
||||||
|
const message = safeErrorMessage(err, "Stream error");
|
||||||
|
events.push({ type: "error", message });
|
||||||
|
throw new AssistantStreamError(message, fullText, events);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flushText();
|
||||||
|
|
||||||
|
// Parse and emit citations from <CITATIONS> block
|
||||||
|
const { citations: parsedCitations, diagnostics: citationDiagnostics } =
|
||||||
|
parseCitationsWithDiagnostics(fullText);
|
||||||
|
const citations = buildCitations
|
||||||
|
? buildCitations(fullText)
|
||||||
|
: parsedCitations.map((c) =>
|
||||||
|
createCitation(
|
||||||
|
c,
|
||||||
|
docIndex,
|
||||||
|
courtlistenerTurnState.casesByClusterId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
devLog("[chat/stream] final citations", {
|
||||||
|
hasCitationsBlock: citationDiagnostics.hasBlock,
|
||||||
|
citationsBlockLength: citationDiagnostics.rawLength,
|
||||||
|
parseError: citationDiagnostics.error,
|
||||||
|
parsedCitationCount: parsedCitations.length,
|
||||||
|
emittedCitationCount: citations.length,
|
||||||
|
usedCustomCitationBuilder: !!buildCitations,
|
||||||
|
});
|
||||||
|
write(
|
||||||
|
`data: ${JSON.stringify({ type: "citations", status: "final", citations })}\n\n`,
|
||||||
|
);
|
||||||
|
write("data: [DONE]\n\n");
|
||||||
|
|
||||||
|
return { fullText, events, citations };
|
||||||
|
}
|
||||||
1813
backend/src/lib/chat/tools/documentOps.ts
Normal file
1896
backend/src/lib/chat/tools/toolDispatcher.ts
Normal file
471
backend/src/lib/chat/tools/toolSchemas.ts
Normal file
|
|
@ -0,0 +1,471 @@
|
||||||
|
export const PROJECT_EXTRA_TOOLS = [
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "list_documents",
|
||||||
|
description:
|
||||||
|
"List all documents available in the project. Returns each document's ID, filename, and file type. Call this to discover what documents are available before deciding which ones to read.",
|
||||||
|
parameters: { type: "object", properties: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "fetch_documents",
|
||||||
|
description:
|
||||||
|
"Read the full text content of multiple documents in a single call. Use this instead of calling read_document repeatedly when you need to read several documents at once. In one response, fetch each document/version at most once; after it has been fetched, use the prior tool result or find_in_document for targeted checks.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
doc_ids: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description:
|
||||||
|
"Array of document IDs to read (e.g. ['doc-0', 'doc-2'])",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["doc_ids"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "replicate_document",
|
||||||
|
description:
|
||||||
|
"Make byte-for-byte copies of an existing project document as new project documents. Use when the user wants standalone copies to edit (e.g. 'use this NDA as a template', 'give me three drafts I can adapt') without modifying the original. Pass `count` to create multiple copies in a single call rather than calling the tool repeatedly. Returns the new doc_id slugs so you can immediately call edit_document / read_document on them.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
doc_id: {
|
||||||
|
type: "string",
|
||||||
|
description: "ID of the source document to copy (e.g. 'doc-0').",
|
||||||
|
},
|
||||||
|
count: {
|
||||||
|
type: "integer",
|
||||||
|
description:
|
||||||
|
"How many copies to create. Defaults to 1. Maximum 20.",
|
||||||
|
minimum: 1,
|
||||||
|
maximum: 20,
|
||||||
|
},
|
||||||
|
new_filename: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Optional base filename. With count > 1, copies are suffixed (e.g. 'Foo (1).docx', 'Foo (2).docx'). Extension is forced to match the source.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["doc_id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TABULAR_TOOLS = [
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "read_table_cells",
|
||||||
|
description:
|
||||||
|
"Read the extracted cell content from the tabular review. Each cell contains the value extracted for a specific column from a specific document. Pass col_indices and/or row_indices (0-based) to read a subset; omit either to read all columns or all rows.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
col_indices: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "integer" },
|
||||||
|
description:
|
||||||
|
"0-based column indices to read (e.g. [0, 2]). Omit to read all columns.",
|
||||||
|
},
|
||||||
|
row_indices: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "integer" },
|
||||||
|
description:
|
||||||
|
"0-based document (row) indices to read (e.g. [0, 1]). Omit to read all rows.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const WORKFLOW_TOOLS = [
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "list_workflows",
|
||||||
|
description:
|
||||||
|
"List all workflows available to the user. Returns each workflow's ID and title. Call this when the user asks to run a workflow, apply a template, or you need to discover what workflows exist.",
|
||||||
|
parameters: { type: "object", properties: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "read_workflow",
|
||||||
|
description:
|
||||||
|
"Read the full instructions (prompt) of a workflow by its ID. Call this after list_workflows to load a specific workflow's prompt, then follow those instructions.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
workflow_id: {
|
||||||
|
type: "string",
|
||||||
|
description: "The workflow ID to read",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["workflow_id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TOOLS = [
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "ask_inputs",
|
||||||
|
description:
|
||||||
|
"Ask the user for one or more decisions, clarifications, or document uploads before continuing. Use this when guessing would materially affect the answer or when required documents have not been attached. Put all needed questions and document requests in one items array. After calling ask_inputs, do not continue the substantive task until the user responds in a later message.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
items: {
|
||||||
|
type: "array",
|
||||||
|
minItems: 1,
|
||||||
|
maxItems: 12,
|
||||||
|
description:
|
||||||
|
"The list of user inputs needed before continuing. Use choice items for decisions/clarifications and documents items for required uploads.",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Stable short ID for this input, unique within this tool call.",
|
||||||
|
},
|
||||||
|
kind: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["choice", "documents"],
|
||||||
|
},
|
||||||
|
question: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"For choice items only: the concise question to show to the user.",
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
type: "array",
|
||||||
|
description:
|
||||||
|
"For choice items only: selectable choices to show. Each choice has a single user-facing value, which is also sent back if selected.",
|
||||||
|
minItems: 1,
|
||||||
|
maxItems: 8,
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
value: {
|
||||||
|
type: "string",
|
||||||
|
description: "The user-facing choice text.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["value"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
allow_other: {
|
||||||
|
type: "boolean",
|
||||||
|
description:
|
||||||
|
"For choice items only: whether to show an Other option with a text field. Defaults to true.",
|
||||||
|
},
|
||||||
|
other_label: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"For choice items only: label for the free-text option. Defaults to Other.",
|
||||||
|
},
|
||||||
|
document_types: {
|
||||||
|
type: "array",
|
||||||
|
description:
|
||||||
|
"For documents items only: readable labels for the types of documents you need the user to attach.",
|
||||||
|
minItems: 1,
|
||||||
|
maxItems: 8,
|
||||||
|
items: {
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response_prefix: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Optional prefix the UI should include when sending this response back as the next message.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["id", "kind"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["items"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "read_document",
|
||||||
|
description:
|
||||||
|
"Read the full text content of a document attached by the user. Always call this before answering questions about, summarising, citing from, or editing a document, but call it at most once per document/version in a single response. After this returns, use the prior tool result or find_in_document for targeted checks instead of reading the same document/version again.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
doc_id: {
|
||||||
|
type: "string",
|
||||||
|
description: "The document ID to read (e.g. 'doc-0', 'doc-1')",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["doc_id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "find_in_document",
|
||||||
|
description:
|
||||||
|
"Search for specific strings inside a document — a Ctrl+F equivalent. Returns each match with surrounding context so you can locate and quote the exact text without reading the whole document. Matching is case-insensitive and whitespace-tolerant. Use this for targeted lookups (e.g. finding a clause title, party name, or a specific phrase) rather than reading the whole document.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
doc_id: {
|
||||||
|
type: "string",
|
||||||
|
description: "The document ID to search (e.g. 'doc-0').",
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"The string to search for. Matching is case-insensitive and collapses runs of whitespace, so 'Section 4.2' matches 'section 4.2'.",
|
||||||
|
},
|
||||||
|
max_results: {
|
||||||
|
type: "integer",
|
||||||
|
description:
|
||||||
|
"Maximum number of matches to return (default 20). Use a smaller value for common terms.",
|
||||||
|
},
|
||||||
|
context_chars: {
|
||||||
|
type: "integer",
|
||||||
|
description:
|
||||||
|
"Characters of surrounding context to include on each side of a match (default 80).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["doc_id", "query"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "generate_docx",
|
||||||
|
description:
|
||||||
|
"Generate a Word (.docx) document from structured content. Use this when the user asks you to draft, create, or produce a legal document. Returns a download URL for the generated file.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
title: {
|
||||||
|
type: "string",
|
||||||
|
description: "Document title (used as filename and heading)",
|
||||||
|
},
|
||||||
|
landscape: {
|
||||||
|
type: "boolean",
|
||||||
|
description:
|
||||||
|
"Set to true for landscape page orientation. Default is portrait.",
|
||||||
|
},
|
||||||
|
sections: {
|
||||||
|
type: "array",
|
||||||
|
description:
|
||||||
|
"List of document sections. Each section may contain a heading, prose content, or a table.",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
heading: {
|
||||||
|
type: "string",
|
||||||
|
description: "Optional section heading",
|
||||||
|
},
|
||||||
|
level: {
|
||||||
|
type: "integer",
|
||||||
|
description: "Heading level: 1, 2, or 3",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Prose text content (paragraphs separated by double newlines)",
|
||||||
|
},
|
||||||
|
pageBreak: {
|
||||||
|
type: "boolean",
|
||||||
|
description:
|
||||||
|
"Set to true to start this section on a new page. Use for contract signature pages.",
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
type: "object",
|
||||||
|
description: "Optional table to render in this section",
|
||||||
|
properties: {
|
||||||
|
headers: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description: "Column header labels",
|
||||||
|
},
|
||||||
|
rows: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
"Array of rows, each row is an array of cell strings matching the headers order",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["headers", "rows"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["title", "sections"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "generate_excel",
|
||||||
|
description:
|
||||||
|
"Generate an Excel (.xlsx) workbook from structured sheet data. Use this when the user asks for a spreadsheet, tracker, matrix, checklist, schedule, or Excel file. Returns a download URL for the generated file.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
title: {
|
||||||
|
type: "string",
|
||||||
|
description: "Workbook title, used as the filename.",
|
||||||
|
},
|
||||||
|
sheets: {
|
||||||
|
type: "array",
|
||||||
|
description:
|
||||||
|
"Workbook sheets. Each sheet has a name, columns, and rows. Row values should follow the columns order.",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: "string",
|
||||||
|
description: "Sheet tab name. Keep it short.",
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description: "Column header labels.",
|
||||||
|
},
|
||||||
|
rows: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
"Array of rows, each row an array of cell strings matching the columns order.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["name", "columns", "rows"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["title", "sheets"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "generate_ppt",
|
||||||
|
description:
|
||||||
|
"Generate a PowerPoint (.pptx) presentation from structured slides. Use this when the user asks for slides, a deck, presentation, or PowerPoint file. Returns a download URL for the generated file.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
title: {
|
||||||
|
type: "string",
|
||||||
|
description: "Presentation title, used as the filename.",
|
||||||
|
},
|
||||||
|
slides: {
|
||||||
|
type: "array",
|
||||||
|
description:
|
||||||
|
"Slides in order. Each slide may have a title, bullets, and optional speaker notes.",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
title: {
|
||||||
|
type: "string",
|
||||||
|
description: "Slide title.",
|
||||||
|
},
|
||||||
|
bullets: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description:
|
||||||
|
"Main bullet points for the slide. Keep each bullet concise.",
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Optional speaker notes. Included as text on a notes slide placeholder is not supported; use only for generation context.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["title", "bullets"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["title", "slides"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "edit_document",
|
||||||
|
description:
|
||||||
|
"Propose edits to a user-attached .docx as tracked changes. Each edit is a precise, minimal substitution of specific words/characters, NOT a whole-line or paragraph replacement. Use read_document first unless this same document/version has already been read in the current response. Anchor each edit with short before/after context so it can be located unambiguously. Returns per-edit annotations the UI will render as Accept/Reject cards and a download link to the edited document.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
doc_id: {
|
||||||
|
type: "string",
|
||||||
|
description: "Document slug (e.g. 'doc-0').",
|
||||||
|
},
|
||||||
|
edits: {
|
||||||
|
type: "array",
|
||||||
|
description: "List of precise substitutions.",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
find: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Exact substring to replace (keep it as short as possible — ideally just the words/chars being changed).",
|
||||||
|
},
|
||||||
|
replace: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Replacement text. Empty string = pure deletion.",
|
||||||
|
},
|
||||||
|
context_before: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"~40 chars immediately preceding `find`, used to disambiguate.",
|
||||||
|
},
|
||||||
|
context_after: {
|
||||||
|
type: "string",
|
||||||
|
description: "~40 chars immediately following `find`.",
|
||||||
|
},
|
||||||
|
reason: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Short explanation shown to the user on the card.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["find", "replace", "context_before", "context_after"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["doc_id", "edits"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
153
backend/src/lib/chat/types.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export const STANDARD_FONT_DATA_URL = (() => {
|
||||||
|
try {
|
||||||
|
const pkgPath = require.resolve("pdfjs-dist/package.json");
|
||||||
|
return path.join(path.dirname(pkgPath), "standard_fonts") + path.sep;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV !== "production";
|
||||||
|
export const devLog = (...args: Parameters<typeof console.log>) => {
|
||||||
|
if (isDev) console.log(...args);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Core types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type DocStore = Map<
|
||||||
|
string,
|
||||||
|
{ storage_path: string; file_type: string; filename: string }
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type WorkflowStore = Map<string, { title: string; skill_md: string }>;
|
||||||
|
|
||||||
|
export type DocIndex = Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
document_id: string;
|
||||||
|
filename: string;
|
||||||
|
version_id?: string | null;
|
||||||
|
version_number?: number | null;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type TabularCellStore = {
|
||||||
|
columns: { index: number; name: string }[];
|
||||||
|
documents: { id: string; filename: string }[];
|
||||||
|
/** key: `${colIndex}:${docId}` */
|
||||||
|
cells: Map<
|
||||||
|
string,
|
||||||
|
{ summary: string; flag?: string; reasoning?: string } | null
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolCall = {
|
||||||
|
id: string;
|
||||||
|
function: { name: string; arguments: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChatMessage = {
|
||||||
|
role: string;
|
||||||
|
content: string | null;
|
||||||
|
files?: { filename: string; document_id?: string }[];
|
||||||
|
workflow?: { id: string; title: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Doc resolution helpers (used by citations + documentOps)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function resolveDoc(rawId: string, docIndex: DocIndex) {
|
||||||
|
return docIndex[rawId];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve whatever identifier the model passed (`doc-N` slug, filename, or
|
||||||
|
* document UUID) back to a chat-local doc label.
|
||||||
|
*/
|
||||||
|
export function resolveDocLabel(
|
||||||
|
rawId: string,
|
||||||
|
docStore: DocStore,
|
||||||
|
docIndex?: DocIndex,
|
||||||
|
): string | null {
|
||||||
|
if (docStore.has(rawId)) return rawId;
|
||||||
|
for (const [label, info] of docStore.entries()) {
|
||||||
|
if (info.filename === rawId) return label;
|
||||||
|
}
|
||||||
|
if (docIndex) {
|
||||||
|
for (const [label, info] of Object.entries(docIndex)) {
|
||||||
|
if (info.document_id === rawId) return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Event / annotation types (shared between toolDispatcher and streaming)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type AskInputOption = {
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AskInputItem =
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
kind: "choice";
|
||||||
|
question: string;
|
||||||
|
options: AskInputOption[];
|
||||||
|
allow_other: boolean;
|
||||||
|
other_label: string;
|
||||||
|
response_prefix?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
kind: "documents";
|
||||||
|
document_types: string[];
|
||||||
|
response_prefix?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AskInputsEvent = {
|
||||||
|
type: "ask_inputs";
|
||||||
|
items: AskInputItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AskInputResponseItem =
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
kind: "choice";
|
||||||
|
question: string;
|
||||||
|
answer?: string;
|
||||||
|
skipped?: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
kind: "documents";
|
||||||
|
filenames: string[];
|
||||||
|
skipped?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AskInputsResponseRequest = {
|
||||||
|
responses: AskInputResponseItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EditAnnotation = {
|
||||||
|
kind: "edit";
|
||||||
|
edit_id: string;
|
||||||
|
document_id: string;
|
||||||
|
version_id: string;
|
||||||
|
version_number?: number | null;
|
||||||
|
change_id: string;
|
||||||
|
del_w_id?: string;
|
||||||
|
ins_w_id?: string;
|
||||||
|
deleted_text: string;
|
||||||
|
inserted_text: string;
|
||||||
|
context_before: string;
|
||||||
|
context_after: string;
|
||||||
|
reason?: string;
|
||||||
|
status: "pending" | "accepted" | "rejected";
|
||||||
|
};
|
||||||
60
backend/src/lib/documentTypes.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
export const ALLOWED_DOCUMENT_TYPES = new Set([
|
||||||
|
"pdf",
|
||||||
|
"docx",
|
||||||
|
"doc",
|
||||||
|
"xlsx",
|
||||||
|
"xlsm",
|
||||||
|
"xls",
|
||||||
|
"pptx",
|
||||||
|
"ppt",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const ALLOWED_DOCUMENT_TYPES_LABEL =
|
||||||
|
"pdf, docx, doc, xlsx, xlsm, xls, pptx, ppt";
|
||||||
|
|
||||||
|
const WORD_TYPES = new Set(["docx", "doc"]);
|
||||||
|
const SPREADSHEET_TYPES = new Set(["xlsx", "xlsm", "xls"]);
|
||||||
|
const PRESENTATION_TYPES = new Set(["pptx", "ppt"]);
|
||||||
|
|
||||||
|
export function isWordDocumentType(fileType: string | null | undefined) {
|
||||||
|
return WORD_TYPES.has((fileType ?? "").toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSpreadsheetDocumentType(fileType: string | null | undefined) {
|
||||||
|
return SPREADSHEET_TYPES.has((fileType ?? "").toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPresentationDocumentType(fileType: string | null | undefined) {
|
||||||
|
return PRESENTATION_TYPES.has((fileType ?? "").toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldConvertToPdf(fileType: string | null | undefined) {
|
||||||
|
const normalized = (fileType ?? "").toLowerCase();
|
||||||
|
// Spreadsheets are intentionally excluded: they are rendered natively as a
|
||||||
|
// grid in the frontend (Fortune-sheet) from the raw file bytes rather than a
|
||||||
|
// PDF rendition, which clipped wide/large sheets.
|
||||||
|
return (
|
||||||
|
isWordDocumentType(normalized) || isPresentationDocumentType(normalized)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contentTypeForDocumentType(fileType: string | null | undefined) {
|
||||||
|
switch ((fileType ?? "").toLowerCase()) {
|
||||||
|
case "pdf":
|
||||||
|
return "application/pdf";
|
||||||
|
case "docx":
|
||||||
|
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||||
|
case "xlsx":
|
||||||
|
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||||
|
case "xlsm":
|
||||||
|
return "application/vnd.ms-excel.sheet.macroEnabled.12";
|
||||||
|
case "xls":
|
||||||
|
return "application/vnd.ms-excel";
|
||||||
|
case "pptx":
|
||||||
|
return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
||||||
|
case "ppt":
|
||||||
|
return "application/vnd.ms-powerpoint";
|
||||||
|
default:
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,7 +28,7 @@ export type GeminiFunctionDeclaration = {
|
||||||
|
|
||||||
export function toGeminiTools(tools: OpenAIToolSchema[]): GeminiFunctionDeclaration[] {
|
export function toGeminiTools(tools: OpenAIToolSchema[]): GeminiFunctionDeclaration[] {
|
||||||
return tools.map((t) => {
|
return tools.map((t) => {
|
||||||
const params = normalizeSchema(t.function.parameters);
|
const params = normalizeGeminiSchema(t.function.parameters);
|
||||||
// Gemini rejects `{ type: "object", properties: {} }` with no fields
|
// Gemini rejects `{ type: "object", properties: {} }` with no fields
|
||||||
// present; omit the parameters key entirely when empty.
|
// present; omit the parameters key entirely when empty.
|
||||||
const hasProps =
|
const hasProps =
|
||||||
|
|
@ -72,3 +72,121 @@ function normalizeSchema(schema: unknown): Record<string, unknown> {
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GEMINI_SCHEMA_KEYS = new Set([
|
||||||
|
"type",
|
||||||
|
"description",
|
||||||
|
"enum",
|
||||||
|
"format",
|
||||||
|
"items",
|
||||||
|
"nullable",
|
||||||
|
"properties",
|
||||||
|
"required",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function normalizeGeminiSchema(schema: unknown): Record<string, unknown> {
|
||||||
|
return normalizeGeminiSchemaNode(schema, schema, new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGeminiSchemaNode(
|
||||||
|
schema: unknown,
|
||||||
|
root: unknown,
|
||||||
|
seenRefs: Set<string>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
||||||
|
return { type: "object", properties: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = schema as Record<string, unknown>;
|
||||||
|
const ref = typeof s.$ref === "string" ? s.$ref : null;
|
||||||
|
if (ref) {
|
||||||
|
const resolved = resolveLocalJsonRef(root, ref);
|
||||||
|
if (resolved && !seenRefs.has(ref)) {
|
||||||
|
return normalizeGeminiSchemaNode(
|
||||||
|
resolved,
|
||||||
|
root,
|
||||||
|
new Set([...seenRefs, ref]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { type: "string" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(s)) {
|
||||||
|
if (!GEMINI_SCHEMA_KEYS.has(key)) continue;
|
||||||
|
if (key.startsWith("$") || key.startsWith("x-")) continue;
|
||||||
|
out[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = normalizeGeminiType(out.type);
|
||||||
|
out.type = type;
|
||||||
|
|
||||||
|
if (type === "object") {
|
||||||
|
const props =
|
||||||
|
s.properties && typeof s.properties === "object" && !Array.isArray(s.properties)
|
||||||
|
? (s.properties as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const normProps: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(props)) {
|
||||||
|
normProps[key] = normalizeGeminiSchemaNode(value, root, seenRefs);
|
||||||
|
}
|
||||||
|
out.properties = normProps;
|
||||||
|
if (Array.isArray(s.required)) {
|
||||||
|
out.required = s.required.filter(
|
||||||
|
(name): name is string =>
|
||||||
|
typeof name === "string" && Object.prototype.hasOwnProperty.call(normProps, name),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
delete out.required;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete out.properties;
|
||||||
|
delete out.required;
|
||||||
|
|
||||||
|
if (type === "array") {
|
||||||
|
out.items = normalizeGeminiSchemaNode(s.items, root, seenRefs);
|
||||||
|
} else {
|
||||||
|
delete out.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGeminiType(value: unknown): string {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const nonNull = value.find(
|
||||||
|
(item): item is string => typeof item === "string" && item !== "null",
|
||||||
|
);
|
||||||
|
return normalizeGeminiType(nonNull);
|
||||||
|
}
|
||||||
|
if (typeof value !== "string" || !value) return "object";
|
||||||
|
if (value === "integer") return "number";
|
||||||
|
if (
|
||||||
|
value === "object" ||
|
||||||
|
value === "array" ||
|
||||||
|
value === "string" ||
|
||||||
|
value === "number" ||
|
||||||
|
value === "boolean"
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLocalJsonRef(root: unknown, ref: string): unknown {
|
||||||
|
if (!ref.startsWith("#/")) return null;
|
||||||
|
const parts = ref
|
||||||
|
.slice(2)
|
||||||
|
.split("/")
|
||||||
|
.map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~"));
|
||||||
|
let cursor = root;
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!cursor || typeof cursor !== "object" || Array.isArray(cursor)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
cursor = (cursor as Record<string, unknown>)[part];
|
||||||
|
}
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
|
||||||
53
backend/src/lib/officeText.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import JSZip from "jszip";
|
||||||
|
|
||||||
|
function decodeXml(text: string) {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
|
||||||
|
.replace(/&#x([0-9a-f]+);/gi, (_, code) =>
|
||||||
|
String.fromCharCode(Number.parseInt(code, 16)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTagText(xml: string, tagName: string) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const re = new RegExp(
|
||||||
|
`<${tagName}\\b[^>]*>([\\s\\S]*?)<\\/${tagName}>`,
|
||||||
|
"gi",
|
||||||
|
);
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = re.exec(xml))) parts.push(decodeXml(match[1]));
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function naturalSort(a: string, b: string) {
|
||||||
|
return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readZipText(zip: JSZip, path: string) {
|
||||||
|
const entry = zip.file(path);
|
||||||
|
return entry ? entry.async("text") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractPresentationText(buffer: Buffer) {
|
||||||
|
const zip = await JSZip.loadAsync(buffer);
|
||||||
|
const slidePaths = Object.keys(zip.files)
|
||||||
|
.filter((name) => /^ppt\/slides\/slide\d+\.xml$/i.test(name))
|
||||||
|
.sort(naturalSort);
|
||||||
|
|
||||||
|
const slides: string[] = [];
|
||||||
|
for (let index = 0; index < slidePaths.length; index++) {
|
||||||
|
const xml = await readZipText(zip, slidePaths[index]);
|
||||||
|
if (!xml) continue;
|
||||||
|
const text = extractTagText(xml, "a:t")
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
if (text) slides.push(`## Slide ${index + 1}\n\n${text}`);
|
||||||
|
}
|
||||||
|
return slides.join("\n\n").trim();
|
||||||
|
}
|
||||||
109
backend/src/lib/spreadsheet.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import * as XLSX from "xlsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spreadsheet parsing for the LLM read path.
|
||||||
|
*
|
||||||
|
* Replaces the old regex-over-OOXML extractor (`extractSpreadsheetText`) with a
|
||||||
|
* real reader. SheetJS handles `.xlsx`, `.xlsm`, and legacy `.xls` uniformly and
|
||||||
|
* exposes `cell.w` — the Excel-formatted display string — so dates and currency
|
||||||
|
* reach the model the way a human sees them (`3/1/26`, `$1,200`) rather than as
|
||||||
|
* raw serial numbers. Cached formula results are used (we never show formulas).
|
||||||
|
*
|
||||||
|
* The output is a compact, cell-addressed markdown table per sheet: a header row
|
||||||
|
* of column letters plus a leftmost row-number column. That lets the model name
|
||||||
|
* any cell as `Sheet!<col><row>` (e.g. `Q3 Budget!B7`) for cell-level citations,
|
||||||
|
* with none of the old `Row N:` / `|`-separator noise.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Formatted display text for a cell (`w`), falling back to the raw value. */
|
||||||
|
function cellDisplayText(cell: XLSX.CellObject | undefined): string {
|
||||||
|
if (!cell) return "";
|
||||||
|
if (typeof cell.w === "string" && cell.w.length > 0) return cell.w;
|
||||||
|
if (cell.v == null) return "";
|
||||||
|
return String(cell.v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escape a cell value so it can't break the markdown table layout. */
|
||||||
|
function sanitizeCellText(value: string): string {
|
||||||
|
return value.replace(/\r?\n/g, " ").replace(/\|/g, "\\|").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSheet(sheetName: string, ws: XLSX.WorkSheet): string | null {
|
||||||
|
const ref = ws["!ref"];
|
||||||
|
if (!ref) return null;
|
||||||
|
const range = XLSX.utils.decode_range(ref);
|
||||||
|
|
||||||
|
// Map each merged range's top-left (anchor) address to its encoded range so we
|
||||||
|
// can tag the anchor inline (e.g. `Amount ⟨merged B2:C2⟩`). The covered cells
|
||||||
|
// stay blank, so the model never reads a covered address (e.g. B1 inside
|
||||||
|
// A1:C1) as its own value; the tag tells it the anchor spans that range, and
|
||||||
|
// to cite the whole range for anything in it.
|
||||||
|
const mergeAnchors = new Map<string, string>();
|
||||||
|
for (const m of ws["!merges"] ?? []) {
|
||||||
|
mergeAnchors.set(XLSX.utils.encode_cell(m.s), XLSX.utils.encode_range(m));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a trimmed grid: capture formatted text for every cell in the used
|
||||||
|
// range, then drop trailing empty columns and fully empty rows so we don't
|
||||||
|
// emit oceans of blank cells.
|
||||||
|
const rows: { rowNumber: number; cells: string[] }[] = [];
|
||||||
|
let lastNonEmptyCol = -1;
|
||||||
|
|
||||||
|
for (let r = range.s.r; r <= range.e.r; r++) {
|
||||||
|
const cells: string[] = [];
|
||||||
|
let rowHasContent = false;
|
||||||
|
for (let c = range.s.c; c <= range.e.c; c++) {
|
||||||
|
const addr = XLSX.utils.encode_cell({ r, c });
|
||||||
|
let text = sanitizeCellText(cellDisplayText(ws[addr]));
|
||||||
|
const mergeRange = mergeAnchors.get(addr);
|
||||||
|
if (mergeRange) {
|
||||||
|
text = text
|
||||||
|
? `${text} ⟨merged ${mergeRange}⟩`
|
||||||
|
: `⟨merged ${mergeRange}⟩`;
|
||||||
|
}
|
||||||
|
cells[c - range.s.c] = text;
|
||||||
|
if (text) {
|
||||||
|
rowHasContent = true;
|
||||||
|
if (c - range.s.c > lastNonEmptyCol) lastNonEmptyCol = c - range.s.c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rowHasContent) rows.push({ rowNumber: r + 1, cells });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length === 0 || lastNonEmptyCol < 0) return null;
|
||||||
|
|
||||||
|
// Column-letter header, e.g. ["A", "B", "C"] for the used columns.
|
||||||
|
const colLetters: string[] = [];
|
||||||
|
for (let c = 0; c <= lastNonEmptyCol; c++) {
|
||||||
|
colLetters.push(XLSX.utils.encode_col(range.s.c + c));
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerRow = `| Row | ${colLetters.join(" | ")} |`;
|
||||||
|
const separator = `| --- | ${colLetters.map(() => "---").join(" | ")} |`;
|
||||||
|
const bodyRows = rows.map(({ rowNumber, cells }) => {
|
||||||
|
const padded: string[] = [];
|
||||||
|
for (let c = 0; c <= lastNonEmptyCol; c++) padded.push(cells[c] ?? "");
|
||||||
|
return `| ${rowNumber} | ${padded.join(" | ")} |`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines = [`## Sheet: ${sheetName}`, "", headerRow, separator, ...bodyRows];
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a spreadsheet as cell-addressed markdown for the LLM. Handles
|
||||||
|
* `.xlsx`, `.xlsm`, and legacy `.xls` (SheetJS reads all three), so callers no
|
||||||
|
* longer need the LibreOffice→PDF→text detour for spreadsheets.
|
||||||
|
*/
|
||||||
|
export function spreadsheetToLLMText(buffer: Buffer): string {
|
||||||
|
const wb = XLSX.read(buffer, { type: "buffer" });
|
||||||
|
const sheets: string[] = [];
|
||||||
|
for (const sheetName of wb.SheetNames) {
|
||||||
|
const ws = wb.Sheets[sheetName];
|
||||||
|
if (!ws) continue;
|
||||||
|
const rendered = renderSheet(sheetName, ws);
|
||||||
|
if (rendered) sheets.push(rendered);
|
||||||
|
}
|
||||||
|
return sheets.join("\n\n").trim();
|
||||||
|
}
|
||||||
1757
backend/src/lib/systemWorkflows.ts
Normal file
|
|
@ -321,6 +321,10 @@ export async function deleteUserAccountData(
|
||||||
db.from("chats").delete().eq("user_id", userId),
|
db.from("chats").delete().eq("user_id", userId),
|
||||||
db.from("project_subfolders").delete().eq("user_id", userId),
|
db.from("project_subfolders").delete().eq("user_id", userId),
|
||||||
db.from("hidden_workflows").delete().eq("user_id", userId),
|
db.from("hidden_workflows").delete().eq("user_id", userId),
|
||||||
|
db
|
||||||
|
.from("workflow_open_source_submissions")
|
||||||
|
.delete()
|
||||||
|
.eq("submitted_by_user_id", userId),
|
||||||
db.from("workflow_shares").delete().eq("shared_by_user_id", userId),
|
db.from("workflow_shares").delete().eq("shared_by_user_id", userId),
|
||||||
userEmail
|
userEmail
|
||||||
? db
|
? db
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,7 @@ export async function buildUserAccountExport(
|
||||||
projects,
|
projects,
|
||||||
standaloneDocuments,
|
standaloneDocuments,
|
||||||
workflows,
|
workflows,
|
||||||
|
workflowOpenSourceSubmissions,
|
||||||
hiddenWorkflows,
|
hiddenWorkflows,
|
||||||
workflowSharesByUser,
|
workflowSharesByUser,
|
||||||
workflowSharesWithUser,
|
workflowSharesWithUser,
|
||||||
|
|
@ -194,6 +195,11 @@ export async function buildUserAccountExport(
|
||||||
selectAll(db, "workflows", (query) =>
|
selectAll(db, "workflows", (query) =>
|
||||||
query.eq("user_id", userId).order("created_at", { ascending: true }),
|
query.eq("user_id", userId).order("created_at", { ascending: true }),
|
||||||
),
|
),
|
||||||
|
selectAll(db, "workflow_open_source_submissions", (query) =>
|
||||||
|
query
|
||||||
|
.eq("submitted_by_user_id", userId)
|
||||||
|
.order("submitted_at", { ascending: true }),
|
||||||
|
),
|
||||||
selectAll(db, "hidden_workflows", (query) =>
|
selectAll(db, "hidden_workflows", (query) =>
|
||||||
query.eq("user_id", userId).order("created_at", { ascending: true }),
|
query.eq("user_id", userId).order("created_at", { ascending: true }),
|
||||||
),
|
),
|
||||||
|
|
@ -263,6 +269,7 @@ export async function buildUserAccountExport(
|
||||||
document_versions: versions,
|
document_versions: versions,
|
||||||
document_edits: edits,
|
document_edits: edits,
|
||||||
workflows,
|
workflows,
|
||||||
|
workflow_open_source_submissions: workflowOpenSourceSubmissions,
|
||||||
hidden_workflows: hiddenWorkflows,
|
hidden_workflows: hiddenWorkflows,
|
||||||
workflow_shares_by_user: workflowSharesByUser,
|
workflow_shares_by_user: workflowSharesByUser,
|
||||||
workflow_shares_with_user: workflowSharesWithUser,
|
workflow_shares_with_user: workflowSharesWithUser,
|
||||||
|
|
|
||||||
113
backend/src/lib/userLookup.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
type Db = SupabaseClient<any, "public", any>;
|
||||||
|
|
||||||
|
export type ProfileUserInfo = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
display_name: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeEmail(value: unknown) {
|
||||||
|
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDisplayName(value: unknown) {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadProfileUsersByEmail(db: Db) {
|
||||||
|
const { data, error } = await db
|
||||||
|
.from("user_profiles")
|
||||||
|
.select("user_id, email, display_name")
|
||||||
|
.not("email", "is", null);
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
const userByEmail = new Map<string, ProfileUserInfo>();
|
||||||
|
const userById = new Map<string, ProfileUserInfo>();
|
||||||
|
for (const row of data ?? []) {
|
||||||
|
const email = normalizeEmail(row.email);
|
||||||
|
if (!email) continue;
|
||||||
|
const info = {
|
||||||
|
id: row.user_id as string,
|
||||||
|
email,
|
||||||
|
display_name: normalizeDisplayName(row.display_name),
|
||||||
|
};
|
||||||
|
userByEmail.set(email, info);
|
||||||
|
userById.set(info.id, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { userByEmail, userById };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findProfileUserByEmail(db: Db, email: string) {
|
||||||
|
const normalized = normalizeEmail(email);
|
||||||
|
if (!normalized) return null;
|
||||||
|
|
||||||
|
const { data, error } = await db
|
||||||
|
.from("user_profiles")
|
||||||
|
.select("user_id, email, display_name")
|
||||||
|
.eq("email", normalized)
|
||||||
|
.maybeSingle();
|
||||||
|
if (error) throw error;
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: data.user_id as string,
|
||||||
|
email: normalized,
|
||||||
|
display_name: normalizeDisplayName(data.display_name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findMissingUserEmails(db: Db, emails: string[]) {
|
||||||
|
const normalizedEmails = [...new Set(emails.map(normalizeEmail).filter(Boolean))];
|
||||||
|
if (normalizedEmails.length === 0) return [];
|
||||||
|
|
||||||
|
const { data, error } = await db
|
||||||
|
.from("user_profiles")
|
||||||
|
.select("email")
|
||||||
|
.in("email", normalizedEmails);
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
const found = new Set(
|
||||||
|
(data ?? [])
|
||||||
|
.map((row) => normalizeEmail(row.email))
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
return normalizedEmails.filter((email) => !found.has(email));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncProfileEmail(
|
||||||
|
db: Db,
|
||||||
|
userId: string,
|
||||||
|
email: string | null | undefined,
|
||||||
|
) {
|
||||||
|
const normalizedEmail = normalizeEmail(email);
|
||||||
|
if (!userId || !normalizedEmail) return null;
|
||||||
|
|
||||||
|
const { data: existing, error: loadError } = await db
|
||||||
|
.from("user_profiles")
|
||||||
|
.select("email")
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.maybeSingle();
|
||||||
|
if (loadError) return loadError;
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
const { error } = await db.from("user_profiles").insert({
|
||||||
|
user_id: userId,
|
||||||
|
email: normalizedEmail,
|
||||||
|
});
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizeEmail(existing.email) === normalizedEmail) return null;
|
||||||
|
|
||||||
|
const { error } = await db
|
||||||
|
.from("user_profiles")
|
||||||
|
.update({
|
||||||
|
email: normalizedEmail,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq("user_id", userId);
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
||||||
|
import { syncProfileEmail } from "../lib/userLookup";
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV !== "production";
|
const isDev = process.env.NODE_ENV !== "production";
|
||||||
const devLog = (...args: Parameters<typeof console.log>) => {
|
const devLog = (...args: Parameters<typeof console.log>) => {
|
||||||
|
|
@ -118,6 +119,19 @@ export async function requireAuth(
|
||||||
res.locals.userId = data.user.id;
|
res.locals.userId = data.user.id;
|
||||||
res.locals.userEmail = data.user.email?.toLowerCase() ?? "";
|
res.locals.userEmail = data.user.email?.toLowerCase() ?? "";
|
||||||
res.locals.token = token;
|
res.locals.token = token;
|
||||||
|
const syncError = await syncProfileEmail(
|
||||||
|
admin,
|
||||||
|
data.user.id,
|
||||||
|
data.user.email,
|
||||||
|
);
|
||||||
|
if (syncError) {
|
||||||
|
devLog("[auth/profile-email] sync failed", {
|
||||||
|
method: req.method,
|
||||||
|
path: req.originalUrl,
|
||||||
|
userId: data.user.id,
|
||||||
|
error: syncError.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (!(await enforceLoginMfaIfEnabled(req, res, admin, token))) {
|
if (!(await enforceLoginMfaIfEnabled(req, res, admin, token))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,17 @@ import {
|
||||||
buildMessages,
|
buildMessages,
|
||||||
enrichWithPriorEvents,
|
enrichWithPriorEvents,
|
||||||
buildWorkflowStore,
|
buildWorkflowStore,
|
||||||
|
appendAskInputsResponseToLastAssistantMessage,
|
||||||
|
appendAssistantEventsToLastAssistantMessage,
|
||||||
AssistantStreamError,
|
AssistantStreamError,
|
||||||
buildCancelledAssistantMessage,
|
buildCancelledAssistantMessage,
|
||||||
extractAnnotations,
|
extractCitations,
|
||||||
isAbortError,
|
isAbortError,
|
||||||
runLLMStream,
|
runLLMStream,
|
||||||
stripTransientAssistantEvents,
|
stripTransientAssistantEvents,
|
||||||
|
parseAskInputsResponsePayload,
|
||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
} from "../lib/chatTools";
|
} from "../lib/chat";
|
||||||
import { completeText } from "../lib/llm";
|
import { completeText } from "../lib/llm";
|
||||||
import {
|
import {
|
||||||
getUserModelSettings,
|
getUserModelSettings,
|
||||||
|
|
@ -225,8 +228,6 @@ chatRouter.get("/:chatId", requireAuth, async (req, res) => {
|
||||||
// produced the edit (always "pending"). If the user later accepts or rejects,
|
// produced the edit (always "pending"). If the user later accepts or rejects,
|
||||||
// `document_edits.status` is updated but the stored event is not. On chat load
|
// `document_edits.status` is updated but the stored event is not. On chat load
|
||||||
// we merge the current DB status in so EditCards render with the real state.
|
// we merge the current DB status in so EditCards render with the real state.
|
||||||
// Legacy rows may also have duplicate edit_data in top-level annotations, so
|
|
||||||
// keep patching that path until old data no longer matters.
|
|
||||||
async function hydrateEditStatuses(
|
async function hydrateEditStatuses(
|
||||||
messages: Record<string, unknown>[],
|
messages: Record<string, unknown>[],
|
||||||
db: ReturnType<typeof createServerSupabase>,
|
db: ReturnType<typeof createServerSupabase>,
|
||||||
|
|
@ -242,7 +243,6 @@ async function hydrateEditStatuses(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
for (const m of messages) {
|
for (const m of messages) {
|
||||||
collectFromAnnList(m.annotations);
|
|
||||||
const content = m.content;
|
const content = m.content;
|
||||||
if (Array.isArray(content)) {
|
if (Array.isArray(content)) {
|
||||||
for (const ev of content as Record<string, unknown>[]) {
|
for (const ev of content as Record<string, unknown>[]) {
|
||||||
|
|
@ -312,7 +312,6 @@ async function hydrateEditStatuses(
|
||||||
};
|
};
|
||||||
return messages.map((m) => {
|
return messages.map((m) => {
|
||||||
const next: Record<string, unknown> = { ...m };
|
const next: Record<string, unknown> = { ...m };
|
||||||
next.annotations = patchAnnList(m.annotations);
|
|
||||||
if (Array.isArray(m.content)) {
|
if (Array.isArray(m.content)) {
|
||||||
next.content = (m.content as Record<string, unknown>[]).map(
|
next.content = (m.content as Record<string, unknown>[]).map(
|
||||||
(ev) => {
|
(ev) => {
|
||||||
|
|
@ -439,6 +438,9 @@ chatRouter.post("/", requireAuth, async (req, res) => {
|
||||||
if (!parsedModel.ok) {
|
if (!parsedModel.ok) {
|
||||||
return void res.status(400).json({ detail: parsedModel.detail });
|
return void res.status(400).json({ detail: parsedModel.detail });
|
||||||
}
|
}
|
||||||
|
const askInputsResponse = parseAskInputsResponsePayload(
|
||||||
|
body.ask_inputs_response,
|
||||||
|
);
|
||||||
|
|
||||||
const messages = parsedMessages.messages;
|
const messages = parsedMessages.messages;
|
||||||
const chat_id = parsedChatId.chatId;
|
const chat_id = parsedChatId.chatId;
|
||||||
|
|
@ -509,7 +511,13 @@ chatRouter.post("/", requireAuth, async (req, res) => {
|
||||||
devLog("[chat/stream] resolved chatId", chatId);
|
devLog("[chat/stream] resolved chatId", chatId);
|
||||||
|
|
||||||
const lastUser = [...messages].reverse().find((m) => m.role === "user");
|
const lastUser = [...messages].reverse().find((m) => m.role === "user");
|
||||||
if (lastUser) {
|
if (askInputsResponse) {
|
||||||
|
await appendAskInputsResponseToLastAssistantMessage(
|
||||||
|
db,
|
||||||
|
chatId,
|
||||||
|
askInputsResponse,
|
||||||
|
);
|
||||||
|
} else if (lastUser) {
|
||||||
await db.from("chat_messages").insert({
|
await db.from("chat_messages").insert({
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
role: "user",
|
role: "user",
|
||||||
|
|
@ -571,7 +579,7 @@ chatRouter.post("/", requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`);
|
write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`);
|
||||||
|
|
||||||
const { fullText, events, annotations } = await runLLMStream({
|
const { fullText, events, citations } = await runLLMStream({
|
||||||
apiMessages,
|
apiMessages,
|
||||||
docStore,
|
docStore,
|
||||||
docIndex,
|
docIndex,
|
||||||
|
|
@ -592,12 +600,21 @@ chatRouter.post("/", requireAuth, async (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const persistedEvents = stripTransientAssistantEvents(events);
|
const persistedEvents = stripTransientAssistantEvents(events);
|
||||||
await db.from("chat_messages").insert({
|
if (askInputsResponse) {
|
||||||
chat_id: chatId,
|
await appendAssistantEventsToLastAssistantMessage(
|
||||||
role: "assistant",
|
db,
|
||||||
content: persistedEvents.length ? persistedEvents : null,
|
chatId,
|
||||||
annotations: annotations.length ? annotations : null,
|
persistedEvents,
|
||||||
});
|
citations,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await db.from("chat_messages").insert({
|
||||||
|
chat_id: chatId,
|
||||||
|
role: "assistant",
|
||||||
|
content: persistedEvents.length ? persistedEvents : null,
|
||||||
|
citations: citations.length ? citations : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!chatTitle && lastUser?.content) {
|
if (!chatTitle && lastUser?.content) {
|
||||||
await db
|
await db
|
||||||
|
|
@ -612,17 +629,31 @@ chatRouter.post("/", requireAuth, async (req, res) => {
|
||||||
const partial = buildCancelledAssistantMessage({
|
const partial = buildCancelledAssistantMessage({
|
||||||
fullText: err.fullText,
|
fullText: err.fullText,
|
||||||
events: err.events,
|
events: err.events,
|
||||||
buildAnnotations: (fullText, events) =>
|
buildCitations: (fullText, events) =>
|
||||||
extractAnnotations(fullText, docIndex, events),
|
extractCitations(fullText, docIndex, events),
|
||||||
});
|
|
||||||
const { error: saveError } = await db.from("chat_messages").insert({
|
|
||||||
chat_id: chatId,
|
|
||||||
role: "assistant",
|
|
||||||
content: partial.events.length ? partial.events : null,
|
|
||||||
annotations: partial.annotations.length
|
|
||||||
? partial.annotations
|
|
||||||
: null,
|
|
||||||
});
|
});
|
||||||
|
const saveError = askInputsResponse
|
||||||
|
? null
|
||||||
|
: (
|
||||||
|
await db.from("chat_messages").insert({
|
||||||
|
chat_id: chatId,
|
||||||
|
role: "assistant",
|
||||||
|
content: partial.events.length
|
||||||
|
? partial.events
|
||||||
|
: null,
|
||||||
|
citations: partial.citations.length
|
||||||
|
? partial.citations
|
||||||
|
: null,
|
||||||
|
})
|
||||||
|
).error;
|
||||||
|
if (askInputsResponse) {
|
||||||
|
await appendAssistantEventsToLastAssistantMessage(
|
||||||
|
db,
|
||||||
|
chatId,
|
||||||
|
partial.events,
|
||||||
|
partial.citations,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (saveError) {
|
if (saveError) {
|
||||||
console.error(
|
console.error(
|
||||||
"[chat/stream] failed to save aborted stream",
|
"[chat/stream] failed to save aborted stream",
|
||||||
|
|
@ -640,17 +671,29 @@ chatRouter.post("/", requireAuth, async (req, res) => {
|
||||||
const errorFullText =
|
const errorFullText =
|
||||||
err instanceof AssistantStreamError ? err.fullText : "";
|
err instanceof AssistantStreamError ? err.fullText : "";
|
||||||
try {
|
try {
|
||||||
const annotations = extractAnnotations(
|
const citations = extractCitations(
|
||||||
errorFullText,
|
errorFullText,
|
||||||
docIndex,
|
docIndex,
|
||||||
errorEvents,
|
errorEvents,
|
||||||
);
|
);
|
||||||
const { error: saveError } = await db.from("chat_messages").insert({
|
const saveError = askInputsResponse
|
||||||
chat_id: chatId,
|
? null
|
||||||
role: "assistant",
|
: (
|
||||||
content: errorEvents.length ? errorEvents : null,
|
await db.from("chat_messages").insert({
|
||||||
annotations: annotations.length ? annotations : null,
|
chat_id: chatId,
|
||||||
});
|
role: "assistant",
|
||||||
|
content: errorEvents.length ? errorEvents : null,
|
||||||
|
citations: citations.length ? citations : null,
|
||||||
|
})
|
||||||
|
).error;
|
||||||
|
if (askInputsResponse) {
|
||||||
|
await appendAssistantEventsToLastAssistantMessage(
|
||||||
|
db,
|
||||||
|
chatId,
|
||||||
|
errorEvents,
|
||||||
|
citations,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (saveError)
|
if (saveError)
|
||||||
console.error("[chat/stream] failed to save error", saveError);
|
console.error("[chat/stream] failed to save error", saveError);
|
||||||
} catch (saveErr) {
|
} catch (saveErr) {
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,14 @@ import {
|
||||||
} from "../lib/documentVersions";
|
} from "../lib/documentVersions";
|
||||||
import { ensureDocAccess } from "../lib/access";
|
import { ensureDocAccess } from "../lib/access";
|
||||||
import { singleFileUpload } from "../lib/upload";
|
import { singleFileUpload } from "../lib/upload";
|
||||||
|
import {
|
||||||
|
ALLOWED_DOCUMENT_TYPES,
|
||||||
|
ALLOWED_DOCUMENT_TYPES_LABEL,
|
||||||
|
contentTypeForDocumentType,
|
||||||
|
shouldConvertToPdf,
|
||||||
|
} from "../lib/documentTypes";
|
||||||
|
|
||||||
export const documentsRouter = Router();
|
export const documentsRouter = Router();
|
||||||
const ALLOWED_TYPES = new Set(["pdf", "docx", "doc"]);
|
|
||||||
const isDev = process.env.NODE_ENV !== "production";
|
const isDev = process.env.NODE_ENV !== "production";
|
||||||
const devLog = (...args: Parameters<typeof console.log>) => {
|
const devLog = (...args: Parameters<typeof console.log>) => {
|
||||||
if (isDev) console.log(...args);
|
if (isDev) console.log(...args);
|
||||||
|
|
@ -60,6 +65,7 @@ documentsRouter.get("/", requireAuth, async (req, res) => {
|
||||||
.select("*")
|
.select("*")
|
||||||
.eq("user_id", userId)
|
.eq("user_id", userId)
|
||||||
.is("project_id", null)
|
.is("project_id", null)
|
||||||
|
.or("library_kind.eq.file,library_kind.is.null")
|
||||||
.order("created_at", { ascending: false });
|
.order("created_at", { ascending: false });
|
||||||
if (error) return void res.status(500).json({ detail: error.message });
|
if (error) return void res.status(500).json({ detail: error.message });
|
||||||
const docs = (data ?? []) as unknown as {
|
const docs = (data ?? []) as unknown as {
|
||||||
|
|
@ -79,7 +85,9 @@ documentsRouter.post(
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const userId = res.locals.userId as string;
|
const userId = res.locals.userId as string;
|
||||||
const db = createServerSupabase();
|
const db = createServerSupabase();
|
||||||
await handleDocumentUpload(req, res, userId, null, db);
|
await handleDocumentUpload(req, res, userId, null, db, {
|
||||||
|
libraryKind: "file",
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -129,16 +137,16 @@ documentsRouter.get("/:documentId/display", requireAuth, async (req, res) => {
|
||||||
return void res.status(404).json({ detail: "No file available" });
|
return void res.status(404).json({ detail: "No file available" });
|
||||||
|
|
||||||
const fileType = active.file_type ?? "";
|
const fileType = active.file_type ?? "";
|
||||||
const isDocx = fileType === "docx" || fileType === "doc";
|
const isConvertibleOffice = shouldConvertToPdf(fileType);
|
||||||
const displayFilename = downloadFilenameForVersion(
|
const displayFilename = downloadFilenameForVersion(
|
||||||
active.filename,
|
active.filename,
|
||||||
active.version_number,
|
active.version_number,
|
||||||
active.source === "assistant_edit",
|
active.source === "assistant_edit",
|
||||||
);
|
);
|
||||||
|
|
||||||
// For DOCX, prefer the per-version PDF rendition if one exists.
|
// For Office files, prefer the per-version PDF rendition if one exists.
|
||||||
const servePath =
|
const servePath =
|
||||||
isDocx && active.pdf_storage_path
|
isConvertibleOffice && active.pdf_storage_path
|
||||||
? active.pdf_storage_path
|
? active.pdf_storage_path
|
||||||
: active.storage_path;
|
: active.storage_path;
|
||||||
const raw = await downloadFile(servePath);
|
const raw = await downloadFile(servePath);
|
||||||
|
|
@ -147,7 +155,7 @@ documentsRouter.get("/:documentId/display", requireAuth, async (req, res) => {
|
||||||
.status(404)
|
.status(404)
|
||||||
.json({ detail: "Document not found in storage" });
|
.json({ detail: "Document not found in storage" });
|
||||||
|
|
||||||
if (fileType === "pdf" || (isDocx && active.pdf_storage_path)) {
|
if (fileType === "pdf" || (isConvertibleOffice && active.pdf_storage_path)) {
|
||||||
res.setHeader("Content-Type", "application/pdf");
|
res.setHeader("Content-Type", "application/pdf");
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
"Content-Disposition",
|
"Content-Disposition",
|
||||||
|
|
@ -155,11 +163,8 @@ documentsRouter.get("/:documentId/display", requireAuth, async (req, res) => {
|
||||||
);
|
);
|
||||||
res.send(Buffer.from(raw));
|
res.send(Buffer.from(raw));
|
||||||
} else {
|
} else {
|
||||||
// Fallback: serve raw DOCX (mammoth will handle it client-side)
|
// Fallback: serve raw Office bytes when PDF conversion was unavailable.
|
||||||
res.setHeader(
|
res.setHeader("Content-Type", contentTypeForDocumentType(fileType));
|
||||||
"Content-Type",
|
|
||||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
||||||
);
|
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
"Content-Disposition",
|
"Content-Disposition",
|
||||||
buildContentDisposition("inline", displayFilename),
|
buildContentDisposition("inline", displayFilename),
|
||||||
|
|
@ -424,9 +429,13 @@ documentsRouter.post(
|
||||||
if (!sourceAccess.ok)
|
if (!sourceAccess.ok)
|
||||||
return void res.status(404).json({ detail: "Source document not found" });
|
return void res.status(404).json({ detail: "Source document not found" });
|
||||||
const willDeleteSource =
|
const willDeleteSource =
|
||||||
sourceDoc.project_id &&
|
(sourceDoc.project_id &&
|
||||||
targetDoc.project_id &&
|
targetDoc.project_id &&
|
||||||
sourceDoc.project_id === targetDoc.project_id;
|
sourceDoc.project_id === targetDoc.project_id) ||
|
||||||
|
(!sourceDoc.project_id &&
|
||||||
|
!targetDoc.project_id &&
|
||||||
|
sourceDoc.user_id === userId &&
|
||||||
|
targetDoc.user_id === userId);
|
||||||
if (willDeleteSource && !sourceAccess.isOwner) {
|
if (willDeleteSource && !sourceAccess.isOwner) {
|
||||||
return void res.status(403).json({
|
return void res.status(403).json({
|
||||||
detail: "Only the source document owner can move it into a version.",
|
detail: "Only the source document owner can move it into a version.",
|
||||||
|
|
@ -455,10 +464,7 @@ documentsRouter.post(
|
||||||
(filename.includes(".") ? filename.split(".").pop()!.toLowerCase() : "");
|
(filename.includes(".") ? filename.split(".").pop()!.toLowerCase() : "");
|
||||||
const versionSlug = crypto.randomUUID().replace(/-/g, "");
|
const versionSlug = crypto.randomUUID().replace(/-/g, "");
|
||||||
const key = versionStorageKey(userId, documentId, versionSlug, filename);
|
const key = versionStorageKey(userId, documentId, versionSlug, filename);
|
||||||
const contentType =
|
const contentType = contentTypeForDocumentType(suffix);
|
||||||
suffix === "pdf"
|
|
||||||
? "application/pdf"
|
|
||||||
: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await uploadFile(key, bytes, contentType);
|
await uploadFile(key, bytes, contentType);
|
||||||
|
|
@ -483,7 +489,7 @@ documentsRouter.post(
|
||||||
pdfStoragePath = pdfKey;
|
pdfStoragePath = pdfKey;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (suffix === "docx" || suffix === "doc") {
|
} else if (shouldConvertToPdf(suffix)) {
|
||||||
try {
|
try {
|
||||||
const pdfBuf = await docxToPdf(Buffer.from(bytes));
|
const pdfBuf = await docxToPdf(Buffer.from(bytes));
|
||||||
const pdfKey = `converted-pdfs/${userId}/${documentId}/${versionSlug}.pdf`;
|
const pdfKey = `converted-pdfs/${userId}/${documentId}/${versionSlug}.pdf`;
|
||||||
|
|
@ -498,7 +504,7 @@ documentsRouter.post(
|
||||||
pdfStoragePath = pdfKey;
|
pdfStoragePath = pdfKey;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
`[versions/copy] DOCX→PDF conversion failed for ${filename}:`,
|
`[versions/copy] Office→PDF conversion failed for ${filename}:`,
|
||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -599,9 +605,9 @@ documentsRouter.post(
|
||||||
const suffix = file.originalname.includes(".")
|
const suffix = file.originalname.includes(".")
|
||||||
? file.originalname.split(".").pop()!.toLowerCase()
|
? file.originalname.split(".").pop()!.toLowerCase()
|
||||||
: "";
|
: "";
|
||||||
if (!ALLOWED_TYPES.has(suffix)) {
|
if (!ALLOWED_DOCUMENT_TYPES.has(suffix)) {
|
||||||
return void res.status(400).json({
|
return void res.status(400).json({
|
||||||
detail: `Unsupported file type: ${suffix}. Allowed: pdf, docx, doc`,
|
detail: `Unsupported file type: ${suffix}. Allowed: ${ALLOWED_DOCUMENT_TYPES_LABEL}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -614,10 +620,7 @@ documentsRouter.post(
|
||||||
versionSlug,
|
versionSlug,
|
||||||
file.originalname,
|
file.originalname,
|
||||||
);
|
);
|
||||||
const contentType =
|
const contentType = contentTypeForDocumentType(suffix);
|
||||||
suffix === "pdf"
|
|
||||||
? "application/pdf"
|
|
||||||
: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
|
||||||
try {
|
try {
|
||||||
await uploadFile(
|
await uploadFile(
|
||||||
key,
|
key,
|
||||||
|
|
@ -638,7 +641,7 @@ documentsRouter.post(
|
||||||
// historical versions without on-demand conversion. Same logic as the
|
// historical versions without on-demand conversion. Same logic as the
|
||||||
// initial-upload pipeline; failures don't block the version row.
|
// initial-upload pipeline; failures don't block the version row.
|
||||||
let pdfStoragePath: string | null = null;
|
let pdfStoragePath: string | null = null;
|
||||||
if (suffix === "docx" || suffix === "doc") {
|
if (shouldConvertToPdf(suffix)) {
|
||||||
try {
|
try {
|
||||||
const pdfBuf = await docxToPdf(file.buffer);
|
const pdfBuf = await docxToPdf(file.buffer);
|
||||||
const pdfKey = `converted-pdfs/${userId}/${documentId}/${versionSlug}.pdf`;
|
const pdfKey = `converted-pdfs/${userId}/${documentId}/${versionSlug}.pdf`;
|
||||||
|
|
@ -653,7 +656,7 @@ documentsRouter.post(
|
||||||
pdfStoragePath = pdfKey;
|
pdfStoragePath = pdfKey;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
`[versions/upload] DOCX→PDF conversion failed for ${file.originalname}:`,
|
`[versions/upload] Office→PDF conversion failed for ${file.originalname}:`,
|
||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -814,9 +817,9 @@ documentsRouter.put(
|
||||||
const suffix = file.originalname.includes(".")
|
const suffix = file.originalname.includes(".")
|
||||||
? file.originalname.split(".").pop()!.toLowerCase()
|
? file.originalname.split(".").pop()!.toLowerCase()
|
||||||
: "";
|
: "";
|
||||||
if (!ALLOWED_TYPES.has(suffix)) {
|
if (!ALLOWED_DOCUMENT_TYPES.has(suffix)) {
|
||||||
return void res.status(400).json({
|
return void res.status(400).json({
|
||||||
detail: `Unsupported file type: ${suffix}. Allowed: pdf, docx, doc`,
|
detail: `Unsupported file type: ${suffix}. Allowed: ${ALLOWED_DOCUMENT_TYPES_LABEL}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (target.file_type && target.file_type !== suffix) {
|
if (target.file_type && target.file_type !== suffix) {
|
||||||
|
|
@ -832,10 +835,7 @@ documentsRouter.put(
|
||||||
versionSlug,
|
versionSlug,
|
||||||
file.originalname,
|
file.originalname,
|
||||||
);
|
);
|
||||||
const contentType =
|
const contentType = contentTypeForDocumentType(suffix);
|
||||||
suffix === "pdf"
|
|
||||||
? "application/pdf"
|
|
||||||
: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await uploadFile(
|
await uploadFile(
|
||||||
|
|
@ -854,7 +854,7 @@ documentsRouter.put(
|
||||||
}
|
}
|
||||||
|
|
||||||
let pdfStoragePath: string | null = null;
|
let pdfStoragePath: string | null = null;
|
||||||
if (suffix === "docx" || suffix === "doc") {
|
if (shouldConvertToPdf(suffix)) {
|
||||||
try {
|
try {
|
||||||
const pdfBuf = await docxToPdf(file.buffer);
|
const pdfBuf = await docxToPdf(file.buffer);
|
||||||
const pdfKey = `converted-pdfs/${userId}/${documentId}/${versionSlug}.pdf`;
|
const pdfKey = `converted-pdfs/${userId}/${documentId}/${versionSlug}.pdf`;
|
||||||
|
|
@ -869,7 +869,7 @@ documentsRouter.put(
|
||||||
pdfStoragePath = pdfKey;
|
pdfStoragePath = pdfKey;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
`[versions/replace] DOCX→PDF conversion failed for ${file.originalname}:`,
|
`[versions/replace] Office→PDF conversion failed for ${file.originalname}:`,
|
||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1290,12 +1290,16 @@ documentsRouter.post(
|
||||||
(req, res) => void handleEditResolution(req, res, "reject"),
|
(req, res) => void handleEditResolution(req, res, "reject"),
|
||||||
);
|
);
|
||||||
|
|
||||||
async function handleDocumentUpload(
|
export async function handleDocumentUpload(
|
||||||
req: import("express").Request,
|
req: import("express").Request,
|
||||||
res: import("express").Response,
|
res: import("express").Response,
|
||||||
userId: string,
|
userId: string,
|
||||||
projectId: string | null,
|
projectId: string | null,
|
||||||
db: ReturnType<typeof createServerSupabase>,
|
db: ReturnType<typeof createServerSupabase>,
|
||||||
|
options: {
|
||||||
|
libraryKind?: "file" | "template";
|
||||||
|
libraryFolderId?: string | null;
|
||||||
|
} = {},
|
||||||
) {
|
) {
|
||||||
const file = req.file;
|
const file = req.file;
|
||||||
if (!file) return void res.status(400).json({ detail: "file is required" });
|
if (!file) return void res.status(400).json({ detail: "file is required" });
|
||||||
|
|
@ -1304,11 +1308,11 @@ async function handleDocumentUpload(
|
||||||
const suffix = filename.includes(".")
|
const suffix = filename.includes(".")
|
||||||
? filename.split(".").pop()!.toLowerCase()
|
? filename.split(".").pop()!.toLowerCase()
|
||||||
: "";
|
: "";
|
||||||
if (!ALLOWED_TYPES.has(suffix))
|
if (!ALLOWED_DOCUMENT_TYPES.has(suffix))
|
||||||
return void res
|
return void res
|
||||||
.status(400)
|
.status(400)
|
||||||
.json({
|
.json({
|
||||||
detail: `Unsupported file type: ${suffix}. Allowed: pdf, docx, doc`,
|
detail: `Unsupported file type: ${suffix}. Allowed: ${ALLOWED_DOCUMENT_TYPES_LABEL}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const content = file.buffer;
|
const content = file.buffer;
|
||||||
|
|
@ -1318,6 +1322,8 @@ async function handleDocumentUpload(
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
status: "processing",
|
status: "processing",
|
||||||
|
library_kind: options.libraryKind ?? "file",
|
||||||
|
library_folder_id: options.libraryFolderId ?? null,
|
||||||
})
|
})
|
||||||
.select("*")
|
.select("*")
|
||||||
.single();
|
.single();
|
||||||
|
|
@ -1338,10 +1344,7 @@ async function handleDocumentUpload(
|
||||||
try {
|
try {
|
||||||
const docId = doc.id as string;
|
const docId = doc.id as string;
|
||||||
const key = storageKey(userId, docId, filename);
|
const key = storageKey(userId, docId, filename);
|
||||||
const contentType =
|
const contentType = contentTypeForDocumentType(suffix);
|
||||||
suffix === "pdf"
|
|
||||||
? "application/pdf"
|
|
||||||
: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
|
||||||
await uploadFile(
|
await uploadFile(
|
||||||
key,
|
key,
|
||||||
content.buffer.slice(
|
content.buffer.slice(
|
||||||
|
|
@ -1357,9 +1360,9 @@ async function handleDocumentUpload(
|
||||||
) as ArrayBuffer;
|
) as ArrayBuffer;
|
||||||
const pageCount = suffix === "pdf" ? await countPdfPages(rawBuf) : null;
|
const pageCount = suffix === "pdf" ? await countPdfPages(rawBuf) : null;
|
||||||
|
|
||||||
// Convert DOCX/DOC → PDF for display. PDFs are their own rendition.
|
// Convert Office files → PDF for display. PDFs are their own rendition.
|
||||||
let pdfStoragePath: string | null = null;
|
let pdfStoragePath: string | null = null;
|
||||||
if (suffix === "docx" || suffix === "doc") {
|
if (shouldConvertToPdf(suffix)) {
|
||||||
try {
|
try {
|
||||||
const pdfBuf = await docxToPdf(content);
|
const pdfBuf = await docxToPdf(content);
|
||||||
const pdfKey = convertedPdfKey(userId, docId);
|
const pdfKey = convertedPdfKey(userId, docId);
|
||||||
|
|
@ -1374,7 +1377,7 @@ async function handleDocumentUpload(
|
||||||
pdfStoragePath = pdfKey;
|
pdfStoragePath = pdfKey;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
`[upload] DOCX→PDF conversion failed for ${filename}:`,
|
`[upload] Office→PDF conversion failed for ${filename}:`,
|
||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1427,6 +1430,8 @@ async function handleDocumentUpload(
|
||||||
filename,
|
filename,
|
||||||
storage_path: key,
|
storage_path: key,
|
||||||
pdf_storage_path: pdfStoragePath,
|
pdf_storage_path: pdfStoragePath,
|
||||||
|
folder_id:
|
||||||
|
(updated.library_folder_id as string | null | undefined) ?? null,
|
||||||
file_type: suffix,
|
file_type: suffix,
|
||||||
size_bytes: content.byteLength,
|
size_bytes: content.byteLength,
|
||||||
page_count: pageCount,
|
page_count: pageCount,
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,15 @@ import { createServerSupabase } from "../lib/supabase";
|
||||||
import { buildContentDisposition, downloadFile } from "../lib/storage";
|
import { buildContentDisposition, downloadFile } from "../lib/storage";
|
||||||
import { verifyDownload } from "../lib/downloadTokens";
|
import { verifyDownload } from "../lib/downloadTokens";
|
||||||
import { ensureDocAccess } from "../lib/access";
|
import { ensureDocAccess } from "../lib/access";
|
||||||
|
import { contentTypeForDocumentType } from "../lib/documentTypes";
|
||||||
|
|
||||||
export const downloadsRouter = Router();
|
export const downloadsRouter = Router();
|
||||||
|
|
||||||
function contentTypeFor(filename: string): string {
|
function contentTypeFor(filename: string): string {
|
||||||
const lower = filename.toLowerCase();
|
const suffix = filename.includes(".")
|
||||||
if (lower.endsWith(".docx"))
|
? filename.split(".").pop()?.toLowerCase()
|
||||||
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
: "";
|
||||||
if (lower.endsWith(".pdf")) return "application/pdf";
|
return contentTypeForDocumentType(suffix);
|
||||||
if (lower.endsWith(".xlsx"))
|
|
||||||
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
|
||||||
return "application/octet-stream";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /download/:token
|
// GET /download/:token
|
||||||
|
|
|
||||||
414
backend/src/routes/library.ts
Normal file
|
|
@ -0,0 +1,414 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import { requireAuth } from "../middleware/auth";
|
||||||
|
import { createServerSupabase } from "../lib/supabase";
|
||||||
|
import { deleteFile } from "../lib/storage";
|
||||||
|
import {
|
||||||
|
attachActiveVersionPaths,
|
||||||
|
attachLatestVersionNumbers,
|
||||||
|
} from "../lib/documentVersions";
|
||||||
|
import { singleFileUpload } from "../lib/upload";
|
||||||
|
import { handleDocumentUpload } from "./documents";
|
||||||
|
|
||||||
|
export const libraryRouter = Router();
|
||||||
|
|
||||||
|
type LibraryKind = "file" | "template";
|
||||||
|
|
||||||
|
function normalizeLibraryKind(value: unknown): LibraryKind | null {
|
||||||
|
if (value === "file" || value === "files") return "file";
|
||||||
|
if (value === "template" || value === "templates") return "template";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDocumentFilename(nextName: unknown, currentName: string) {
|
||||||
|
if (typeof nextName !== "string") return null;
|
||||||
|
const trimmed = nextName.trim().slice(0, 200);
|
||||||
|
if (!trimmed) return null;
|
||||||
|
if (/\.[a-z0-9]{1,6}$/i.test(trimmed)) return trimmed;
|
||||||
|
const ext = currentName.match(/\.[a-z0-9]{1,6}$/i)?.[0] ?? "";
|
||||||
|
return `${trimmed}${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapLibraryDocument<T extends Record<string, unknown>>(doc: T) {
|
||||||
|
return {
|
||||||
|
...doc,
|
||||||
|
folder_id: (doc.library_folder_id as string | null | undefined) ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLibraryFolder(
|
||||||
|
db: ReturnType<typeof createServerSupabase>,
|
||||||
|
userId: string,
|
||||||
|
kind: LibraryKind,
|
||||||
|
folderId: string,
|
||||||
|
): Promise<{ id: string; parent_folder_id: string | null } | null> {
|
||||||
|
const { data } = await db
|
||||||
|
.from("library_folders")
|
||||||
|
.select("id, parent_folder_id")
|
||||||
|
.eq("id", folderId)
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.eq("library_kind", kind)
|
||||||
|
.maybeSingle();
|
||||||
|
return (data as { id: string; parent_folder_id: string | null } | null) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteLibraryDocumentsAndVersionFiles(
|
||||||
|
db: ReturnType<typeof createServerSupabase>,
|
||||||
|
userId: string,
|
||||||
|
kind: LibraryKind,
|
||||||
|
documentIds: string[],
|
||||||
|
) {
|
||||||
|
if (documentIds.length === 0) return null;
|
||||||
|
const { data: versions, error: versionsError } = await db
|
||||||
|
.from("document_versions")
|
||||||
|
.select("storage_path, pdf_storage_path")
|
||||||
|
.in("document_id", documentIds);
|
||||||
|
if (versionsError) return versionsError;
|
||||||
|
|
||||||
|
const paths = new Set<string>();
|
||||||
|
for (const version of versions ?? []) {
|
||||||
|
if (typeof version.storage_path === "string" && version.storage_path) {
|
||||||
|
paths.add(version.storage_path);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof version.pdf_storage_path === "string" &&
|
||||||
|
version.pdf_storage_path
|
||||||
|
) {
|
||||||
|
paths.add(version.pdf_storage_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all([...paths].map((path) => deleteFile(path).catch(() => {})));
|
||||||
|
|
||||||
|
let deleteQuery = db
|
||||||
|
.from("documents")
|
||||||
|
.delete()
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.is("project_id", null);
|
||||||
|
deleteQuery =
|
||||||
|
kind === "file"
|
||||||
|
? deleteQuery.or("library_kind.eq.file,library_kind.is.null")
|
||||||
|
: deleteQuery.eq("library_kind", kind);
|
||||||
|
const { error } = await deleteQuery.in("id", documentIds);
|
||||||
|
return error ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /library/:kind
|
||||||
|
libraryRouter.get("/:kind", requireAuth, async (req, res) => {
|
||||||
|
const userId = res.locals.userId as string;
|
||||||
|
const kind = normalizeLibraryKind(req.params.kind);
|
||||||
|
if (!kind) return void res.status(404).json({ detail: "Library not found" });
|
||||||
|
|
||||||
|
const db = createServerSupabase();
|
||||||
|
let documentsQuery = db
|
||||||
|
.from("documents")
|
||||||
|
.select("*")
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.is("project_id", null);
|
||||||
|
documentsQuery =
|
||||||
|
kind === "file"
|
||||||
|
? documentsQuery.or("library_kind.eq.file,library_kind.is.null")
|
||||||
|
: documentsQuery.eq("library_kind", kind);
|
||||||
|
const [{ data: docs, error: docsError }, { data: folders, error: foldersError }] =
|
||||||
|
await Promise.all([
|
||||||
|
documentsQuery.order("created_at", { ascending: true }),
|
||||||
|
db
|
||||||
|
.from("library_folders")
|
||||||
|
.select("*")
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.eq("library_kind", kind)
|
||||||
|
.order("created_at", { ascending: true }),
|
||||||
|
]);
|
||||||
|
if (docsError) return void res.status(500).json({ detail: docsError.message });
|
||||||
|
if (foldersError)
|
||||||
|
return void res.status(500).json({ detail: foldersError.message });
|
||||||
|
|
||||||
|
const docsTyped = (docs ?? []).map(mapLibraryDocument) as {
|
||||||
|
id: string;
|
||||||
|
current_version_id?: string | null;
|
||||||
|
}[];
|
||||||
|
await attachLatestVersionNumbers(db, docsTyped);
|
||||||
|
await attachActiveVersionPaths(db, docsTyped);
|
||||||
|
res.json({ documents: docsTyped, folders: folders ?? [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /library/:kind/documents
|
||||||
|
libraryRouter.post(
|
||||||
|
"/:kind/documents",
|
||||||
|
requireAuth,
|
||||||
|
singleFileUpload("file"),
|
||||||
|
async (req, res) => {
|
||||||
|
const userId = res.locals.userId as string;
|
||||||
|
const kind = normalizeLibraryKind(req.params.kind);
|
||||||
|
if (!kind) return void res.status(404).json({ detail: "Library not found" });
|
||||||
|
const db = createServerSupabase();
|
||||||
|
await handleDocumentUpload(req, res, userId, null, db, {
|
||||||
|
libraryKind: kind,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /library/:kind/folders
|
||||||
|
libraryRouter.post("/:kind/folders", requireAuth, async (req, res) => {
|
||||||
|
const userId = res.locals.userId as string;
|
||||||
|
const kind = normalizeLibraryKind(req.params.kind);
|
||||||
|
if (!kind) return void res.status(404).json({ detail: "Library not found" });
|
||||||
|
|
||||||
|
const { name, parent_folder_id } = req.body as {
|
||||||
|
name?: string;
|
||||||
|
parent_folder_id?: string | null;
|
||||||
|
};
|
||||||
|
if (!name?.trim())
|
||||||
|
return void res.status(400).json({ detail: "name is required" });
|
||||||
|
|
||||||
|
const db = createServerSupabase();
|
||||||
|
if (parent_folder_id) {
|
||||||
|
const parent = await loadLibraryFolder(db, userId, kind, parent_folder_id);
|
||||||
|
if (!parent)
|
||||||
|
return void res.status(404).json({ detail: "Parent folder not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await db
|
||||||
|
.from("library_folders")
|
||||||
|
.insert({
|
||||||
|
user_id: userId,
|
||||||
|
library_kind: kind,
|
||||||
|
name: name.trim(),
|
||||||
|
parent_folder_id: parent_folder_id ?? null,
|
||||||
|
})
|
||||||
|
.select("*")
|
||||||
|
.single();
|
||||||
|
if (error) return void res.status(500).json({ detail: error.message });
|
||||||
|
res.status(201).json(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /library/:kind/folders/:folderId
|
||||||
|
libraryRouter.patch("/:kind/folders/:folderId", requireAuth, async (req, res) => {
|
||||||
|
const userId = res.locals.userId as string;
|
||||||
|
const kind = normalizeLibraryKind(req.params.kind);
|
||||||
|
if (!kind) return void res.status(404).json({ detail: "Library not found" });
|
||||||
|
|
||||||
|
const { folderId } = req.params;
|
||||||
|
const body = req.body as { name?: string; parent_folder_id?: string | null };
|
||||||
|
const db = createServerSupabase();
|
||||||
|
const folder = await loadLibraryFolder(db, userId, kind, folderId);
|
||||||
|
if (!folder) return void res.status(404).json({ detail: "Folder not found" });
|
||||||
|
|
||||||
|
const updates: Record<string, unknown> = {
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
if (body.name != null) {
|
||||||
|
const trimmed = body.name.trim();
|
||||||
|
if (!trimmed)
|
||||||
|
return void res.status(400).json({ detail: "name is required" });
|
||||||
|
updates.name = trimmed;
|
||||||
|
}
|
||||||
|
if ("parent_folder_id" in body) {
|
||||||
|
if (body.parent_folder_id) {
|
||||||
|
let cur: string | null = body.parent_folder_id;
|
||||||
|
while (cur) {
|
||||||
|
if (cur === folderId) {
|
||||||
|
return void res.status(400).json({
|
||||||
|
detail: "Cannot move a folder into itself or a descendant",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const parent = await loadLibraryFolder(db, userId, kind, cur);
|
||||||
|
if (!parent)
|
||||||
|
return void res.status(404).json({ detail: "Parent folder not found" });
|
||||||
|
cur = parent.parent_folder_id ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updates.parent_folder_id = body.parent_folder_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await db
|
||||||
|
.from("library_folders")
|
||||||
|
.update(updates)
|
||||||
|
.eq("id", folderId)
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.eq("library_kind", kind)
|
||||||
|
.select("*")
|
||||||
|
.single();
|
||||||
|
if (error || !data)
|
||||||
|
return void res.status(404).json({ detail: "Folder not found" });
|
||||||
|
res.json(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /library/:kind/folders/:folderId
|
||||||
|
libraryRouter.delete("/:kind/folders/:folderId", requireAuth, async (req, res) => {
|
||||||
|
const userId = res.locals.userId as string;
|
||||||
|
const kind = normalizeLibraryKind(req.params.kind);
|
||||||
|
if (!kind) return void res.status(404).json({ detail: "Library not found" });
|
||||||
|
|
||||||
|
const { folderId } = req.params;
|
||||||
|
const db = createServerSupabase();
|
||||||
|
const { data: allFolders, error: foldersError } = await db
|
||||||
|
.from("library_folders")
|
||||||
|
.select("id, parent_folder_id")
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.eq("library_kind", kind);
|
||||||
|
if (foldersError)
|
||||||
|
return void res.status(500).json({ detail: foldersError.message });
|
||||||
|
if (!(allFolders ?? []).some((folder) => folder.id === folderId)) {
|
||||||
|
return void res.status(404).json({ detail: "Folder not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const childrenByParent = new Map<string, string[]>();
|
||||||
|
for (const folder of allFolders ?? []) {
|
||||||
|
const parentId = folder.parent_folder_id as string | null;
|
||||||
|
if (!parentId) continue;
|
||||||
|
const children = childrenByParent.get(parentId) ?? [];
|
||||||
|
children.push(folder.id as string);
|
||||||
|
childrenByParent.set(parentId, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderIds = new Set<string>();
|
||||||
|
const stack = [folderId];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const id = stack.pop()!;
|
||||||
|
if (folderIds.has(id)) continue;
|
||||||
|
folderIds.add(id);
|
||||||
|
stack.push(...(childrenByParent.get(id) ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
|
let documentsInFolderQuery = db
|
||||||
|
.from("documents")
|
||||||
|
.select("id")
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.is("project_id", null);
|
||||||
|
documentsInFolderQuery =
|
||||||
|
kind === "file"
|
||||||
|
? documentsInFolderQuery.or("library_kind.eq.file,library_kind.is.null")
|
||||||
|
: documentsInFolderQuery.eq("library_kind", kind);
|
||||||
|
const { data: docs, error: docsError } = await documentsInFolderQuery.in(
|
||||||
|
"library_folder_id",
|
||||||
|
[...folderIds],
|
||||||
|
);
|
||||||
|
if (docsError) return void res.status(500).json({ detail: docsError.message });
|
||||||
|
|
||||||
|
const docIds = (docs ?? []).map((doc) => doc.id as string);
|
||||||
|
const deleteDocsError = await deleteLibraryDocumentsAndVersionFiles(
|
||||||
|
db,
|
||||||
|
userId,
|
||||||
|
kind,
|
||||||
|
docIds,
|
||||||
|
);
|
||||||
|
if (deleteDocsError)
|
||||||
|
return void res.status(500).json({ detail: deleteDocsError.message });
|
||||||
|
|
||||||
|
const { error } = await db
|
||||||
|
.from("library_folders")
|
||||||
|
.delete()
|
||||||
|
.eq("id", folderId)
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.eq("library_kind", kind);
|
||||||
|
if (error) return void res.status(500).json({ detail: error.message });
|
||||||
|
res.status(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /library/:kind/documents/:documentId/folder
|
||||||
|
libraryRouter.patch(
|
||||||
|
"/:kind/documents/:documentId/folder",
|
||||||
|
requireAuth,
|
||||||
|
async (req, res) => {
|
||||||
|
const userId = res.locals.userId as string;
|
||||||
|
const kind = normalizeLibraryKind(req.params.kind);
|
||||||
|
if (!kind) return void res.status(404).json({ detail: "Library not found" });
|
||||||
|
|
||||||
|
const { documentId } = req.params;
|
||||||
|
const { folder_id } = req.body as { folder_id: string | null };
|
||||||
|
const db = createServerSupabase();
|
||||||
|
|
||||||
|
if (folder_id) {
|
||||||
|
const folder = await loadLibraryFolder(db, userId, kind, folder_id);
|
||||||
|
if (!folder)
|
||||||
|
return void res.status(404).json({ detail: "Folder not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let moveQuery = db
|
||||||
|
.from("documents")
|
||||||
|
.update({
|
||||||
|
library_folder_id: folder_id ?? null,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq("id", documentId)
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.is("project_id", null);
|
||||||
|
moveQuery =
|
||||||
|
kind === "file"
|
||||||
|
? moveQuery.or("library_kind.eq.file,library_kind.is.null")
|
||||||
|
: moveQuery.eq("library_kind", kind);
|
||||||
|
const { data, error } = await moveQuery
|
||||||
|
.select("*")
|
||||||
|
.single();
|
||||||
|
if (error || !data)
|
||||||
|
return void res.status(404).json({ detail: "Document not found" });
|
||||||
|
res.json(mapLibraryDocument(data));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// PATCH /library/:kind/documents/:documentId
|
||||||
|
libraryRouter.patch(
|
||||||
|
"/:kind/documents/:documentId",
|
||||||
|
requireAuth,
|
||||||
|
async (req, res) => {
|
||||||
|
const userId = res.locals.userId as string;
|
||||||
|
const kind = normalizeLibraryKind(req.params.kind);
|
||||||
|
if (!kind) return void res.status(404).json({ detail: "Library not found" });
|
||||||
|
|
||||||
|
const { documentId } = req.params;
|
||||||
|
const db = createServerSupabase();
|
||||||
|
let docQuery = db
|
||||||
|
.from("documents")
|
||||||
|
.select("id, current_version_id")
|
||||||
|
.eq("id", documentId)
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.is("project_id", null);
|
||||||
|
docQuery =
|
||||||
|
kind === "file"
|
||||||
|
? docQuery.or("library_kind.eq.file,library_kind.is.null")
|
||||||
|
: docQuery.eq("library_kind", kind);
|
||||||
|
const { data: doc } = await docQuery.single();
|
||||||
|
if (!doc) return void res.status(404).json({ detail: "Document not found" });
|
||||||
|
|
||||||
|
const active = doc.current_version_id
|
||||||
|
? await db
|
||||||
|
.from("document_versions")
|
||||||
|
.select("filename")
|
||||||
|
.eq("id", doc.current_version_id)
|
||||||
|
.eq("document_id", documentId)
|
||||||
|
.single()
|
||||||
|
: null;
|
||||||
|
const currentName =
|
||||||
|
typeof active?.data?.filename === "string" && active.data.filename.trim()
|
||||||
|
? active.data.filename.trim()
|
||||||
|
: "Untitled document";
|
||||||
|
const filename = normalizeDocumentFilename(req.body?.filename, currentName);
|
||||||
|
if (!filename)
|
||||||
|
return void res.status(400).json({ detail: "filename is required" });
|
||||||
|
|
||||||
|
let updateQuery = db
|
||||||
|
.from("documents")
|
||||||
|
.update({ updated_at: new Date().toISOString() })
|
||||||
|
.eq("id", documentId)
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.is("project_id", null);
|
||||||
|
updateQuery =
|
||||||
|
kind === "file"
|
||||||
|
? updateQuery.or("library_kind.eq.file,library_kind.is.null")
|
||||||
|
: updateQuery.eq("library_kind", kind);
|
||||||
|
const { data: updated, error } = await updateQuery
|
||||||
|
.select("*")
|
||||||
|
.single();
|
||||||
|
if (error || !updated)
|
||||||
|
return void res.status(404).json({ detail: "Document not found" });
|
||||||
|
|
||||||
|
if (doc.current_version_id) {
|
||||||
|
await db
|
||||||
|
.from("document_versions")
|
||||||
|
.update({ filename })
|
||||||
|
.eq("id", doc.current_version_id)
|
||||||
|
.eq("document_id", documentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(mapLibraryDocument({ ...updated, filename }));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -6,15 +6,18 @@ import {
|
||||||
buildMessages,
|
buildMessages,
|
||||||
buildWorkflowStore,
|
buildWorkflowStore,
|
||||||
enrichWithPriorEvents,
|
enrichWithPriorEvents,
|
||||||
|
appendAskInputsResponseToLastAssistantMessage,
|
||||||
|
appendAssistantEventsToLastAssistantMessage,
|
||||||
AssistantStreamError,
|
AssistantStreamError,
|
||||||
buildCancelledAssistantMessage,
|
buildCancelledAssistantMessage,
|
||||||
extractAnnotations,
|
extractCitations,
|
||||||
isAbortError,
|
isAbortError,
|
||||||
runLLMStream,
|
runLLMStream,
|
||||||
stripTransientAssistantEvents,
|
stripTransientAssistantEvents,
|
||||||
PROJECT_EXTRA_TOOLS,
|
PROJECT_EXTRA_TOOLS,
|
||||||
|
parseAskInputsResponsePayload,
|
||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
} from "../lib/chatTools";
|
} from "../lib/chat";
|
||||||
import {
|
import {
|
||||||
getUserModelSettings,
|
getUserModelSettings,
|
||||||
} from "../lib/userSettings";
|
} from "../lib/userSettings";
|
||||||
|
|
@ -36,14 +39,25 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
|
||||||
const userId = res.locals.userId as string;
|
const userId = res.locals.userId as string;
|
||||||
const userEmail = res.locals.userEmail as string | undefined;
|
const userEmail = res.locals.userEmail as string | undefined;
|
||||||
const { projectId } = req.params;
|
const { projectId } = req.params;
|
||||||
const { messages, chat_id, model, displayed_doc, attached_documents } =
|
const {
|
||||||
|
messages,
|
||||||
|
chat_id,
|
||||||
|
model,
|
||||||
|
displayed_doc,
|
||||||
|
attached_documents,
|
||||||
|
ask_inputs_response,
|
||||||
|
} =
|
||||||
req.body as {
|
req.body as {
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
chat_id?: string;
|
chat_id?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
displayed_doc?: { filename: string; document_id: string };
|
displayed_doc?: { filename: string; document_id: string };
|
||||||
attached_documents?: { filename: string; document_id: string }[];
|
attached_documents?: { filename: string; document_id: string }[];
|
||||||
|
ask_inputs_response?: unknown;
|
||||||
};
|
};
|
||||||
|
const askInputsResponse = parseAskInputsResponsePayload(
|
||||||
|
ask_inputs_response,
|
||||||
|
);
|
||||||
|
|
||||||
const db = createServerSupabase();
|
const db = createServerSupabase();
|
||||||
|
|
||||||
|
|
@ -86,7 +100,13 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastUser = [...messages].reverse().find((m) => m.role === "user");
|
const lastUser = [...messages].reverse().find((m) => m.role === "user");
|
||||||
if (lastUser) {
|
if (askInputsResponse) {
|
||||||
|
await appendAskInputsResponseToLastAssistantMessage(
|
||||||
|
db,
|
||||||
|
chatId,
|
||||||
|
askInputsResponse,
|
||||||
|
);
|
||||||
|
} else if (lastUser) {
|
||||||
await db.from("chat_messages").insert({
|
await db.from("chat_messages").insert({
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
role: "user",
|
role: "user",
|
||||||
|
|
@ -173,7 +193,7 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`);
|
write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`);
|
||||||
|
|
||||||
const { events, annotations } = await runLLMStream({
|
const { events, citations } = await runLLMStream({
|
||||||
apiMessages,
|
apiMessages,
|
||||||
docStore,
|
docStore,
|
||||||
docIndex,
|
docIndex,
|
||||||
|
|
@ -190,12 +210,21 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const persistedEvents = stripTransientAssistantEvents(events);
|
const persistedEvents = stripTransientAssistantEvents(events);
|
||||||
await db.from("chat_messages").insert({
|
if (askInputsResponse) {
|
||||||
chat_id: chatId,
|
await appendAssistantEventsToLastAssistantMessage(
|
||||||
role: "assistant",
|
db,
|
||||||
content: persistedEvents.length ? persistedEvents : null,
|
chatId,
|
||||||
annotations: annotations.length ? annotations : null,
|
persistedEvents,
|
||||||
});
|
citations,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await db.from("chat_messages").insert({
|
||||||
|
chat_id: chatId,
|
||||||
|
role: "assistant",
|
||||||
|
content: persistedEvents.length ? persistedEvents : null,
|
||||||
|
citations: citations.length ? citations : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!chatTitle && lastUser?.content) {
|
if (!chatTitle && lastUser?.content) {
|
||||||
await db
|
await db
|
||||||
|
|
@ -212,17 +241,31 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
|
||||||
const partial = buildCancelledAssistantMessage({
|
const partial = buildCancelledAssistantMessage({
|
||||||
fullText: err.fullText,
|
fullText: err.fullText,
|
||||||
events: err.events,
|
events: err.events,
|
||||||
buildAnnotations: (fullText, events) =>
|
buildCitations: (fullText, events) =>
|
||||||
extractAnnotations(fullText, docIndex, events),
|
extractCitations(fullText, docIndex, events),
|
||||||
});
|
|
||||||
const { error: saveError } = await db.from("chat_messages").insert({
|
|
||||||
chat_id: chatId,
|
|
||||||
role: "assistant",
|
|
||||||
content: partial.events.length ? partial.events : null,
|
|
||||||
annotations: partial.annotations.length
|
|
||||||
? partial.annotations
|
|
||||||
: null,
|
|
||||||
});
|
});
|
||||||
|
const saveError = askInputsResponse
|
||||||
|
? null
|
||||||
|
: (
|
||||||
|
await db.from("chat_messages").insert({
|
||||||
|
chat_id: chatId,
|
||||||
|
role: "assistant",
|
||||||
|
content: partial.events.length
|
||||||
|
? partial.events
|
||||||
|
: null,
|
||||||
|
citations: partial.citations.length
|
||||||
|
? partial.citations
|
||||||
|
: null,
|
||||||
|
})
|
||||||
|
).error;
|
||||||
|
if (askInputsResponse) {
|
||||||
|
await appendAssistantEventsToLastAssistantMessage(
|
||||||
|
db,
|
||||||
|
chatId,
|
||||||
|
partial.events,
|
||||||
|
partial.citations,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (saveError) {
|
if (saveError) {
|
||||||
console.error(
|
console.error(
|
||||||
"[project-chat/stream] failed to save aborted stream",
|
"[project-chat/stream] failed to save aborted stream",
|
||||||
|
|
@ -240,17 +283,29 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
|
||||||
const errorFullText =
|
const errorFullText =
|
||||||
err instanceof AssistantStreamError ? err.fullText : "";
|
err instanceof AssistantStreamError ? err.fullText : "";
|
||||||
try {
|
try {
|
||||||
const annotations = extractAnnotations(
|
const citations = extractCitations(
|
||||||
errorFullText,
|
errorFullText,
|
||||||
docIndex,
|
docIndex,
|
||||||
errorEvents,
|
errorEvents,
|
||||||
);
|
);
|
||||||
const { error: saveError } = await db.from("chat_messages").insert({
|
const saveError = askInputsResponse
|
||||||
chat_id: chatId,
|
? null
|
||||||
role: "assistant",
|
: (
|
||||||
content: errorEvents.length ? errorEvents : null,
|
await db.from("chat_messages").insert({
|
||||||
annotations: annotations.length ? annotations : null,
|
chat_id: chatId,
|
||||||
});
|
role: "assistant",
|
||||||
|
content: errorEvents.length ? errorEvents : null,
|
||||||
|
citations: citations.length ? citations : null,
|
||||||
|
})
|
||||||
|
).error;
|
||||||
|
if (askInputsResponse) {
|
||||||
|
await appendAssistantEventsToLastAssistantMessage(
|
||||||
|
db,
|
||||||
|
chatId,
|
||||||
|
errorEvents,
|
||||||
|
citations,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (saveError)
|
if (saveError)
|
||||||
console.error("[project-chat/stream] failed to save error", saveError);
|
console.error("[project-chat/stream] failed to save error", saveError);
|
||||||
} catch (saveErr) {
|
} catch (saveErr) {
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,24 @@ import { docxToPdf, convertedPdfKey } from "../lib/convert";
|
||||||
import { checkProjectAccess } from "../lib/access";
|
import { checkProjectAccess } from "../lib/access";
|
||||||
import { singleFileUpload } from "../lib/upload";
|
import { singleFileUpload } from "../lib/upload";
|
||||||
import { deleteUserProjects } from "../lib/userDataCleanup";
|
import { deleteUserProjects } from "../lib/userDataCleanup";
|
||||||
|
import {
|
||||||
|
ALLOWED_DOCUMENT_TYPES,
|
||||||
|
ALLOWED_DOCUMENT_TYPES_LABEL,
|
||||||
|
contentTypeForDocumentType,
|
||||||
|
shouldConvertToPdf,
|
||||||
|
} from "../lib/documentTypes";
|
||||||
|
import {
|
||||||
|
findMissingUserEmails,
|
||||||
|
loadProfileUsersByEmail,
|
||||||
|
} from "../lib/userLookup";
|
||||||
|
|
||||||
export const projectsRouter = Router();
|
export const projectsRouter = Router();
|
||||||
const ALLOWED_TYPES = new Set(["pdf", "docx", "doc"]);
|
|
||||||
|
function normalizeOptionalString(value: unknown) {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeDocumentFilename(nextName: unknown, currentName: string) {
|
function normalizeDocumentFilename(nextName: unknown, currentName: string) {
|
||||||
if (typeof nextName !== "string") return null;
|
if (typeof nextName !== "string") return null;
|
||||||
|
|
@ -137,9 +152,16 @@ async function attachChatCreatorLabels(
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /projects
|
// GET /projects
|
||||||
|
// Pass ?include=documents to also receive each project's documents in the
|
||||||
|
// same response. The directory pickers (useDirectoryData) previously fanned
|
||||||
|
// out one GET /projects/:id per project to obtain those documents; with N
|
||||||
|
// projects that burst — auth check plus several DB queries per request —
|
||||||
|
// could overwhelm the Supabase gateway. Batching keeps it at one request
|
||||||
|
// and a fixed number of queries regardless of project count.
|
||||||
projectsRouter.get("/", requireAuth, async (req, res) => {
|
projectsRouter.get("/", requireAuth, async (req, res) => {
|
||||||
const userId = res.locals.userId as string;
|
const userId = res.locals.userId as string;
|
||||||
const userEmail = res.locals.userEmail as string | undefined;
|
const userEmail = res.locals.userEmail as string | undefined;
|
||||||
|
const includeDocuments = req.query.include === "documents";
|
||||||
const db = createServerSupabase();
|
const db = createServerSupabase();
|
||||||
|
|
||||||
const { data, error } = await db.rpc("get_projects_overview", {
|
const { data, error } = await db.rpc("get_projects_overview", {
|
||||||
|
|
@ -148,16 +170,55 @@ projectsRouter.get("/", requireAuth, async (req, res) => {
|
||||||
});
|
});
|
||||||
if (error) return void res.status(500).json({ detail: error.message });
|
if (error) return void res.status(500).json({ detail: error.message });
|
||||||
|
|
||||||
res.json(data ?? []);
|
const projects = (data ?? []) as { id: string }[];
|
||||||
|
if (!includeDocuments || projects.length === 0) {
|
||||||
|
return void res.json(projects);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: docs, error: docsError } = await db
|
||||||
|
.from("documents")
|
||||||
|
.select("*")
|
||||||
|
.in(
|
||||||
|
"project_id",
|
||||||
|
projects.map((p) => p.id),
|
||||||
|
)
|
||||||
|
.order("created_at", { ascending: true });
|
||||||
|
if (docsError)
|
||||||
|
return void res.status(500).json({ detail: docsError.message });
|
||||||
|
|
||||||
|
const docsTyped = (docs ?? []) as unknown as {
|
||||||
|
id: string;
|
||||||
|
project_id?: string | null;
|
||||||
|
user_id?: string | null;
|
||||||
|
current_version_id?: string | null;
|
||||||
|
}[];
|
||||||
|
await attachLatestVersionNumbers(db, docsTyped);
|
||||||
|
await attachActiveVersionPaths(db, docsTyped);
|
||||||
|
await attachDocumentOwnerLabels(db, docsTyped);
|
||||||
|
|
||||||
|
const docsByProject = new Map<string, typeof docsTyped>();
|
||||||
|
for (const doc of docsTyped) {
|
||||||
|
if (!doc.project_id) continue;
|
||||||
|
const bucket = docsByProject.get(doc.project_id);
|
||||||
|
if (bucket) bucket.push(doc);
|
||||||
|
else docsByProject.set(doc.project_id, [doc]);
|
||||||
|
}
|
||||||
|
res.json(
|
||||||
|
projects.map((p) => ({
|
||||||
|
...p,
|
||||||
|
documents: docsByProject.get(p.id) ?? [],
|
||||||
|
})),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /projects
|
// POST /projects
|
||||||
projectsRouter.post("/", requireAuth, async (req, res) => {
|
projectsRouter.post("/", requireAuth, async (req, res) => {
|
||||||
const userId = res.locals.userId as string;
|
const userId = res.locals.userId as string;
|
||||||
const userEmail = res.locals.userEmail as string | undefined;
|
const userEmail = res.locals.userEmail as string | undefined;
|
||||||
const { name, cm_number, shared_with } = req.body as {
|
const { name, cm_number, practice, shared_with } = req.body as {
|
||||||
name: string;
|
name: string;
|
||||||
cm_number?: string;
|
cm_number?: string;
|
||||||
|
practice?: string;
|
||||||
shared_with?: string[];
|
shared_with?: string[];
|
||||||
};
|
};
|
||||||
if (!name?.trim())
|
if (!name?.trim())
|
||||||
|
|
@ -181,12 +242,20 @@ projectsRouter.post("/", requireAuth, async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = createServerSupabase();
|
const db = createServerSupabase();
|
||||||
|
const missingSharedUsers = await findMissingUserEmails(db, cleanedSharedWith);
|
||||||
|
if (missingSharedUsers.length > 0) {
|
||||||
|
return void res.status(400).json({
|
||||||
|
detail: `${missingSharedUsers[0]} does not belong to a Mike user.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const { data, error } = await db
|
const { data, error } = await db
|
||||||
.from("projects")
|
.from("projects")
|
||||||
.insert({
|
.insert({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
cm_number: cm_number ?? null,
|
cm_number: normalizeOptionalString(cm_number),
|
||||||
|
practice: normalizeOptionalString(practice),
|
||||||
shared_with: cleanedSharedWith,
|
shared_with: cleanedSharedWith,
|
||||||
})
|
})
|
||||||
.select("*")
|
.select("*")
|
||||||
|
|
@ -266,60 +335,18 @@ projectsRouter.get("/:projectId/people", requireAuth, async (req, res) => {
|
||||||
if (!isOwner && !isShared)
|
if (!isOwner && !isShared)
|
||||||
return void res.status(404).json({ detail: "Project not found" });
|
return void res.status(404).json({ detail: "Project not found" });
|
||||||
|
|
||||||
// Pull every auth user (matching the lookup endpoint's pattern). For
|
// Use the mirrored profile email so sharing checks do not scan auth.users.
|
||||||
// larger deployments this should page or be replaced with a bulk-by-id
|
const { userByEmail, userById } = await loadProfileUsersByEmail(db);
|
||||||
// RPC, but it keeps things simple while user counts are modest.
|
|
||||||
const { data: usersData } = await db.auth.admin.listUsers({ perPage: 1000 });
|
|
||||||
const allUsers = usersData?.users ?? [];
|
|
||||||
const userByEmail = new Map<string, { id: string; email: string }>();
|
|
||||||
const userById = new Map<string, { id: string; email: string }>();
|
|
||||||
for (const u of allUsers) {
|
|
||||||
if (!u.email) continue;
|
|
||||||
const lower = u.email.toLowerCase();
|
|
||||||
userByEmail.set(lower, { id: u.id, email: u.email });
|
|
||||||
userById.set(u.id, { id: u.id, email: u.email });
|
|
||||||
}
|
|
||||||
|
|
||||||
const memberUserIds: string[] = [];
|
|
||||||
for (const email of sharedWith) {
|
|
||||||
const u = userByEmail.get(email);
|
|
||||||
if (u) memberUserIds.push(u.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const profileIds = [
|
|
||||||
project.user_id as string,
|
|
||||||
...memberUserIds,
|
|
||||||
].filter((x, i, arr) => arr.indexOf(x) === i);
|
|
||||||
|
|
||||||
const profileByUserId = new Map<
|
|
||||||
string,
|
|
||||||
{ display_name: string | null; organisation: string | null }
|
|
||||||
>();
|
|
||||||
if (profileIds.length > 0) {
|
|
||||||
const { data: profiles } = await db
|
|
||||||
.from("user_profiles")
|
|
||||||
.select("user_id, display_name, organisation")
|
|
||||||
.in("user_id", profileIds);
|
|
||||||
for (const p of profiles ?? []) {
|
|
||||||
profileByUserId.set(p.user_id as string, {
|
|
||||||
display_name: (p.display_name as string | null) ?? null,
|
|
||||||
organisation: (p.organisation as string | null) ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ownerInfo = userById.get(project.user_id as string);
|
const ownerInfo = userById.get(project.user_id as string);
|
||||||
const owner = {
|
const owner = {
|
||||||
user_id: project.user_id,
|
user_id: project.user_id,
|
||||||
email: ownerInfo?.email ?? null,
|
email: ownerInfo?.email ?? null,
|
||||||
display_name:
|
display_name: ownerInfo?.display_name ?? null,
|
||||||
profileByUserId.get(project.user_id as string)?.display_name ?? null,
|
|
||||||
};
|
};
|
||||||
const members = sharedWith.map((email) => {
|
const members = sharedWith.map((email) => {
|
||||||
const u = userByEmail.get(email);
|
const u = userByEmail.get(email);
|
||||||
const display_name = u
|
const display_name = u?.display_name ?? null;
|
||||||
? profileByUserId.get(u.id)?.display_name ?? null
|
|
||||||
: null;
|
|
||||||
return { email, display_name };
|
return { email, display_name };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -334,6 +361,9 @@ projectsRouter.patch("/:projectId", requireAuth, async (req, res) => {
|
||||||
const updates: Record<string, unknown> = {};
|
const updates: Record<string, unknown> = {};
|
||||||
if (req.body.name != null) updates.name = req.body.name;
|
if (req.body.name != null) updates.name = req.body.name;
|
||||||
if (req.body.cm_number != null) updates.cm_number = req.body.cm_number;
|
if (req.body.cm_number != null) updates.cm_number = req.body.cm_number;
|
||||||
|
if ("practice" in req.body) {
|
||||||
|
updates.practice = normalizeOptionalString(req.body.practice);
|
||||||
|
}
|
||||||
if (Array.isArray(req.body.shared_with)) {
|
if (Array.isArray(req.body.shared_with)) {
|
||||||
// Normalise: lowercase + dedupe + drop empties.
|
// Normalise: lowercase + dedupe + drop empties.
|
||||||
const normalizedUserEmail = userEmail?.trim().toLowerCase();
|
const normalizedUserEmail = userEmail?.trim().toLowerCase();
|
||||||
|
|
@ -355,6 +385,18 @@ projectsRouter.patch("/:projectId", requireAuth, async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = createServerSupabase();
|
const db = createServerSupabase();
|
||||||
|
if (Array.isArray(updates.shared_with)) {
|
||||||
|
const missingSharedUsers = await findMissingUserEmails(
|
||||||
|
db,
|
||||||
|
updates.shared_with as string[],
|
||||||
|
);
|
||||||
|
if (missingSharedUsers.length > 0) {
|
||||||
|
return void res.status(400).json({
|
||||||
|
detail: `${missingSharedUsers[0]} does not belong to a Mike user.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { data, error } = await db
|
const { data, error } = await db
|
||||||
.from("projects")
|
.from("projects")
|
||||||
.update({ ...updates, updated_at: new Date().toISOString() })
|
.update({ ...updates, updated_at: new Date().toISOString() })
|
||||||
|
|
@ -456,7 +498,11 @@ projectsRouter.post(
|
||||||
// Standalone → assign project_id
|
// Standalone → assign project_id
|
||||||
const { data: updated, error } = await db
|
const { data: updated, error } = await db
|
||||||
.from("documents")
|
.from("documents")
|
||||||
.update({ project_id: projectId, updated_at: new Date().toISOString() })
|
.update({
|
||||||
|
project_id: projectId,
|
||||||
|
library_folder_id: null,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
.eq("id", documentId)
|
.eq("id", documentId)
|
||||||
.select("*")
|
.select("*")
|
||||||
.single();
|
.single();
|
||||||
|
|
@ -519,10 +565,9 @@ projectsRouter.post(
|
||||||
);
|
);
|
||||||
let newPdfPath: string | null = null;
|
let newPdfPath: string | null = null;
|
||||||
try {
|
try {
|
||||||
const contentType =
|
const contentType = contentTypeForDocumentType(
|
||||||
((srcV.file_type as string | null) ?? doc.file_type) === "pdf"
|
(srcV.file_type as string | null) ?? doc.file_type,
|
||||||
? "application/pdf"
|
);
|
||||||
: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
|
||||||
await uploadFile(newKey, srcBytes, contentType);
|
await uploadFile(newKey, srcBytes, contentType);
|
||||||
|
|
||||||
// PDFs share one object for source + display rendition. DOCX
|
// PDFs share one object for source + display rendition. DOCX
|
||||||
|
|
@ -885,11 +930,11 @@ export async function handleDocumentUpload(
|
||||||
const suffix = filename.includes(".")
|
const suffix = filename.includes(".")
|
||||||
? filename.split(".").pop()!.toLowerCase()
|
? filename.split(".").pop()!.toLowerCase()
|
||||||
: "";
|
: "";
|
||||||
if (!ALLOWED_TYPES.has(suffix))
|
if (!ALLOWED_DOCUMENT_TYPES.has(suffix))
|
||||||
return void res
|
return void res
|
||||||
.status(400)
|
.status(400)
|
||||||
.json({
|
.json({
|
||||||
detail: `Unsupported file type: ${suffix}. Allowed: pdf, docx, doc`,
|
detail: `Unsupported file type: ${suffix}. Allowed: ${ALLOWED_DOCUMENT_TYPES_LABEL}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const content = file.buffer;
|
const content = file.buffer;
|
||||||
|
|
@ -911,10 +956,7 @@ export async function handleDocumentUpload(
|
||||||
try {
|
try {
|
||||||
const docId = doc.id as string;
|
const docId = doc.id as string;
|
||||||
const key = storageKey(userId, docId, filename);
|
const key = storageKey(userId, docId, filename);
|
||||||
const contentType =
|
const contentType = contentTypeForDocumentType(suffix);
|
||||||
suffix === "pdf"
|
|
||||||
? "application/pdf"
|
|
||||||
: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
|
||||||
await uploadFile(
|
await uploadFile(
|
||||||
key,
|
key,
|
||||||
content.buffer.slice(
|
content.buffer.slice(
|
||||||
|
|
@ -930,9 +972,9 @@ export async function handleDocumentUpload(
|
||||||
) as ArrayBuffer;
|
) as ArrayBuffer;
|
||||||
const pageCount = suffix === "pdf" ? await countPdfPages(rawBuf) : null;
|
const pageCount = suffix === "pdf" ? await countPdfPages(rawBuf) : null;
|
||||||
|
|
||||||
// Convert DOCX/DOC → PDF for display. PDFs are their own rendition.
|
// Convert Office files → PDF for display. PDFs are their own rendition.
|
||||||
let pdfStoragePath: string | null = null;
|
let pdfStoragePath: string | null = null;
|
||||||
if (suffix === "docx" || suffix === "doc") {
|
if (shouldConvertToPdf(suffix)) {
|
||||||
try {
|
try {
|
||||||
const pdfBuf = await docxToPdf(content);
|
const pdfBuf = await docxToPdf(content);
|
||||||
const pdfKey = convertedPdfKey(userId, docId);
|
const pdfKey = convertedPdfKey(userId, docId);
|
||||||
|
|
@ -947,7 +989,7 @@ export async function handleDocumentUpload(
|
||||||
pdfStoragePath = pdfKey;
|
pdfStoragePath = pdfKey;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
`[upload] DOCX→PDF conversion failed for ${filename}:`,
|
`[upload] Office→PDF conversion failed for ${filename}:`,
|
||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,14 @@ import {
|
||||||
attachActiveVersionPaths,
|
attachActiveVersionPaths,
|
||||||
loadActiveVersion,
|
loadActiveVersion,
|
||||||
} from "../lib/documentVersions";
|
} from "../lib/documentVersions";
|
||||||
import { normalizeDocxZipPaths } from "../lib/convert";
|
import { docxToPdf, normalizeDocxZipPaths } from "../lib/convert";
|
||||||
|
import {
|
||||||
|
isPresentationDocumentType,
|
||||||
|
isSpreadsheetDocumentType,
|
||||||
|
isWordDocumentType,
|
||||||
|
} from "../lib/documentTypes";
|
||||||
|
import { extractPresentationText } from "../lib/officeText";
|
||||||
|
import { spreadsheetToLLMText } from "../lib/spreadsheet";
|
||||||
import {
|
import {
|
||||||
AssistantStreamError,
|
AssistantStreamError,
|
||||||
buildCancelledAssistantMessage,
|
buildCancelledAssistantMessage,
|
||||||
|
|
@ -16,7 +23,7 @@ import {
|
||||||
TABULAR_TOOLS,
|
TABULAR_TOOLS,
|
||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
type TabularCellStore,
|
type TabularCellStore,
|
||||||
} from "../lib/chatTools";
|
} from "../lib/chat";
|
||||||
import {
|
import {
|
||||||
completeText,
|
completeText,
|
||||||
providerForModel,
|
providerForModel,
|
||||||
|
|
@ -31,6 +38,10 @@ import {
|
||||||
filterAccessibleDocumentIds,
|
filterAccessibleDocumentIds,
|
||||||
} from "../lib/access";
|
} from "../lib/access";
|
||||||
import { safeErrorLog, safeErrorMessage } from "../lib/safeError";
|
import { safeErrorLog, safeErrorMessage } from "../lib/safeError";
|
||||||
|
import {
|
||||||
|
findMissingUserEmails,
|
||||||
|
loadProfileUsersByEmail,
|
||||||
|
} from "../lib/userLookup";
|
||||||
|
|
||||||
function formatPromptSuffix(format?: string, tags?: string[]): string {
|
function formatPromptSuffix(format?: string, tags?: string[]): string {
|
||||||
switch (format) {
|
switch (format) {
|
||||||
|
|
@ -307,55 +318,19 @@ tabularRouter.get("/:reviewId/people", requireAuth, async (req, res) => {
|
||||||
: []
|
: []
|
||||||
).map((e) => (e ?? "").toLowerCase());
|
).map((e) => (e ?? "").toLowerCase());
|
||||||
|
|
||||||
// Same pattern as /projects/:id/people: walk auth.users to map emails
|
// Use the mirrored profile email so sharing checks do not scan auth.users.
|
||||||
// to user_ids, then pull display_names from user_profiles by user_id.
|
const { userByEmail, userById } = await loadProfileUsersByEmail(db);
|
||||||
const { data: usersData } = await db.auth.admin.listUsers({
|
|
||||||
perPage: 1000,
|
|
||||||
});
|
|
||||||
const allUsers = usersData?.users ?? [];
|
|
||||||
const userByEmail = new Map<string, { id: string; email: string }>();
|
|
||||||
const userById = new Map<string, { id: string; email: string }>();
|
|
||||||
for (const u of allUsers) {
|
|
||||||
if (!u.email) continue;
|
|
||||||
const lower = u.email.toLowerCase();
|
|
||||||
userByEmail.set(lower, { id: u.id, email: u.email });
|
|
||||||
userById.set(u.id, { id: u.id, email: u.email });
|
|
||||||
}
|
|
||||||
|
|
||||||
const memberUserIds: string[] = [];
|
|
||||||
for (const email of sharedWith) {
|
|
||||||
const u = userByEmail.get(email);
|
|
||||||
if (u) memberUserIds.push(u.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const profileIds = [review.user_id as string, ...memberUserIds].filter(
|
|
||||||
(x, i, arr) => arr.indexOf(x) === i,
|
|
||||||
);
|
|
||||||
|
|
||||||
const profileByUserId = new Map<string, string | null>();
|
|
||||||
if (profileIds.length > 0) {
|
|
||||||
const { data: profiles } = await db
|
|
||||||
.from("user_profiles")
|
|
||||||
.select("user_id, display_name")
|
|
||||||
.in("user_id", profileIds);
|
|
||||||
for (const p of profiles ?? []) {
|
|
||||||
profileByUserId.set(
|
|
||||||
p.user_id as string,
|
|
||||||
(p.display_name as string | null) ?? null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ownerInfo = userById.get(review.user_id as string);
|
const ownerInfo = userById.get(review.user_id as string);
|
||||||
res.json({
|
res.json({
|
||||||
owner: {
|
owner: {
|
||||||
user_id: review.user_id,
|
user_id: review.user_id,
|
||||||
email: ownerInfo?.email ?? null,
|
email: ownerInfo?.email ?? null,
|
||||||
display_name: profileByUserId.get(review.user_id as string) ?? null,
|
display_name: ownerInfo?.display_name ?? null,
|
||||||
},
|
},
|
||||||
members: sharedWith.map((email) => {
|
members: sharedWith.map((email) => {
|
||||||
const u = userByEmail.get(email);
|
const u = userByEmail.get(email);
|
||||||
const display_name = u ? (profileByUserId.get(u.id) ?? null) : null;
|
const display_name = u?.display_name ?? null;
|
||||||
return { email, display_name };
|
return { email, display_name };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
@ -433,6 +408,15 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => {
|
||||||
return void res
|
return void res
|
||||||
.status(403)
|
.status(403)
|
||||||
.json({ detail: "Only the review owner can change sharing" });
|
.json({ detail: "Only the review owner can change sharing" });
|
||||||
|
const missingSharedUsers = await findMissingUserEmails(
|
||||||
|
db,
|
||||||
|
sharedWithUpdate,
|
||||||
|
);
|
||||||
|
if (missingSharedUsers.length > 0) {
|
||||||
|
return void res.status(400).json({
|
||||||
|
detail: `${missingSharedUsers[0]} does not belong to a Mike user.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
updates.shared_with = sharedWithUpdate;
|
updates.shared_with = sharedWithUpdate;
|
||||||
}
|
}
|
||||||
if (projectIdUpdateProvided) {
|
if (projectIdUpdateProvided) {
|
||||||
|
|
@ -712,10 +696,10 @@ tabularRouter.post(
|
||||||
const buf = await downloadFile(docActive.storage_path);
|
const buf = await downloadFile(docActive.storage_path);
|
||||||
if (buf) {
|
if (buf) {
|
||||||
try {
|
try {
|
||||||
markdown =
|
markdown = await extractDocumentMarkdown(
|
||||||
docActive.file_type === "pdf"
|
buf,
|
||||||
? await extractPdfMarkdown(buf)
|
docActive.file_type,
|
||||||
: await extractDocxMarkdown(buf);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
`[regenerate-cell] extraction error doc=${document_id}`,
|
`[regenerate-cell] extraction error doc=${document_id}`,
|
||||||
|
|
@ -858,10 +842,10 @@ tabularRouter.post("/:reviewId/generate", requireAuth, async (req, res) => {
|
||||||
const buf = await downloadFile(storagePath);
|
const buf = await downloadFile(storagePath);
|
||||||
if (buf) {
|
if (buf) {
|
||||||
try {
|
try {
|
||||||
markdown =
|
markdown = await extractDocumentMarkdown(
|
||||||
fileType === "pdf"
|
buf,
|
||||||
? await extractPdfMarkdown(buf)
|
fileType,
|
||||||
: await extractDocxMarkdown(buf);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
`[tabular/generate] extraction error doc=${docId}`,
|
`[tabular/generate] extraction error doc=${docId}`,
|
||||||
|
|
@ -1013,6 +997,29 @@ tabularRouter.delete(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// PATCH /tabular-review/:reviewId/chats/:chatId — rename a chat
|
||||||
|
tabularRouter.patch(
|
||||||
|
"/:reviewId/chats/:chatId",
|
||||||
|
requireAuth,
|
||||||
|
async (req, res) => {
|
||||||
|
const userId = res.locals.userId as string;
|
||||||
|
const { chatId } = req.params;
|
||||||
|
const title =
|
||||||
|
typeof req.body?.title === "string" ? req.body.title.trim() : "";
|
||||||
|
if (!title)
|
||||||
|
return void res.status(400).json({ detail: "Title is required" });
|
||||||
|
const db = createServerSupabase();
|
||||||
|
// Owner-only rename — mirrors the delete rule above.
|
||||||
|
const { error } = await db
|
||||||
|
.from("tabular_review_chats")
|
||||||
|
.update({ title: title.slice(0, 200) })
|
||||||
|
.eq("id", chatId)
|
||||||
|
.eq("user_id", userId);
|
||||||
|
if (error) return void res.status(500).json({ detail: error.message });
|
||||||
|
res.status(204).send();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// GET /tabular-review/:reviewId/chats/:chatId/messages — messages for a single chat
|
// GET /tabular-review/:reviewId/chats/:chatId/messages — messages for a single chat
|
||||||
tabularRouter.get(
|
tabularRouter.get(
|
||||||
"/:reviewId/chats/:chatId/messages",
|
"/:reviewId/chats/:chatId/messages",
|
||||||
|
|
@ -1379,17 +1386,18 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => {
|
||||||
const partial = buildCancelledAssistantMessage({
|
const partial = buildCancelledAssistantMessage({
|
||||||
fullText: err.fullText,
|
fullText: err.fullText,
|
||||||
events: err.events,
|
events: err.events,
|
||||||
buildAnnotations: (fullText) =>
|
buildCitations: (fullText) =>
|
||||||
extractTabularAnnotations(fullText, tabularStore),
|
extractTabularAnnotations(fullText, tabularStore),
|
||||||
});
|
});
|
||||||
|
const annotations = partial.citations;
|
||||||
const { error: saveError } = await db
|
const { error: saveError } = await db
|
||||||
.from("tabular_review_chat_messages")
|
.from("tabular_review_chat_messages")
|
||||||
.insert({
|
.insert({
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: partial.events.length ? partial.events : null,
|
content: partial.events.length ? partial.events : null,
|
||||||
annotations: partial.annotations.length
|
annotations: annotations.length
|
||||||
? partial.annotations
|
? annotations
|
||||||
: null,
|
: null,
|
||||||
});
|
});
|
||||||
if (saveError) {
|
if (saveError) {
|
||||||
|
|
@ -1737,6 +1745,34 @@ Rules:
|
||||||
await Promise.all(pending);
|
await Promise.all(pending);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function extractDocumentMarkdown(
|
||||||
|
buf: ArrayBuffer,
|
||||||
|
fileType: string | null | undefined,
|
||||||
|
): Promise<string> {
|
||||||
|
const normalizedType = (fileType ?? "").toLowerCase();
|
||||||
|
if (normalizedType === "pdf") return extractPdfMarkdown(buf);
|
||||||
|
if (normalizedType === "docx") return extractDocxMarkdown(buf);
|
||||||
|
if (isSpreadsheetDocumentType(normalizedType)) {
|
||||||
|
// SheetJS handles .xlsx/.xlsm/.xls directly, no PDF detour.
|
||||||
|
return spreadsheetToLLMText(Buffer.from(buf));
|
||||||
|
}
|
||||||
|
if (normalizedType === "pptx") {
|
||||||
|
return extractPresentationText(Buffer.from(buf));
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
isPresentationDocumentType(normalizedType) ||
|
||||||
|
isWordDocumentType(normalizedType)
|
||||||
|
) {
|
||||||
|
const pdfBuf = await docxToPdf(Buffer.from(buf));
|
||||||
|
const pdfArrayBuffer = pdfBuf.buffer.slice(
|
||||||
|
pdfBuf.byteOffset,
|
||||||
|
pdfBuf.byteOffset + pdfBuf.byteLength,
|
||||||
|
) as ArrayBuffer;
|
||||||
|
return extractPdfMarkdown(pdfArrayBuffer);
|
||||||
|
}
|
||||||
|
return extractDocxMarkdown(buf);
|
||||||
|
}
|
||||||
|
|
||||||
async function extractPdfMarkdown(buf: ArrayBuffer): Promise<string> {
|
async function extractPdfMarkdown(buf: ArrayBuffer): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const pdfjsLib = await import(
|
const pdfjsLib = await import(
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import {
|
||||||
buildUserTabularReviewsExport,
|
buildUserTabularReviewsExport,
|
||||||
userExportFilename,
|
userExportFilename,
|
||||||
} from "../lib/userDataExport";
|
} from "../lib/userDataExport";
|
||||||
|
import { findProfileUserByEmail } from "../lib/userLookup";
|
||||||
|
|
||||||
export const userRouter = Router();
|
export const userRouter = Router();
|
||||||
|
|
||||||
|
|
@ -505,6 +506,22 @@ userRouter.post("/profile", requireAuth, async (_req, res) => {
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /user/lookup?email=person@example.com
|
||||||
|
userRouter.get("/lookup", requireAuth, async (req, res) => {
|
||||||
|
const email = typeof req.query.email === "string" ? req.query.email : "";
|
||||||
|
if (!email.trim()) {
|
||||||
|
return void res.status(400).json({ detail: "email is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = createServerSupabase();
|
||||||
|
const user = await findProfileUserByEmail(db, email);
|
||||||
|
res.json({
|
||||||
|
exists: !!user,
|
||||||
|
email: user?.email ?? email.trim().toLowerCase(),
|
||||||
|
display_name: user?.display_name ?? null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// GET /user/profile
|
// GET /user/profile
|
||||||
userRouter.get("/profile", requireAuth, async (_req, res) => {
|
userRouter.get("/profile", requireAuth, async (_req, res) => {
|
||||||
const userId = res.locals.userId as string;
|
const userId = res.locals.userId as string;
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,92 @@
|
||||||
import { Router, type NextFunction, type Request, type Response } from "express";
|
import { Router, type NextFunction, type Request, type Response } from "express";
|
||||||
import { requireAuth } from "../middleware/auth";
|
import { requireAuth } from "../middleware/auth";
|
||||||
import { createServerSupabase } from "../lib/supabase";
|
import { createServerSupabase } from "../lib/supabase";
|
||||||
|
import {
|
||||||
|
SYSTEM_WORKFLOW_IDS,
|
||||||
|
SYSTEM_WORKFLOWS,
|
||||||
|
type SystemWorkflow,
|
||||||
|
} from "../lib/systemWorkflows";
|
||||||
|
import { findMissingUserEmails } from "../lib/userLookup";
|
||||||
|
|
||||||
export const workflowsRouter = Router();
|
export const workflowsRouter = Router();
|
||||||
|
|
||||||
type Db = ReturnType<typeof createServerSupabase>;
|
type Db = ReturnType<typeof createServerSupabase>;
|
||||||
|
const isDev = process.env.NODE_ENV !== "production";
|
||||||
|
const devLog = (...args: Parameters<typeof console.log>) => {
|
||||||
|
if (isDev) console.log(...args);
|
||||||
|
};
|
||||||
|
|
||||||
type WorkflowRecord = {
|
type WorkflowRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string | null;
|
user_id: string | null;
|
||||||
is_system: boolean;
|
is_system?: boolean;
|
||||||
|
title?: string;
|
||||||
|
type?: string;
|
||||||
|
prompt_md?: string | null;
|
||||||
|
columns_config?: unknown;
|
||||||
|
language?: string | null;
|
||||||
|
version?: string | null;
|
||||||
|
practice?: string | null;
|
||||||
|
jurisdictions?: string[] | null;
|
||||||
|
created_at?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type WorkflowType = "assistant" | "tabular";
|
||||||
|
|
||||||
|
type WorkflowContributor = {
|
||||||
|
name: string;
|
||||||
|
organisation: string | null;
|
||||||
|
role: string | null;
|
||||||
|
linkedin: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorkflowMetadata = {
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
type: WorkflowType;
|
||||||
|
contributors: WorkflowContributor[];
|
||||||
|
language: string;
|
||||||
|
version: string | null;
|
||||||
|
practice: string | null;
|
||||||
|
jurisdictions: string[] | null;
|
||||||
|
};
|
||||||
|
type OpenSourceSubmissionStatus = "pending" | "approved" | "rejected";
|
||||||
|
|
||||||
|
type OpenSourceSubmissionRow = {
|
||||||
|
id: string;
|
||||||
|
workflow_id: string;
|
||||||
|
submitted_by_user_id: string;
|
||||||
|
submitter_email: string | null;
|
||||||
|
submitter_name: string | null;
|
||||||
|
contributor_mode?: "named" | "anonymous";
|
||||||
|
status: OpenSourceSubmissionStatus;
|
||||||
|
snapshot: unknown;
|
||||||
|
submitted_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
reviewed_at?: string | null;
|
||||||
|
review_notes?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenSourceSubmissionSummary = Pick<
|
||||||
|
OpenSourceSubmissionRow,
|
||||||
|
"id" | "status" | "submitted_at" | "updated_at"
|
||||||
|
> & {
|
||||||
|
reviewed_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_WORKFLOW_CONTRIBUTOR: WorkflowContributor = {
|
||||||
|
name: "Mike",
|
||||||
|
organisation: null,
|
||||||
|
role: null,
|
||||||
|
linkedin: null,
|
||||||
|
};
|
||||||
|
const DEFAULT_WORKFLOW_LANGUAGE = "English";
|
||||||
|
const DEFAULT_WORKFLOW_PRACTICE = "General Transactions";
|
||||||
|
const DEFAULT_WORKFLOW_JURISDICTIONS = ["General"];
|
||||||
|
const WORKFLOW_CONTRIBUTIONS_ENABLED =
|
||||||
|
process.env.WORKFLOW_CONTRIBUTIONS_ENABLED === "true";
|
||||||
|
|
||||||
type WorkflowAccess =
|
type WorkflowAccess =
|
||||||
| {
|
| {
|
||||||
workflow: WorkflowRecord;
|
workflow: WorkflowRecord;
|
||||||
|
|
@ -29,7 +103,7 @@ function asyncRoute(handler: AsyncRoute) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function withWorkflowAccess<T extends Record<string, unknown>>(
|
function withWorkflowAccess<T extends object>(
|
||||||
workflow: T,
|
workflow: T,
|
||||||
access: { allowEdit: boolean; isOwner: boolean; sharedByName?: string | null },
|
access: { allowEdit: boolean; isOwner: boolean; sharedByName?: string | null },
|
||||||
) {
|
) {
|
||||||
|
|
@ -41,6 +115,103 @@ function withWorkflowAccess<T extends Record<string, unknown>>(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withOpenSourceSubmission<T extends object>(
|
||||||
|
workflow: T,
|
||||||
|
submission: OpenSourceSubmissionSummary | null,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...workflow,
|
||||||
|
open_source_submission: submission,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function withSystemWorkflowAccess(workflow: SystemWorkflow) {
|
||||||
|
return withWorkflowAccess(workflow, {
|
||||||
|
allowEdit: false,
|
||||||
|
isOwner: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowTypeFrom(value: unknown): WorkflowType {
|
||||||
|
return value === "tabular" ? "tabular" : "assistant";
|
||||||
|
}
|
||||||
|
|
||||||
|
function metadataFromWorkflowRecord(workflow: WorkflowRecord): WorkflowMetadata {
|
||||||
|
return {
|
||||||
|
title: workflow.title ?? "",
|
||||||
|
description: null,
|
||||||
|
type: workflowTypeFrom(workflow.type),
|
||||||
|
contributors:
|
||||||
|
normalizeContributors(workflow.contributors) ?? [
|
||||||
|
DEFAULT_WORKFLOW_CONTRIBUTOR,
|
||||||
|
],
|
||||||
|
language: workflow.language ?? DEFAULT_WORKFLOW_LANGUAGE,
|
||||||
|
version: workflow.version ?? null,
|
||||||
|
practice: workflow.practice ?? DEFAULT_WORKFLOW_PRACTICE,
|
||||||
|
jurisdictions: workflow.jurisdictions ?? DEFAULT_WORKFLOW_JURISDICTIONS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function withDatabaseWorkflow(workflow: WorkflowRecord) {
|
||||||
|
const {
|
||||||
|
title: _title,
|
||||||
|
type: _type,
|
||||||
|
contributors: _contributors,
|
||||||
|
language: _language,
|
||||||
|
version: _version,
|
||||||
|
practice: _practice,
|
||||||
|
jurisdictions: _jurisdictions,
|
||||||
|
prompt_md,
|
||||||
|
...rest
|
||||||
|
} = workflow;
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
metadata: metadataFromWorkflowRecord(workflow),
|
||||||
|
skill_md: prompt_md ?? null,
|
||||||
|
is_system: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptionalString(value: unknown): string | null {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeJurisdictions(value: unknown): string[] | null {
|
||||||
|
if (!Array.isArray(value)) return null;
|
||||||
|
const items = value
|
||||||
|
.map((item) => normalizeOptionalString(item))
|
||||||
|
.filter((item): item is string => !!item);
|
||||||
|
return items.length > 0 ? Array.from(new Set(items)) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeContributors(value: unknown): WorkflowContributor[] | null {
|
||||||
|
if (!Array.isArray(value)) return null;
|
||||||
|
const contributors = value
|
||||||
|
.map((item): WorkflowContributor | null => {
|
||||||
|
if (!item || typeof item !== "object" || Array.isArray(item)) return null;
|
||||||
|
const record = item as Record<string, unknown>;
|
||||||
|
const name = normalizeOptionalString(record.name);
|
||||||
|
if (!name) return null;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
organisation: normalizeOptionalString(record.organisation),
|
||||||
|
role: normalizeOptionalString(record.role),
|
||||||
|
linkedin: normalizeOptionalString(record.linkedin),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is WorkflowContributor => !!item);
|
||||||
|
return contributors.length ? contributors : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contributorFromName(name: unknown): WorkflowContributor {
|
||||||
|
return {
|
||||||
|
...DEFAULT_WORKFLOW_CONTRIBUTOR,
|
||||||
|
name: normalizeOptionalString(name) ?? DEFAULT_WORKFLOW_CONTRIBUTOR.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveWorkflowAccess(
|
async function resolveWorkflowAccess(
|
||||||
workflowId: string,
|
workflowId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|
@ -72,56 +243,166 @@ async function resolveWorkflowAccess(
|
||||||
return { workflow: workflowRecord, allowEdit: !!share.allow_edit, isOwner: false };
|
return { workflow: workflowRecord, allowEdit: !!share.allow_edit, isOwner: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toOpenSourceSubmissionSummary(
|
||||||
|
row: OpenSourceSubmissionRow,
|
||||||
|
): OpenSourceSubmissionSummary {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
status: row.status,
|
||||||
|
submitted_at: row.submitted_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
reviewed_at: row.reviewed_at ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLatestOpenSourceSubmission(
|
||||||
|
db: Db,
|
||||||
|
workflowId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<OpenSourceSubmissionSummary | null> {
|
||||||
|
const { data, error } = await db
|
||||||
|
.from("workflow_open_source_submissions")
|
||||||
|
.select("id, status, submitted_at, updated_at, reviewed_at")
|
||||||
|
.eq("workflow_id", workflowId)
|
||||||
|
.eq("submitted_by_user_id", userId)
|
||||||
|
.order("submitted_at", { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
if (error) throw error;
|
||||||
|
return data ? toOpenSourceSubmissionSummary(data as OpenSourceSubmissionRow) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOpenSourceSnapshot(
|
||||||
|
workflow: WorkflowRecord,
|
||||||
|
contributors: WorkflowContributor[],
|
||||||
|
contributorMode: "named" | "anonymous",
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
workflow_id: workflow.id,
|
||||||
|
metadata: {
|
||||||
|
...metadataFromWorkflowRecord(workflow),
|
||||||
|
contributors,
|
||||||
|
},
|
||||||
|
skill_md: workflow.prompt_md ?? null,
|
||||||
|
columns_config: workflow.columns_config ?? null,
|
||||||
|
contributor_mode: contributorMode,
|
||||||
|
created_at: workflow.created_at ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateOpenSourceWorkflow(workflow: WorkflowRecord): string | null {
|
||||||
|
if (workflow.type === "assistant") {
|
||||||
|
return typeof workflow.prompt_md === "string" && workflow.prompt_md.trim()
|
||||||
|
? null
|
||||||
|
: "Assistant workflows need instructions before they can be opened source.";
|
||||||
|
}
|
||||||
|
if (workflow.type === "tabular") {
|
||||||
|
return Array.isArray(workflow.columns_config) && workflow.columns_config.length > 0
|
||||||
|
? null
|
||||||
|
: "Tabular workflows need at least one column before they can be opened source.";
|
||||||
|
}
|
||||||
|
return "Workflow type must be 'assistant' or 'tabular'.";
|
||||||
|
}
|
||||||
|
|
||||||
// GET /workflows
|
// GET /workflows
|
||||||
workflowsRouter.get("/", requireAuth, asyncRoute(async (req, res) => {
|
workflowsRouter.get("/", requireAuth, asyncRoute(async (req, res) => {
|
||||||
const userId = res.locals.userId as string;
|
const userId = res.locals.userId as string;
|
||||||
const userEmail = res.locals.userEmail as string | undefined;
|
const userEmail = res.locals.userEmail as string | undefined;
|
||||||
const { type } = req.query as { type?: string };
|
const { type } = req.query as { type?: string };
|
||||||
const db = createServerSupabase();
|
const db = createServerSupabase();
|
||||||
|
const workflowType = typeof type === "string" && type ? type : null;
|
||||||
|
|
||||||
const { data, error } = await db.rpc("get_workflows_overview", {
|
const { data, error } = await db.rpc("get_workflows_overview", {
|
||||||
p_user_id: userId,
|
p_user_id: userId,
|
||||||
p_user_email: userEmail ?? null,
|
p_user_email: userEmail ?? null,
|
||||||
p_type: typeof type === "string" && type ? type : null,
|
p_type: workflowType,
|
||||||
});
|
});
|
||||||
if (error) return void res.status(500).json({ detail: error.message });
|
if (error) {
|
||||||
|
return void res.status(500).json({ detail: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
res.json(data ?? []);
|
const systemWorkflows = SYSTEM_WORKFLOWS.filter(
|
||||||
|
(workflow) => !workflowType || workflow.metadata.type === workflowType,
|
||||||
|
).map(withSystemWorkflowAccess);
|
||||||
|
const databaseWorkflows = ((data ?? []) as WorkflowRecord[]).filter(
|
||||||
|
(workflow) => !SYSTEM_WORKFLOW_IDS.has(workflow.id),
|
||||||
|
).map(withDatabaseWorkflow);
|
||||||
|
|
||||||
|
res.json([...systemWorkflows, ...databaseWorkflows]);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// POST /workflows
|
// POST /workflows
|
||||||
workflowsRouter.post("/", requireAuth, asyncRoute(async (req, res) => {
|
workflowsRouter.post("/", requireAuth, asyncRoute(async (req, res) => {
|
||||||
const userId = res.locals.userId as string;
|
const userId = res.locals.userId as string;
|
||||||
const { title, type, prompt_md, columns_config, practice } = req.body as {
|
const {
|
||||||
title: string;
|
metadata,
|
||||||
type: string;
|
skill_md,
|
||||||
prompt_md?: string;
|
columns_config,
|
||||||
|
} = req.body as {
|
||||||
|
metadata?: Partial<WorkflowMetadata>;
|
||||||
|
skill_md?: string;
|
||||||
columns_config?: unknown;
|
columns_config?: unknown;
|
||||||
practice?: string | null;
|
|
||||||
};
|
};
|
||||||
|
const title = metadata?.title;
|
||||||
|
const type = metadata?.type;
|
||||||
if (!title?.trim())
|
if (!title?.trim())
|
||||||
return void res.status(400).json({ detail: "title is required" });
|
return void res.status(400).json({ detail: "metadata.title is required" });
|
||||||
if (!["assistant", "tabular"].includes(type))
|
if (type !== "assistant" && type !== "tabular")
|
||||||
return void res
|
return void res
|
||||||
.status(400)
|
.status(400)
|
||||||
.json({ detail: "type must be 'assistant' or 'tabular'" });
|
.json({ detail: "metadata.type must be 'assistant' or 'tabular'" });
|
||||||
|
|
||||||
const db = createServerSupabase();
|
const db = createServerSupabase();
|
||||||
|
devLog("[workflows/create] request", {
|
||||||
|
userId,
|
||||||
|
title: title.trim(),
|
||||||
|
type,
|
||||||
|
hasSkill: typeof skill_md === "string" && skill_md.length > 0,
|
||||||
|
columnCount: Array.isArray(columns_config) ? columns_config.length : null,
|
||||||
|
language:
|
||||||
|
normalizeOptionalString(metadata?.language) ?? DEFAULT_WORKFLOW_LANGUAGE,
|
||||||
|
practice: metadata?.practice ?? null,
|
||||||
|
jurisdictions:
|
||||||
|
normalizeJurisdictions(metadata?.jurisdictions) ??
|
||||||
|
DEFAULT_WORKFLOW_JURISDICTIONS,
|
||||||
|
});
|
||||||
const { data, error } = await db
|
const { data, error } = await db
|
||||||
.from("workflows")
|
.from("workflows")
|
||||||
.insert({
|
.insert({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
type,
|
type,
|
||||||
prompt_md: prompt_md ?? null,
|
prompt_md: skill_md ?? null,
|
||||||
columns_config: columns_config ?? null,
|
columns_config: columns_config ?? null,
|
||||||
practice: practice ?? null,
|
language:
|
||||||
is_system: false,
|
normalizeOptionalString(metadata?.language) ?? DEFAULT_WORKFLOW_LANGUAGE,
|
||||||
|
practice:
|
||||||
|
normalizeOptionalString(metadata?.practice) ?? DEFAULT_WORKFLOW_PRACTICE,
|
||||||
|
jurisdictions:
|
||||||
|
normalizeJurisdictions(metadata?.jurisdictions) ??
|
||||||
|
DEFAULT_WORKFLOW_JURISDICTIONS,
|
||||||
})
|
})
|
||||||
.select("*")
|
.select("*")
|
||||||
.single();
|
.single();
|
||||||
if (error) return void res.status(500).json({ detail: error.message });
|
if (error) {
|
||||||
res.status(201).json(data);
|
devLog("[workflows/create] insert error", {
|
||||||
|
userId,
|
||||||
|
title: title.trim(),
|
||||||
|
type,
|
||||||
|
code: error.code,
|
||||||
|
message: error.message,
|
||||||
|
details: error.details,
|
||||||
|
hint: error.hint,
|
||||||
|
});
|
||||||
|
return void res.status(500).json({ detail: error.message });
|
||||||
|
}
|
||||||
|
devLog("[workflows/create] inserted", {
|
||||||
|
id: data?.id,
|
||||||
|
user_id: data?.user_id,
|
||||||
|
title: data?.title,
|
||||||
|
type: data?.type,
|
||||||
|
});
|
||||||
|
res.status(201).json(withDatabaseWorkflow(data as WorkflowRecord));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
async function handleWorkflowUpdate(req: Request, res: Response) {
|
async function handleWorkflowUpdate(req: Request, res: Response) {
|
||||||
|
|
@ -129,15 +410,21 @@ async function handleWorkflowUpdate(req: Request, res: Response) {
|
||||||
const userEmail = res.locals.userEmail as string | undefined;
|
const userEmail = res.locals.userEmail as string | undefined;
|
||||||
const { workflowId } = req.params;
|
const { workflowId } = req.params;
|
||||||
const updates: Record<string, unknown> = {};
|
const updates: Record<string, unknown> = {};
|
||||||
if (req.body.title != null) updates.title = req.body.title;
|
const metadata = req.body.metadata as Partial<WorkflowMetadata> | undefined;
|
||||||
if (req.body.prompt_md != null) updates.prompt_md = req.body.prompt_md;
|
if (metadata?.title != null) updates.title = metadata.title;
|
||||||
|
if (req.body.skill_md != null) updates.prompt_md = req.body.skill_md;
|
||||||
if (req.body.columns_config != null)
|
if (req.body.columns_config != null)
|
||||||
updates.columns_config = req.body.columns_config;
|
updates.columns_config = req.body.columns_config;
|
||||||
if ("practice" in req.body) updates.practice = req.body.practice ?? null;
|
if (metadata && "language" in metadata)
|
||||||
|
updates.language = normalizeOptionalString(metadata.language);
|
||||||
|
if (metadata && "practice" in metadata)
|
||||||
|
updates.practice = metadata.practice ?? null;
|
||||||
|
if (metadata && "jurisdictions" in metadata)
|
||||||
|
updates.jurisdictions = normalizeJurisdictions(metadata.jurisdictions);
|
||||||
|
|
||||||
const db = createServerSupabase();
|
const db = createServerSupabase();
|
||||||
const access = await resolveWorkflowAccess(workflowId, userId, userEmail, db);
|
const access = await resolveWorkflowAccess(workflowId, userId, userEmail, db);
|
||||||
if (!access || access.workflow.is_system || !access.allowEdit) {
|
if (!access || !access.allowEdit) {
|
||||||
return void res
|
return void res
|
||||||
.status(404)
|
.status(404)
|
||||||
.json({ detail: "Workflow not found or not editable" });
|
.json({ detail: "Workflow not found or not editable" });
|
||||||
|
|
@ -146,7 +433,6 @@ async function handleWorkflowUpdate(req: Request, res: Response) {
|
||||||
.from("workflows")
|
.from("workflows")
|
||||||
.update(updates)
|
.update(updates)
|
||||||
.eq("id", workflowId)
|
.eq("id", workflowId)
|
||||||
.eq("is_system", false)
|
|
||||||
.select("*")
|
.select("*")
|
||||||
.single();
|
.single();
|
||||||
if (error || !data)
|
if (error || !data)
|
||||||
|
|
@ -154,7 +440,7 @@ async function handleWorkflowUpdate(req: Request, res: Response) {
|
||||||
.status(404)
|
.status(404)
|
||||||
.json({ detail: "Workflow not found or not editable" });
|
.json({ detail: "Workflow not found or not editable" });
|
||||||
res.json(
|
res.json(
|
||||||
withWorkflowAccess(data, {
|
withWorkflowAccess(withDatabaseWorkflow(data as WorkflowRecord), {
|
||||||
allowEdit: access.allowEdit,
|
allowEdit: access.allowEdit,
|
||||||
isOwner: access.isOwner,
|
isOwner: access.isOwner,
|
||||||
}),
|
}),
|
||||||
|
|
@ -171,13 +457,19 @@ workflowsRouter.patch("/:workflowId", requireAuth, asyncRoute(handleWorkflowUpda
|
||||||
workflowsRouter.delete("/:workflowId", requireAuth, asyncRoute(async (req, res) => {
|
workflowsRouter.delete("/:workflowId", requireAuth, asyncRoute(async (req, res) => {
|
||||||
const userId = res.locals.userId as string;
|
const userId = res.locals.userId as string;
|
||||||
const { workflowId } = req.params;
|
const { workflowId } = req.params;
|
||||||
|
const systemWorkflow = SYSTEM_WORKFLOWS.find(
|
||||||
|
(workflow) => workflow.id === workflowId,
|
||||||
|
);
|
||||||
|
if (systemWorkflow) {
|
||||||
|
return void res.json(withSystemWorkflowAccess(systemWorkflow));
|
||||||
|
}
|
||||||
|
|
||||||
const db = createServerSupabase();
|
const db = createServerSupabase();
|
||||||
const { error } = await db
|
const { error } = await db
|
||||||
.from("workflows")
|
.from("workflows")
|
||||||
.delete()
|
.delete()
|
||||||
.eq("id", workflowId)
|
.eq("id", workflowId)
|
||||||
.eq("user_id", userId)
|
.eq("user_id", userId);
|
||||||
.eq("is_system", false);
|
|
||||||
if (error) return void res.status(500).json({ detail: error.message });
|
if (error) return void res.status(500).json({ detail: error.message });
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
}));
|
}));
|
||||||
|
|
@ -222,20 +514,160 @@ workflowsRouter.delete("/hidden/:workflowId", requireAuth, asyncRoute(async (req
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// POST /workflows/:workflowId/open-source
|
||||||
|
workflowsRouter.post("/:workflowId/open-source", requireAuth, asyncRoute(async (req, res) => {
|
||||||
|
if (!WORKFLOW_CONTRIBUTIONS_ENABLED) {
|
||||||
|
return void res.status(404).json({ detail: "Workflow contributions are disabled" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = res.locals.userId as string;
|
||||||
|
const userEmail = res.locals.userEmail as string | undefined;
|
||||||
|
const { workflowId } = req.params;
|
||||||
|
const openSourceBody = req.body as {
|
||||||
|
contributor_mode?: unknown;
|
||||||
|
contributor?: unknown;
|
||||||
|
};
|
||||||
|
const requestedContributorMode =
|
||||||
|
openSourceBody.contributor_mode === "named"
|
||||||
|
? "named"
|
||||||
|
: "anonymous";
|
||||||
|
const db = createServerSupabase();
|
||||||
|
|
||||||
|
const { data: workflow, error: workflowError } = await db
|
||||||
|
.from("workflows")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", workflowId)
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.maybeSingle();
|
||||||
|
if (workflowError) {
|
||||||
|
return void res.status(500).json({ detail: workflowError.message });
|
||||||
|
}
|
||||||
|
if (!workflow) {
|
||||||
|
return void res
|
||||||
|
.status(404)
|
||||||
|
.json({ detail: "Workflow not found or not open-sourceable" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflowRecord = workflow as WorkflowRecord;
|
||||||
|
const validationError = validateOpenSourceWorkflow(workflowRecord);
|
||||||
|
if (validationError) {
|
||||||
|
return void res.status(400).json({ detail: validationError });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: profile } = await db
|
||||||
|
.from("user_profiles")
|
||||||
|
.select("display_name")
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.maybeSingle();
|
||||||
|
const submitterName =
|
||||||
|
typeof profile?.display_name === "string" && profile.display_name.trim()
|
||||||
|
? profile.display_name.trim()
|
||||||
|
: null;
|
||||||
|
const submittedContributor =
|
||||||
|
normalizeContributors([openSourceBody.contributor])?.[0] ??
|
||||||
|
contributorFromName(submitterName || userEmail);
|
||||||
|
const publicContributors =
|
||||||
|
requestedContributorMode === "named"
|
||||||
|
? [submittedContributor]
|
||||||
|
: [DEFAULT_WORKFLOW_CONTRIBUTOR];
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const snapshot = buildOpenSourceSnapshot(
|
||||||
|
workflowRecord,
|
||||||
|
publicContributors,
|
||||||
|
requestedContributorMode,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: pendingSubmission, error: pendingError } = await db
|
||||||
|
.from("workflow_open_source_submissions")
|
||||||
|
.select("*")
|
||||||
|
.eq("workflow_id", workflowId)
|
||||||
|
.eq("submitted_by_user_id", userId)
|
||||||
|
.eq("status", "pending")
|
||||||
|
.maybeSingle();
|
||||||
|
if (pendingError) {
|
||||||
|
return void res.status(500).json({ detail: pendingError.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingSubmission) {
|
||||||
|
const { data: updated, error: updateError } = await db
|
||||||
|
.from("workflow_open_source_submissions")
|
||||||
|
.update({
|
||||||
|
submitter_email: userEmail ?? null,
|
||||||
|
submitter_name:
|
||||||
|
requestedContributorMode === "named" ? submitterName : null,
|
||||||
|
contributor_mode: requestedContributorMode,
|
||||||
|
snapshot,
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
.eq("id", pendingSubmission.id)
|
||||||
|
.select("id, status, submitted_at, updated_at, reviewed_at")
|
||||||
|
.single();
|
||||||
|
if (updateError || !updated) {
|
||||||
|
return void res.status(500).json({
|
||||||
|
detail: updateError?.message ?? "Failed to update submission",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return void res.json({
|
||||||
|
...toOpenSourceSubmissionSummary(updated as OpenSourceSubmissionRow),
|
||||||
|
mode: "updated",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: created, error: createError } = await db
|
||||||
|
.from("workflow_open_source_submissions")
|
||||||
|
.insert({
|
||||||
|
workflow_id: workflowId,
|
||||||
|
submitted_by_user_id: userId,
|
||||||
|
submitter_email: userEmail ?? null,
|
||||||
|
submitter_name:
|
||||||
|
requestedContributorMode === "named" ? submitterName : null,
|
||||||
|
contributor_mode: requestedContributorMode,
|
||||||
|
status: "pending",
|
||||||
|
snapshot,
|
||||||
|
submitted_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
.select("id, status, submitted_at, updated_at, reviewed_at")
|
||||||
|
.single();
|
||||||
|
if (createError || !created) {
|
||||||
|
return void res.status(500).json({
|
||||||
|
detail: createError?.message ?? "Failed to create submission",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
...toOpenSourceSubmissionSummary(created as OpenSourceSubmissionRow),
|
||||||
|
mode: "created",
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
// GET /workflows/:workflowId
|
// GET /workflows/:workflowId
|
||||||
workflowsRouter.get("/:workflowId", requireAuth, asyncRoute(async (req, res) => {
|
workflowsRouter.get("/:workflowId", requireAuth, asyncRoute(async (req, res) => {
|
||||||
const userId = res.locals.userId as string;
|
const userId = res.locals.userId as string;
|
||||||
const userEmail = res.locals.userEmail as string | undefined;
|
const userEmail = res.locals.userEmail as string | undefined;
|
||||||
const { workflowId } = req.params;
|
const { workflowId } = req.params;
|
||||||
|
const systemWorkflow = SYSTEM_WORKFLOWS.find(
|
||||||
|
(workflow) => workflow.id === workflowId,
|
||||||
|
);
|
||||||
|
if (systemWorkflow) {
|
||||||
|
return void res.json(withSystemWorkflowAccess(systemWorkflow));
|
||||||
|
}
|
||||||
|
|
||||||
const db = createServerSupabase();
|
const db = createServerSupabase();
|
||||||
const access = await resolveWorkflowAccess(workflowId, userId, userEmail, db);
|
const access = await resolveWorkflowAccess(workflowId, userId, userEmail, db);
|
||||||
if (!access)
|
if (!access)
|
||||||
return void res.status(404).json({ detail: "Workflow not found" });
|
return void res.status(404).json({ detail: "Workflow not found" });
|
||||||
|
const openSourceSubmission = access.isOwner
|
||||||
|
? await getLatestOpenSourceSubmission(db, workflowId, userId)
|
||||||
|
: null;
|
||||||
res.json(
|
res.json(
|
||||||
withWorkflowAccess(access.workflow, {
|
withOpenSourceSubmission(
|
||||||
allowEdit: access.allowEdit,
|
withWorkflowAccess(withDatabaseWorkflow(access.workflow), {
|
||||||
isOwner: access.isOwner,
|
allowEdit: access.allowEdit,
|
||||||
}),
|
isOwner: access.isOwner,
|
||||||
|
}),
|
||||||
|
openSourceSubmission,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -250,7 +682,6 @@ workflowsRouter.get("/:workflowId/shares", requireAuth, asyncRoute(async (req, r
|
||||||
.select("id")
|
.select("id")
|
||||||
.eq("id", workflowId)
|
.eq("id", workflowId)
|
||||||
.eq("user_id", userId)
|
.eq("user_id", userId)
|
||||||
.eq("is_system", false)
|
|
||||||
.single();
|
.single();
|
||||||
if (!wf) return void res.status(404).json({ detail: "Workflow not found or not editable" });
|
if (!wf) return void res.status(404).json({ detail: "Workflow not found or not editable" });
|
||||||
|
|
||||||
|
|
@ -308,13 +739,19 @@ workflowsRouter.post("/:workflowId/share", requireAuth, asyncRoute(async (req, r
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = createServerSupabase();
|
const db = createServerSupabase();
|
||||||
|
const missingSharedUsers = await findMissingUserEmails(db, normalizedEmails);
|
||||||
|
if (missingSharedUsers.length > 0) {
|
||||||
|
return void res.status(400).json({
|
||||||
|
detail: `${missingSharedUsers[0]} does not belong to a Mike user.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Verify ownership
|
// Verify ownership
|
||||||
const { data: wf } = await db
|
const { data: wf } = await db
|
||||||
.from("workflows")
|
.from("workflows")
|
||||||
.select("id")
|
.select("id")
|
||||||
.eq("id", workflowId)
|
.eq("id", workflowId)
|
||||||
.eq("user_id", userId)
|
.eq("user_id", userId)
|
||||||
.eq("is_system", false)
|
|
||||||
.single();
|
.single();
|
||||||
if (!wf) return void res.status(404).json({ detail: "Workflow not found or not editable" });
|
if (!wf) return void res.status(404).json({ detail: "Workflow not found or not editable" });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,5 +16,5 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/__tests__/**"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
38
backend/vitest.config.mts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
include: ["src/**/*.test.ts"],
|
||||||
|
exclude: ["dist/**", "node_modules/**"],
|
||||||
|
// Generous timeouts so cold-start module transform/import latency
|
||||||
|
// can't cause spurious timeout failures on a cold CI runner. Warm
|
||||||
|
// tests finish in ~1s; this only guards the pathological cold case —
|
||||||
|
// it does not mask hangs.
|
||||||
|
testTimeout: 20000,
|
||||||
|
hookTimeout: 20000,
|
||||||
|
coverage: {
|
||||||
|
provider: "v8",
|
||||||
|
reporter: ["text", "lcov"],
|
||||||
|
include: ["src/lib/**"],
|
||||||
|
// No-regression RATCHET floor, not a target. src/lib/** spans the
|
||||||
|
// tested libs (access, storage keys/dispositions, downloadTokens,
|
||||||
|
// userApiKeys provider/env checks, chat doc resolution, safeError,
|
||||||
|
// llm model resolution, chat citations, userLookup,
|
||||||
|
// documentVersions, userDataCleanup) AND the large, still-untested
|
||||||
|
// feature libs (courtlistener, mcp, chat tool dispatch, llm
|
||||||
|
// providers, spreadsheet/docx handling), so the global number is
|
||||||
|
// still low. Measured on this tree: 11.18% statements, 10.98%
|
||||||
|
// branches, 14.43% functions, 10.91% lines. These floors sit just
|
||||||
|
// below that (rounded down to whole percents) so CI fails on a
|
||||||
|
// *drop*. Floors only go up: when you add tests, raise them in the
|
||||||
|
// same PR. Backlog + per-area status: docs/testing-coverage.md.
|
||||||
|
thresholds: {
|
||||||
|
statements: 11,
|
||||||
|
branches: 10,
|
||||||
|
functions: 14,
|
||||||
|
lines: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
122
docs/testing-coverage.md
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
# Backend unit-test coverage
|
||||||
|
|
||||||
|
The backend has a Vitest unit-test harness over `backend/src/lib/**`. This doc
|
||||||
|
tracks what is covered, what still needs tests, and how the coverage ratchet
|
||||||
|
works — so you can pick up a checkbox below and land it as a small PR.
|
||||||
|
|
||||||
|
## Running the tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
npm test # run all unit tests
|
||||||
|
npm run test:coverage # same, plus the per-file coverage table + floor check
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests live in `backend/src/lib/__tests__/*.test.ts`. Read a couple of the
|
||||||
|
existing suites first (`access.test.ts`, `userDataCleanup.test.ts`) and match
|
||||||
|
their conventions: plain in-memory Supabase query mocks (no network, no real
|
||||||
|
database), one `describe` block per exported function, and tests that assert
|
||||||
|
current behavior.
|
||||||
|
|
||||||
|
## Current coverage (measured 2026-07)
|
||||||
|
|
||||||
|
Per-area statement coverage from `npm run test:coverage`:
|
||||||
|
|
||||||
|
| Lib area | % statements | Tested? |
|
||||||
|
| --- | ---: | :---: |
|
||||||
|
| `lib/safeError.ts` | 100 | ✓ |
|
||||||
|
| `lib/userDataCleanup.ts` | 100 | ✓ |
|
||||||
|
| `lib/llm/models.ts` | 100 | ✓ |
|
||||||
|
| `lib/documentVersions.ts` | 98 | ✓ |
|
||||||
|
| `lib/chat/citations.ts` | 98 | ✓ |
|
||||||
|
| `lib/userLookup.ts` | 91 | ✓ |
|
||||||
|
| `lib/downloadTokens.ts` | 87 | ✓ |
|
||||||
|
| `lib/chat/types.ts` | 85 | ✓ |
|
||||||
|
| `lib/access.ts` | 76 | ✓ |
|
||||||
|
| `lib/storage.ts` | 33 | partial — key/disposition helpers only |
|
||||||
|
| `lib/userApiKeys.ts` | 13 | partial — provider/env helpers only |
|
||||||
|
| `lib/documentTypes.ts`, `lib/userSettings.ts`, `lib/upload.ts`, `lib/officeText.ts` | 0 | ✗ |
|
||||||
|
| `lib/convert.ts`, `lib/spreadsheet.ts`, `lib/docxTrackedChanges.ts` | 0 | ✗ |
|
||||||
|
| `lib/userDataExport.ts` | 0 | ✗ |
|
||||||
|
| `lib/courtlistener.ts`, `lib/systemWorkflows.ts` | 0 | ✗ |
|
||||||
|
| `lib/chat/prompts.ts`, `lib/chat/contextBuilders.ts`, `lib/chat/streaming.ts` | 0 | ✗ |
|
||||||
|
| `lib/chat/tools/**` (schemas, documentOps, toolDispatcher) | 0 | ✗ |
|
||||||
|
| `lib/llm/**` (providers, tools, index, rawStreamLog) | ~4 | ✗ (only models.ts) |
|
||||||
|
| `lib/mcp/**` (client, servers, oauth, types) | 0 | ✗ |
|
||||||
|
|
||||||
|
Global: **11.18% statements / 10.98% branches / 14.43% functions / 10.91%
|
||||||
|
lines**. The global number is low because `src/lib/**` includes several very
|
||||||
|
large feature libs (toolDispatcher, documentOps, systemWorkflows,
|
||||||
|
courtlistener, docxTrackedChanges) that dominate the line count.
|
||||||
|
|
||||||
|
## TODO — untested libs, in priority order
|
||||||
|
|
||||||
|
Each item is meant to be one self-contained PR: add the suite, then raise the
|
||||||
|
floors in `backend/vitest.config.mts` to just below the new measured numbers.
|
||||||
|
Size is a rough guess: S ≈ an hour, M ≈ an afternoon.
|
||||||
|
|
||||||
|
- [ ] `lib/documentTypes.ts` — pure catalog/lookup of document types; assert
|
||||||
|
known types resolve and unknown inputs fall back sanely. (S)
|
||||||
|
- [ ] `lib/chat/prompts.ts` — pure prompt builders; assert key instructions and
|
||||||
|
interpolated values appear in the output strings. (S)
|
||||||
|
- [ ] `lib/userSettings.ts` — title/tabular model resolution from which API
|
||||||
|
keys a user has; reuse the Supabase mock pattern from
|
||||||
|
`userLookup.test.ts`. (S)
|
||||||
|
- [ ] `lib/upload.ts` — multer wrapper: assert LIMIT_FILE_SIZE maps to a 413
|
||||||
|
with the right message and other errors pass through. (S)
|
||||||
|
- [ ] `lib/officeText.ts` — office XML text extraction; build a tiny in-memory
|
||||||
|
zip fixture with JSZip and assert extracted/decoded text. (S)
|
||||||
|
- [ ] `lib/chat/tools/toolSchemas.ts` — assert every tool schema has a name,
|
||||||
|
description, and well-formed parameters (guards against schema drift). (S)
|
||||||
|
- [ ] `lib/userApiKeys.ts` (rest) — encrypt/decrypt round-trip and DB
|
||||||
|
load/store paths with a mocked Supabase client. (M)
|
||||||
|
- [ ] `lib/storage.ts` (rest) — S3 upload/download/list/delete wrappers with a
|
||||||
|
mocked AWS SDK client. (M)
|
||||||
|
- [ ] `lib/userDataExport.ts` — export assembly: given seeded mock tables,
|
||||||
|
assert the export contains the user's data and nobody else's. (M)
|
||||||
|
- [ ] `lib/spreadsheet.ts` — parse a small in-memory xlsx fixture; assert sheet
|
||||||
|
and cell extraction, including empty/edge cells. (M)
|
||||||
|
- [ ] `lib/chat/contextBuilders.ts` — context assembly from doc stores; assert
|
||||||
|
doc labels, truncation, and ordering. (M)
|
||||||
|
- [ ] `lib/docxTrackedChanges.ts` — tracked-changes XML round-trip on a minimal
|
||||||
|
docx fixture: insert/delete runs, accept/reject. High value: document
|
||||||
|
integrity. (M)
|
||||||
|
- [ ] `lib/courtlistener.ts` — API client with mocked fetch: query building,
|
||||||
|
pagination, and error paths. Legal-research correctness. (M)
|
||||||
|
- [ ] `lib/systemWorkflows.ts` — mostly data: assert workflow definitions are
|
||||||
|
well-formed (unique ids, non-empty skill markdown). (S)
|
||||||
|
- [ ] `lib/llm/tools.ts` + `lib/llm/index.ts` — provider-neutral tool plumbing
|
||||||
|
and provider selection with mocked provider modules. (M)
|
||||||
|
- [ ] `lib/mcp/types.ts` + `lib/mcp/servers.ts` — server config validation and
|
||||||
|
allow-listing logic; security relevant. (M)
|
||||||
|
- [ ] `lib/mcp/client.ts` + `lib/mcp/oauth.ts` — connection lifecycle and OAuth
|
||||||
|
token handling with a mocked MCP SDK; security relevant. (M)
|
||||||
|
- [ ] `lib/llm/rawStreamLog.ts` — log path construction and redaction with a
|
||||||
|
mocked fs. (S)
|
||||||
|
- [ ] `lib/chat/tools/documentOps.ts` — start with the pure helpers (diff/match
|
||||||
|
utilities), not the full tool handlers. (M)
|
||||||
|
- [ ] `lib/chat/tools/toolDispatcher.ts` — dispatch table routing and argument
|
||||||
|
validation with stubbed tools; don't try to cover every tool body. (M)
|
||||||
|
- [ ] `lib/chat/streaming.ts` + `lib/llm/{claude,gemini,openai}.ts` — streaming
|
||||||
|
loops and provider adapters; hardest to unit test, consider extracting
|
||||||
|
pure chunk-parsing helpers first. (M)
|
||||||
|
|
||||||
|
Not worth unit testing directly: `lib/supabase.ts` and `lib/convert.ts` are
|
||||||
|
thin wrappers around external services (Supabase auth, LibreOffice); they are
|
||||||
|
better exercised by the e2e suite.
|
||||||
|
|
||||||
|
## Ratchet policy
|
||||||
|
|
||||||
|
`backend/vitest.config.mts` enforces global coverage **floors** (currently
|
||||||
|
statements 11 / branches 10 / functions 14 / lines 10). They are a
|
||||||
|
no-regression ratchet, not a target:
|
||||||
|
|
||||||
|
- **Floors only go up.** Never lower them to get a PR green — that means your
|
||||||
|
change removed tested behavior or added a large untested lib; add tests
|
||||||
|
instead.
|
||||||
|
- **Raise them in the same PR that adds tests.** After your suite passes, run
|
||||||
|
`npm run test:coverage`, take the new global numbers, and set each floor to
|
||||||
|
the measured value rounded down to a whole percent.
|
||||||
|
- Keep the measured numbers in the config comment and the table above honest
|
||||||
|
when you do.
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1025.0",
|
"@aws-sdk/client-s3": "^3.1025.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1025.0",
|
"@aws-sdk/s3-request-presigner": "^3.1025.0",
|
||||||
|
"@fortune-sheet/core": "^1.0.4",
|
||||||
|
"@fortune-sheet/react": "^1.0.4",
|
||||||
"@opennextjs/cloudflare": "^1.19.9",
|
"@opennextjs/cloudflare": "^1.19.9",
|
||||||
"@openrouter/sdk": "^0.3.11",
|
"@openrouter/sdk": "^0.3.11",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
|
@ -15,6 +17,8 @@
|
||||||
"@supabase/auth-helpers-nextjs": "^0.10.0",
|
"@supabase/auth-helpers-nextjs": "^0.10.0",
|
||||||
"@supabase/auth-js": "^2.101.1",
|
"@supabase/auth-js": "^2.101.1",
|
||||||
"@supabase/supabase-js": "^2.81.1",
|
"@supabase/supabase-js": "^2.81.1",
|
||||||
|
"@tiptap/core": "^3.22.3",
|
||||||
|
"@tiptap/extension-table": "^3.22.3",
|
||||||
"@tiptap/pm": "^3.22.3",
|
"@tiptap/pm": "^3.22.3",
|
||||||
"@tiptap/react": "^3.22.3",
|
"@tiptap/react": "^3.22.3",
|
||||||
"@tiptap/starter-kit": "^3.22.3",
|
"@tiptap/starter-kit": "^3.22.3",
|
||||||
|
|
@ -28,6 +32,7 @@
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"katex": "^0.16.27",
|
"katex": "^0.16.27",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
|
"luckyexcel": "^1.0.1",
|
||||||
"mammoth": "^1.11.0",
|
"mammoth": "^1.11.0",
|
||||||
"marked": "^17.0.1",
|
"marked": "^17.0.1",
|
||||||
"next": "^16.2.6",
|
"next": "^16.2.6",
|
||||||
|
|
@ -329,6 +334,14 @@
|
||||||
|
|
||||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||||
|
|
||||||
|
"@formulajs/formulajs": ["@formulajs/formulajs@2.9.3", "", { "dependencies": { "bessel": "^1.0.2", "jstat": "^1.9.2" }, "bin": { "implementation-stats": "bin/implementation-stats" } }, "sha512-WpgiuJaBl/Hcda9Ti8a7mlnw/vUZkJrtjABvojz6P6mo6d8EscudyA7iAt/kTLsgi0c8zfmzQd/Yjb4ASFkw+g=="],
|
||||||
|
|
||||||
|
"@fortune-sheet/core": ["@fortune-sheet/core@1.0.4", "", { "dependencies": { "@fortune-sheet/formula-parser": "^0.2.13", "dayjs": "^1.11.0", "immer": "^9.0.12", "lodash": "^4.17.21", "numeral": "^2.0.6", "uuid": "^8.3.2" } }, "sha512-CDnUVebfvtT++CymNRw0qnY4sz6zYO3KZazlH+nG6otkRkZ05Bvt7NXqVhmKKSSxIvJR4R6h50abu/dVGBpjpw=="],
|
||||||
|
|
||||||
|
"@fortune-sheet/formula-parser": ["@fortune-sheet/formula-parser@0.2.13", "", { "dependencies": { "@formulajs/formulajs": "^2.9.3", "tiny-emitter": "^2.1.0" } }, "sha512-za2ZVQ5ZfMSPCtL8MdqhCthPip7AoeU4MPyd5UDo4RCl9/arrv1Jz0y755mACVVi4suSZxrzWoJGDkLmofoSmw=="],
|
||||||
|
|
||||||
|
"@fortune-sheet/react": ["@fortune-sheet/react@1.0.4", "", { "dependencies": { "@fortune-sheet/core": "^1.0.4", "@types/regenerator-runtime": "^0.13.6", "immer": "^9.0.12", "lodash": "^4.17.21", "regenerator-runtime": "^0.14.1" }, "peerDependencies": { "react": ">= 18.2", "react-dom": ">= 18.2" } }, "sha512-v+BU5mmp2hhUe4gRF+vOJ3j8zZkzHU0kzhTZzp+Nn00wA/RArMr+CO+SAluKlCPAsTZgPwmgOEK685WPqJ5XNw=="],
|
||||||
|
|
||||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||||
|
|
||||||
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
||||||
|
|
@ -745,6 +758,8 @@
|
||||||
|
|
||||||
"@tiptap/extension-strike": ["@tiptap/extension-strike@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-aRHWQj42HiailXSC9LkKYM3jWMcSeGwOjbqM4PiuxQZmHVDRFmeHkfJItOdn2cSHaO0vuEVK+TvrWUWsBFi3pg=="],
|
"@tiptap/extension-strike": ["@tiptap/extension-strike@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-aRHWQj42HiailXSC9LkKYM3jWMcSeGwOjbqM4PiuxQZmHVDRFmeHkfJItOdn2cSHaO0vuEVK+TvrWUWsBFi3pg=="],
|
||||||
|
|
||||||
|
"@tiptap/extension-table": ["@tiptap/extension-table@3.27.3", "", { "peerDependencies": { "@tiptap/core": "3.27.3", "@tiptap/pm": "3.27.3" } }, "sha512-P7iQ0SkkDWZwnXzQDOVFLXktXlX67VQ3s2YxoJYKTHlc8rPZ4UCaSogFq0G2APyBlizlY3LbMNrDeZAVFOD8wQ=="],
|
||||||
|
|
||||||
"@tiptap/extension-text": ["@tiptap/extension-text@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-mM69uUW5cSxIhyEpWXi/YcfyupcJMDLCPEfYi62awH0iOP/LRoCv/nHjJq4Hyj/KxRJbe8HKwIUnqaCUf7m5Pg=="],
|
"@tiptap/extension-text": ["@tiptap/extension-text@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-mM69uUW5cSxIhyEpWXi/YcfyupcJMDLCPEfYi62awH0iOP/LRoCv/nHjJq4Hyj/KxRJbe8HKwIUnqaCUf7m5Pg=="],
|
||||||
|
|
||||||
"@tiptap/extension-underline": ["@tiptap/extension-underline@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-08kGdbhIrA6h10GWXqOkqIveaBj5tmxclK208/nUIAlonI9hPd739vu7fmVtpnmqCnSSNpoRtU4u6Gj5at0ZpA=="],
|
"@tiptap/extension-underline": ["@tiptap/extension-underline@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-08kGdbhIrA6h10GWXqOkqIveaBj5tmxclK208/nUIAlonI9hPd739vu7fmVtpnmqCnSSNpoRtU4u6Gj5at0ZpA=="],
|
||||||
|
|
@ -819,6 +834,8 @@
|
||||||
|
|
||||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||||
|
|
||||||
|
"@types/regenerator-runtime": ["@types/regenerator-runtime@0.13.8", "", {}, "sha512-jjKoBekfYDH331060tZhosdJVDnXIXx+T8Iw2h2T4HEds6Ddb2lr0JxD15+XPKlXwRHRNgZoY+4Fb2ykoqzHBg=="],
|
||||||
|
|
||||||
"@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="],
|
"@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="],
|
||||||
|
|
||||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||||
|
|
@ -973,6 +990,8 @@
|
||||||
|
|
||||||
"bcp-47-match": ["bcp-47-match@2.0.3", "", {}, "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ=="],
|
"bcp-47-match": ["bcp-47-match@2.0.3", "", {}, "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ=="],
|
||||||
|
|
||||||
|
"bessel": ["bessel@1.0.2", "", {}, "sha512-Al3nHGQGqDYqqinXhQzmwmcRToe/3WyBv4N8aZc5Pef8xw2neZlR9VPi84Sa23JtgWcucu18HxVZrnI0fn2etw=="],
|
||||||
|
|
||||||
"big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="],
|
"big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="],
|
||||||
|
|
||||||
"binary": ["binary@0.3.0", "", { "dependencies": { "buffers": "~0.1.1", "chainsaw": "~0.1.0" } }, "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg=="],
|
"binary": ["binary@0.3.0", "", { "dependencies": { "buffers": "~0.1.1", "chainsaw": "~0.1.0" } }, "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg=="],
|
||||||
|
|
@ -1429,7 +1448,7 @@
|
||||||
|
|
||||||
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
|
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
|
||||||
|
|
||||||
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
|
"immer": ["immer@9.0.21", "", {}, "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA=="],
|
||||||
|
|
||||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||||
|
|
||||||
|
|
@ -1541,6 +1560,8 @@
|
||||||
|
|
||||||
"json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
|
"json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
|
||||||
|
|
||||||
|
"jstat": ["jstat@1.9.6", "", {}, "sha512-rPBkJbK2TnA8pzs93QcDDPlKcrtZWuuCo2dVR0TFLOJSxhqfWOVCSp8aV3/oSbn+4uY4yw1URtLpHQedtmXfug=="],
|
||||||
|
|
||||||
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
|
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
|
||||||
|
|
||||||
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
|
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
|
||||||
|
|
@ -1593,6 +1614,8 @@
|
||||||
|
|
||||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||||
|
|
||||||
|
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
|
||||||
|
|
||||||
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
|
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
|
||||||
|
|
||||||
"lodash.difference": ["lodash.difference@4.5.0", "", {}, "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA=="],
|
"lodash.difference": ["lodash.difference@4.5.0", "", {}, "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA=="],
|
||||||
|
|
@ -1631,6 +1654,8 @@
|
||||||
|
|
||||||
"lucide-react": ["lucide-react@0.553.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-BRgX5zrWmNy/lkVAe0dXBgd7XQdZ3HTf+Hwe3c9WK6dqgnj9h+hxV+MDncM88xDWlCq27+TKvHGE70ViODNILw=="],
|
"lucide-react": ["lucide-react@0.553.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-BRgX5zrWmNy/lkVAe0dXBgd7XQdZ3HTf+Hwe3c9WK6dqgnj9h+hxV+MDncM88xDWlCq27+TKvHGE70ViODNILw=="],
|
||||||
|
|
||||||
|
"luckyexcel": ["luckyexcel@1.0.1", "", { "dependencies": { "jszip": "^3.5.0" } }, "sha512-hvbJmCXNp/vST/huA6sieDn32Ib8bd80L9aIu5ZGxniJvZle7VlpHZrl6weLGaEnX99+t7cPAoYGqrqbfZp/AQ=="],
|
||||||
|
|
||||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
"mammoth": ["mammoth@1.11.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", "bluebird": "~3.4.0", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", "path-is-absolute": "^1.0.0", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, "bin": { "mammoth": "bin/mammoth" } }, "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ=="],
|
"mammoth": ["mammoth@1.11.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", "bluebird": "~3.4.0", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", "path-is-absolute": "^1.0.0", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, "bin": { "mammoth": "bin/mammoth" } }, "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ=="],
|
||||||
|
|
@ -1795,6 +1820,8 @@
|
||||||
|
|
||||||
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
||||||
|
|
||||||
|
"numeral": ["numeral@2.0.6", "", {}, "sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA=="],
|
||||||
|
|
||||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||||
|
|
||||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||||
|
|
@ -1951,6 +1978,8 @@
|
||||||
|
|
||||||
"refractor": ["refractor@5.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^9.0.0", "parse-entities": "^4.0.0" } }, "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw=="],
|
"refractor": ["refractor@5.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^9.0.0", "parse-entities": "^4.0.0" } }, "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw=="],
|
||||||
|
|
||||||
|
"regenerator-runtime": ["regenerator-runtime@0.14.1", "", {}, "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="],
|
||||||
|
|
||||||
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
|
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
|
||||||
|
|
||||||
"rehype": ["rehype@13.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "rehype-parse": "^9.0.0", "rehype-stringify": "^10.0.0", "unified": "^11.0.0" } }, "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A=="],
|
"rehype": ["rehype@13.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "rehype-parse": "^9.0.0", "rehype-stringify": "^10.0.0", "unified": "^11.0.0" } }, "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A=="],
|
||||||
|
|
@ -2131,6 +2160,8 @@
|
||||||
|
|
||||||
"terser": ["terser@5.16.9", "", { "dependencies": { "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg=="],
|
"terser": ["terser@5.16.9", "", { "dependencies": { "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg=="],
|
||||||
|
|
||||||
|
"tiny-emitter": ["tiny-emitter@2.1.0", "", {}, "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q=="],
|
||||||
|
|
||||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||||
|
|
||||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||||
|
|
@ -2511,6 +2542,8 @@
|
||||||
|
|
||||||
"readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
|
"readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
|
||||||
|
|
||||||
|
"recharts/immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
|
||||||
|
|
||||||
"rehype-attr/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="],
|
"rehype-attr/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="],
|
||||||
|
|
||||||
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||||
|
|
|
||||||
3464
frontend/package-lock.json
generated
|
|
@ -7,6 +7,7 @@
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
|
"test": "vitest run",
|
||||||
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
|
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
|
||||||
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
|
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
|
||||||
"upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload",
|
"upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload",
|
||||||
|
|
@ -15,6 +16,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1025.0",
|
"@aws-sdk/client-s3": "^3.1025.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1025.0",
|
"@aws-sdk/s3-request-presigner": "^3.1025.0",
|
||||||
|
"@fortune-sheet/core": "^1.0.4",
|
||||||
|
"@fortune-sheet/react": "^1.0.4",
|
||||||
"@opennextjs/cloudflare": "^1.19.9",
|
"@opennextjs/cloudflare": "^1.19.9",
|
||||||
"@openrouter/sdk": "^0.3.11",
|
"@openrouter/sdk": "^0.3.11",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
|
@ -23,6 +26,8 @@
|
||||||
"@supabase/auth-helpers-nextjs": "^0.10.0",
|
"@supabase/auth-helpers-nextjs": "^0.10.0",
|
||||||
"@supabase/auth-js": "^2.101.1",
|
"@supabase/auth-js": "^2.101.1",
|
||||||
"@supabase/supabase-js": "^2.81.1",
|
"@supabase/supabase-js": "^2.81.1",
|
||||||
|
"@tiptap/core": "^3.22.3",
|
||||||
|
"@tiptap/extension-table": "^3.22.3",
|
||||||
"@tiptap/pm": "^3.22.3",
|
"@tiptap/pm": "^3.22.3",
|
||||||
"@tiptap/react": "^3.22.3",
|
"@tiptap/react": "^3.22.3",
|
||||||
"@tiptap/starter-kit": "^3.22.3",
|
"@tiptap/starter-kit": "^3.22.3",
|
||||||
|
|
@ -36,6 +41,7 @@
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"katex": "^0.16.27",
|
"katex": "^0.16.27",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
|
"luckyexcel": "^1.0.1",
|
||||||
"mammoth": "^1.11.0",
|
"mammoth": "^1.11.0",
|
||||||
"marked": "^17.0.1",
|
"marked": "^17.0.1",
|
||||||
"next": "^16.2.6",
|
"next": "^16.2.6",
|
||||||
|
|
@ -56,18 +62,24 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/marked": "^5.0.2",
|
"@types/marked": "^5.0.2",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"baseline-browser-mapping": "^2.9.11",
|
"baseline-browser-mapping": "^2.9.11",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "^16.2.6",
|
"eslint-config-next": "^16.2.6",
|
||||||
|
"jsdom": "^27.0.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
|
"vitest": "^4.1.9",
|
||||||
"wrangler": "^4.90.0"
|
"wrangler": "^4.90.0"
|
||||||
},
|
},
|
||||||
"license": "AGPL-3.0-only"
|
"license": "AGPL-3.0-only"
|
||||||
|
|
|
||||||
30
frontend/public/icons/app-sidebar/chat.svg
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="body" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#7AB8FF" stop-opacity="0.96"/>
|
||||||
|
<stop offset="0.45" stop-color="#4F92F7" stop-opacity="0.95"/>
|
||||||
|
<stop offset="1" stop-color="#2563EB" stop-opacity="0.97"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="rim" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.75"/>
|
||||||
|
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0.12"/>
|
||||||
|
<stop offset="1" stop-color="#172554" stop-opacity="0.45"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="sheen">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF"/>
|
||||||
|
<stop offset="0.6" stop-color="#FFFFFF" stop-opacity="0.7"/>
|
||||||
|
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<clipPath id="clip">
|
||||||
|
<path d="M18 6 H46 C52.6 6 58 11.4 58 18 V34 C58 40.6 52.6 46 46 46 H30 L19.4 55.4 C17.7 56.9 15 55.7 15 53.4 V45.6 C9.8 44.2 6 39.5 6 34 V18 C6 11.4 11.4 6 18 6 Z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<path d="M18 6 H46 C52.6 6 58 11.4 58 18 V34 C58 40.6 52.6 46 46 46 H30 L19.4 55.4 C17.7 56.9 15 55.7 15 53.4 V45.6 C9.8 44.2 6 39.5 6 34 V18 C6 11.4 11.4 6 18 6 Z"
|
||||||
|
fill="url(#body)" stroke="url(#rim)" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<g clip-path="url(#clip)">
|
||||||
|
<ellipse cx="32" cy="10" rx="23" ry="6" fill="url(#sheen)" opacity="0.28"/>
|
||||||
|
<ellipse cx="14" cy="18" rx="5" ry="10" fill="url(#sheen)" opacity="0.16"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
44
frontend/public/icons/app-sidebar/folder-closed.svg
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="back" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#F9D66A"/>
|
||||||
|
<stop offset="1" stop-color="#D69B1B"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="front" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFE68A" stop-opacity="0.98"/>
|
||||||
|
<stop offset="0.5" stop-color="#F5BE3F" stop-opacity="0.98"/>
|
||||||
|
<stop offset="1" stop-color="#D89414" stop-opacity="0.98"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="rim" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.75"/>
|
||||||
|
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0.15"/>
|
||||||
|
<stop offset="1" stop-color="#8A5A0A" stop-opacity="0.42"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="rimBack" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.65"/>
|
||||||
|
<stop offset="0.4" stop-color="#FFFFFF" stop-opacity="0.1"/>
|
||||||
|
<stop offset="1" stop-color="#8A5A0A" stop-opacity="0.46"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="sheen">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF"/>
|
||||||
|
<stop offset="0.6" stop-color="#FFFFFF" stop-opacity="0.7"/>
|
||||||
|
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<clipPath id="clipFront">
|
||||||
|
<path d="M11.5 19.5 H52.5 C55 19.5 57 21.5 57 24 L56.35 49.4 C56.28 51.95 54.15 54 51.6 54 H12.4 C9.85 54 7.72 51.95 7.65 49.4 L7 24 C7 21.5 9 19.5 11.5 19.5 Z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- back panel with tab, extends to bottom -->
|
||||||
|
<path d="M8 47 V15 C8 12.2 10.2 10 13 10 H23 C24.4 10 25.7 10.6 26.7 11.6 L31 16 H51 C53.8 16 56 18.2 56 21 V47 C56 49.8 53.8 52 51 52 H13 C10.2 52 8 49.8 8 47 Z"
|
||||||
|
fill="url(#back)" stroke="url(#rimBack)" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- front panel, covers all but the tab and a thin strip of back -->
|
||||||
|
<path d="M11.5 19.5 H52.5 C55 19.5 57 21.5 57 24 L56.35 49.4 C56.28 51.95 54.15 54 51.6 54 H12.4 C9.85 54 7.72 51.95 7.65 49.4 L7 24 C7 21.5 9 19.5 11.5 19.5 Z"
|
||||||
|
fill="url(#front)" stroke="url(#rim)" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<g clip-path="url(#clipFront)">
|
||||||
|
<ellipse cx="32" cy="23" rx="26" ry="6" fill="url(#sheen)" opacity="0.45"/>
|
||||||
|
<ellipse cx="11.5" cy="37" rx="4" ry="11" fill="url(#sheen)" opacity="0.26"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
54
frontend/public/icons/app-sidebar/folder-open.svg
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="back" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#F9D66A"/>
|
||||||
|
<stop offset="1" stop-color="#D69B1B"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="front" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFE68A" stop-opacity="0.98"/>
|
||||||
|
<stop offset="0.5" stop-color="#F5BE3F" stop-opacity="0.98"/>
|
||||||
|
<stop offset="1" stop-color="#D89414" stop-opacity="0.98"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paper" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF"/>
|
||||||
|
<stop offset="1" stop-color="#E5E7EB"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="rim" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.75"/>
|
||||||
|
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0.15"/>
|
||||||
|
<stop offset="1" stop-color="#8A5A0A" stop-opacity="0.42"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="rimBack" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.65"/>
|
||||||
|
<stop offset="0.4" stop-color="#FFFFFF" stop-opacity="0.1"/>
|
||||||
|
<stop offset="1" stop-color="#8A5A0A" stop-opacity="0.46"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="sheen">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF"/>
|
||||||
|
<stop offset="0.6" stop-color="#FFFFFF" stop-opacity="0.7"/>
|
||||||
|
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<clipPath id="clipFront">
|
||||||
|
<path d="M5.5 26 H58.5 C60.2 26 61.4 27.6 61 29.2 L55.9 50.3 C55.3 52.5 53.3 54 51 54 H13 C10.7 54 8.7 52.5 8.1 50.3 L3 29.2 C2.6 27.6 3.8 26 5.5 26 Z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- folder back -->
|
||||||
|
<path d="M8 46 V15 C8 12.2 10.2 10 13 10 H23 C24.4 10 25.7 10.6 26.7 11.6 L31 16 H51 C53.8 16 56 18.2 56 21 V46 Z"
|
||||||
|
fill="url(#back)" stroke="url(#rimBack)" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- paper peeking out -->
|
||||||
|
<rect x="14" y="17" width="36" height="14" rx="2.5" fill="url(#paper)"/>
|
||||||
|
<g stroke="#D1D5DB" stroke-width="1.4" stroke-linecap="round" opacity="0.8">
|
||||||
|
<line x1="19" y1="21.5" x2="45" y2="21.5"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- open front, tilted out, tighter corners -->
|
||||||
|
<path d="M5.5 26 H58.5 C60.2 26 61.4 27.6 61 29.2 L55.9 50.3 C55.3 52.5 53.3 54 51 54 H13 C10.7 54 8.7 52.5 8.1 50.3 L3 29.2 C2.6 27.6 3.8 26 5.5 26 Z"
|
||||||
|
fill="url(#front)" stroke="url(#rim)" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<g clip-path="url(#clipFront)">
|
||||||
|
<ellipse cx="32" cy="30" rx="26" ry="6" fill="url(#sheen)" opacity="0.45"/>
|
||||||
|
<ellipse cx="11.5" cy="40" rx="4" ry="10" fill="url(#sheen)" opacity="0.26"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
96
frontend/public/icons/app-sidebar/library.svg
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="leatherBrown" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0" stop-color="#4A2815"/>
|
||||||
|
<stop offset="0.18" stop-color="#8A5230"/>
|
||||||
|
<stop offset="0.52" stop-color="#A66A42"/>
|
||||||
|
<stop offset="0.82" stop-color="#744025"/>
|
||||||
|
<stop offset="1" stop-color="#351A0D"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="leatherBlue" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0" stop-color="#102B52"/>
|
||||||
|
<stop offset="0.18" stop-color="#235A96"/>
|
||||||
|
<stop offset="0.52" stop-color="#3678B9"/>
|
||||||
|
<stop offset="0.82" stop-color="#194A82"/>
|
||||||
|
<stop offset="1" stop-color="#0A203E"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="leatherRed" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0" stop-color="#54151A"/>
|
||||||
|
<stop offset="0.18" stop-color="#9C2930"/>
|
||||||
|
<stop offset="0.52" stop-color="#C44349"/>
|
||||||
|
<stop offset="0.82" stop-color="#812027"/>
|
||||||
|
<stop offset="1" stop-color="#3C0B10"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="leatherGreen" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0" stop-color="#09251B"/>
|
||||||
|
<stop offset="0.2" stop-color="#14533B"/>
|
||||||
|
<stop offset="0.5" stop-color="#287B58"/>
|
||||||
|
<stop offset="0.82" stop-color="#104730"/>
|
||||||
|
<stop offset="1" stop-color="#061B13"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="spineGloss" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.08"/>
|
||||||
|
<stop offset="0.3" stop-color="#FFFFFF" stop-opacity="0.38"/>
|
||||||
|
<stop offset="0.58" stop-color="#FFFFFF" stop-opacity="0.04"/>
|
||||||
|
<stop offset="1" stop-color="#000000" stop-opacity="0.42"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="rim" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#F5E0C8" stop-opacity="0.65"/>
|
||||||
|
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0.1"/>
|
||||||
|
<stop offset="1" stop-color="#2A1508" stop-opacity="0.55"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="gilt" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#F7DE9C"/>
|
||||||
|
<stop offset="0.5" stop-color="#D9A94E"/>
|
||||||
|
<stop offset="1" stop-color="#B07E2C"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- brown book -->
|
||||||
|
<g>
|
||||||
|
<rect x="6" y="3.8" width="11" height="49.2" rx="3" fill="url(#leatherBrown)" stroke="url(#rim)" stroke-width="1.4"/>
|
||||||
|
<rect x="6.7" y="4.5" width="9.6" height="47.8" rx="2.4" fill="url(#spineGloss)"/>
|
||||||
|
<path d="M8.1 8V49" stroke="#F0C7A2" stroke-opacity="0.28" stroke-width="0.65"/>
|
||||||
|
<path d="M15.3 7.5V49.5" stroke="#241007" stroke-opacity="0.55" stroke-width="0.8"/>
|
||||||
|
<rect x="7.4" y="10.8" width="8.2" height="1.5" rx="0.75" fill="url(#gilt)" opacity="0.95"/>
|
||||||
|
<rect x="7.9" y="19" width="7.2" height="5" rx="1" fill="#3C2210" opacity="0.55"/>
|
||||||
|
<rect x="7.4" y="38" width="8.2" height="1.2" rx="0.6" fill="url(#gilt)" opacity="0.8"/>
|
||||||
|
<rect x="7.4" y="46.5" width="8.2" height="1.5" rx="0.75" fill="url(#gilt)" opacity="0.95"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- blue book -->
|
||||||
|
<g>
|
||||||
|
<rect x="19" y="8" width="11.5" height="45" rx="3" fill="url(#leatherBlue)" stroke="url(#rim)" stroke-width="1.4"/>
|
||||||
|
<rect x="19.7" y="8.7" width="10.1" height="43.6" rx="2.4" fill="url(#spineGloss)"/>
|
||||||
|
<path d="M21.2 12V49" stroke="#B9DCFF" stroke-opacity="0.3" stroke-width="0.65"/>
|
||||||
|
<path d="M28.8 11.5V49.5" stroke="#071A31" stroke-opacity="0.62" stroke-width="0.8"/>
|
||||||
|
<rect x="20.5" y="14.6" width="8.5" height="1.5" rx="0.75" fill="url(#gilt)" opacity="0.95"/>
|
||||||
|
<rect x="21" y="22.6" width="7.5" height="5" rx="1" fill="#071D38" opacity="0.65"/>
|
||||||
|
<rect x="20.5" y="39.5" width="8.5" height="1.2" rx="0.6" fill="url(#gilt)" opacity="0.8"/>
|
||||||
|
<rect x="20.5" y="46.8" width="8.5" height="1.5" rx="0.75" fill="url(#gilt)" opacity="0.95"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- red book -->
|
||||||
|
<g>
|
||||||
|
<rect x="32.5" y="5" width="11" height="48" rx="3" fill="url(#leatherRed)" stroke="url(#rim)" stroke-width="1.4"/>
|
||||||
|
<rect x="33.2" y="5.7" width="9.6" height="46.6" rx="2.4" fill="url(#spineGloss)"/>
|
||||||
|
<path d="M34.6 9V49" stroke="#FFD0D2" stroke-opacity="0.3" stroke-width="0.65"/>
|
||||||
|
<path d="M41.8 8.5V49.5" stroke="#35070B" stroke-opacity="0.62" stroke-width="0.8"/>
|
||||||
|
<rect x="33.9" y="11.6" width="8.2" height="1.5" rx="0.75" fill="url(#gilt)" opacity="0.95"/>
|
||||||
|
<rect x="34.4" y="19.6" width="7.2" height="5" rx="1" fill="#430B10" opacity="0.68"/>
|
||||||
|
<rect x="33.9" y="38.5" width="8.2" height="1.2" rx="0.6" fill="url(#gilt)" opacity="0.8"/>
|
||||||
|
<rect x="33.9" y="46.5" width="8.2" height="1.5" rx="0.75" fill="url(#gilt)" opacity="0.95"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- green book, leaning against the red book -->
|
||||||
|
<g transform="rotate(-9 62.1 52)">
|
||||||
|
<rect x="51.6" y="5.2" width="10.5" height="46.8" rx="3" fill="url(#leatherGreen)" stroke="url(#rim)" stroke-width="1.4"/>
|
||||||
|
<rect x="52.3" y="5.9" width="9.1" height="45.4" rx="2.4" fill="url(#spineGloss)"/>
|
||||||
|
<path d="M53.7 9.5V48.5" stroke="#FFFFFF" stroke-opacity="0.24" stroke-width="0.65"/>
|
||||||
|
<path d="M60.4 9V49" stroke="#000000" stroke-opacity="0.72" stroke-width="0.8"/>
|
||||||
|
<rect x="52.95" y="11.8" width="7.8" height="1.5" rx="0.75" fill="url(#gilt)" opacity="0.95"/>
|
||||||
|
<rect x="53.45" y="19.8" width="6.8" height="5" rx="1" fill="#052219" opacity="0.76"/>
|
||||||
|
<rect x="52.95" y="37.5" width="7.8" height="1.2" rx="0.6" fill="url(#gilt)" opacity="0.8"/>
|
||||||
|
<rect x="52.95" y="45.4" width="7.8" height="1.5" rx="0.75" fill="url(#gilt)" opacity="0.95"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.4 KiB |
42
frontend/public/icons/app-sidebar/project-closed.svg
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="back" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#5B6472"/>
|
||||||
|
<stop offset="1" stop-color="#2F3947"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="front" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#B6BCC6" stop-opacity="0.96"/>
|
||||||
|
<stop offset="0.48" stop-color="#596272" stop-opacity="0.96"/>
|
||||||
|
<stop offset="1" stop-color="#424C5A" stop-opacity="0.98"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="rim" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.75"/>
|
||||||
|
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0.15"/>
|
||||||
|
<stop offset="1" stop-color="#1F2937" stop-opacity="0.46"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="rimBack" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.65"/>
|
||||||
|
<stop offset="0.4" stop-color="#FFFFFF" stop-opacity="0.1"/>
|
||||||
|
<stop offset="1" stop-color="#1F2937" stop-opacity="0.5"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="sheen">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF"/>
|
||||||
|
<stop offset="0.6" stop-color="#FFFFFF" stop-opacity="0.7"/>
|
||||||
|
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<clipPath id="clipFront">
|
||||||
|
<path d="M11.5 19.5 H52.5 C55 19.5 57 21.5 57 24 L56.35 49.4 C56.28 51.95 54.15 54 51.6 54 H12.4 C9.85 54 7.72 51.95 7.65 49.4 L7 24 C7 21.5 9 19.5 11.5 19.5 Z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<path d="M8 47 V15 C8 12.2 10.2 10 13 10 H23 C24.4 10 25.7 10.6 26.7 11.6 L31 16 H51 C53.8 16 56 18.2 56 21 V47 C56 49.8 53.8 52 51 52 H13 C10.2 52 8 49.8 8 47 Z"
|
||||||
|
fill="url(#back)" stroke="url(#rimBack)" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<path d="M11.5 19.5 H52.5 C55 19.5 57 21.5 57 24 L56.35 49.4 C56.28 51.95 54.15 54 51.6 54 H12.4 C9.85 54 7.72 51.95 7.65 49.4 L7 24 C7 21.5 9 19.5 11.5 19.5 Z"
|
||||||
|
fill="url(#front)" stroke="url(#rim)" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<g clip-path="url(#clipFront)">
|
||||||
|
<ellipse cx="32" cy="23" rx="26" ry="6" fill="url(#sheen)" opacity="0.45"/>
|
||||||
|
<ellipse cx="11.5" cy="37" rx="4" ry="11" fill="url(#sheen)" opacity="0.26"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
51
frontend/public/icons/app-sidebar/project-opened.svg
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="back" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#5B6472"/>
|
||||||
|
<stop offset="1" stop-color="#2F3947"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="front" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#B6BCC6" stop-opacity="0.96"/>
|
||||||
|
<stop offset="0.48" stop-color="#596272" stop-opacity="0.96"/>
|
||||||
|
<stop offset="1" stop-color="#424C5A" stop-opacity="0.98"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paper" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF"/>
|
||||||
|
<stop offset="1" stop-color="#E5E7EB"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="rim" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.75"/>
|
||||||
|
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0.15"/>
|
||||||
|
<stop offset="1" stop-color="#1F2937" stop-opacity="0.46"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="rimBack" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.65"/>
|
||||||
|
<stop offset="0.4" stop-color="#FFFFFF" stop-opacity="0.1"/>
|
||||||
|
<stop offset="1" stop-color="#1F2937" stop-opacity="0.5"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="sheen">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF"/>
|
||||||
|
<stop offset="0.6" stop-color="#FFFFFF" stop-opacity="0.7"/>
|
||||||
|
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<clipPath id="clipFront">
|
||||||
|
<path d="M5.5 26 H58.5 C60.2 26 61.4 27.6 61 29.2 L55.9 50.3 C55.3 52.5 53.3 54 51 54 H13 C10.7 54 8.7 52.5 8.1 50.3 L3 29.2 C2.6 27.6 3.8 26 5.5 26 Z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<path d="M8 46 V15 C8 12.2 10.2 10 13 10 H23 C24.4 10 25.7 10.6 26.7 11.6 L31 16 H51 C53.8 16 56 18.2 56 21 V46 Z"
|
||||||
|
fill="url(#back)" stroke="url(#rimBack)" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<rect x="14" y="17" width="36" height="14" rx="2.5" fill="url(#paper)"/>
|
||||||
|
<g stroke="#9CA3AF" stroke-width="1.4" stroke-linecap="round" opacity="0.8">
|
||||||
|
<line x1="19" y1="21.5" x2="45" y2="21.5"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<path d="M5.5 26 H58.5 C60.2 26 61.4 27.6 61 29.2 L55.9 50.3 C55.3 52.5 53.3 54 51 54 H13 C10.7 54 8.7 52.5 8.1 50.3 L3 29.2 C2.6 27.6 3.8 26 5.5 26 Z"
|
||||||
|
fill="url(#front)" stroke="url(#rim)" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<g clip-path="url(#clipFront)">
|
||||||
|
<ellipse cx="32" cy="30" rx="26" ry="6" fill="url(#sheen)" opacity="0.45"/>
|
||||||
|
<ellipse cx="11.5" cy="40" rx="4" ry="10" fill="url(#sheen)" opacity="0.26"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
30
frontend/public/icons/app-sidebar/quick-actions.svg
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bolt" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#60A5FA" stop-opacity="0.96"/>
|
||||||
|
<stop offset="0.5" stop-color="#2563EB" stop-opacity="0.95"/>
|
||||||
|
<stop offset="1" stop-color="#1D4ED8" stop-opacity="0.97"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="rim" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.85"/>
|
||||||
|
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0.15"/>
|
||||||
|
<stop offset="1" stop-color="#172554" stop-opacity="0.45"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="sheen">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF"/>
|
||||||
|
<stop offset="0.6" stop-color="#FFFFFF" stop-opacity="0.7"/>
|
||||||
|
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<clipPath id="clipBolt">
|
||||||
|
<path d="M32.91 4.51 L9.79 35.53 Q8 37.94 11 37.94 L29.5 37.94 Q32 37.94 31.72 40.43 L29.64 58.92 Q29.3 61.9 31.09 59.49 L54.21 28.47 Q56 26.06 53 26.06 L34.5 26.06 Q32 26.06 32.28 23.58 L34.36 5.08 Q34.7 2.1 32.91 4.51 Z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<path d="M32.91 4.51 L9.79 35.53 Q8 37.94 11 37.94 L29.5 37.94 Q32 37.94 31.72 40.43 L29.64 58.92 Q29.3 61.9 31.09 59.49 L54.21 28.47 Q56 26.06 53 26.06 L34.5 26.06 Q32 26.06 32.28 23.58 L34.36 5.08 Q34.7 2.1 32.91 4.51 Z"
|
||||||
|
fill="url(#bolt)" stroke="url(#rim)" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
<g clip-path="url(#clipBolt)">
|
||||||
|
<ellipse cx="30.5" cy="7.5" rx="11" ry="5" fill="url(#sheen)" opacity="0.4"/>
|
||||||
|
<ellipse cx="18" cy="33" rx="4" ry="8" fill="url(#sheen)" opacity="0.24" transform="rotate(-30 18 33)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
39
frontend/public/icons/app-sidebar/tabular-review.svg
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="card" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#F9FAFB" stop-opacity="0.96"/>
|
||||||
|
<stop offset="0.5" stop-color="#EDEFF2" stop-opacity="0.94"/>
|
||||||
|
<stop offset="1" stop-color="#D1D5DB" stop-opacity="0.96"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="header" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#34D399"/>
|
||||||
|
<stop offset="1" stop-color="#059669"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="rim" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.9"/>
|
||||||
|
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0.15"/>
|
||||||
|
<stop offset="1" stop-color="#374151" stop-opacity="0.4"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="sheen">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF"/>
|
||||||
|
<stop offset="0.6" stop-color="#FFFFFF" stop-opacity="0.7"/>
|
||||||
|
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<clipPath id="clipCard"><rect x="5" y="9" width="54" height="44" rx="10"/></clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect x="5" y="9" width="54" height="44" rx="10" fill="url(#card)" stroke="url(#rim)" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<g clip-path="url(#clipCard)">
|
||||||
|
<rect x="5" y="9" width="54" height="12" fill="url(#header)"/>
|
||||||
|
<ellipse cx="32" cy="12" rx="25" ry="5" fill="url(#sheen)" opacity="0.6"/>
|
||||||
|
<g stroke="#9CA3AF" stroke-width="1.6" opacity="0.9">
|
||||||
|
<line x1="23" y1="21" x2="23" y2="53"/>
|
||||||
|
<line x1="41" y1="21" x2="41" y2="53"/>
|
||||||
|
<line x1="5" y1="31.67" x2="59" y2="31.67"/>
|
||||||
|
<line x1="5" y1="42.33" x2="59" y2="42.33"/>
|
||||||
|
</g>
|
||||||
|
<ellipse cx="10" cy="34" rx="4" ry="12" fill="url(#sheen)" opacity="0.38"/>
|
||||||
|
<ellipse cx="32" cy="24" rx="26" ry="4" fill="url(#sheen)" opacity="0.45"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
71
frontend/public/icons/app-sidebar/workflow.svg
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="nPurple" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#C4B5FD" stop-opacity="0.96"/>
|
||||||
|
<stop offset="0.5" stop-color="#8B5CF6" stop-opacity="0.95"/>
|
||||||
|
<stop offset="1" stop-color="#6D28D9" stop-opacity="0.97"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="nBlue" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#93C5FD" stop-opacity="0.96"/>
|
||||||
|
<stop offset="0.5" stop-color="#3B82F6" stop-opacity="0.95"/>
|
||||||
|
<stop offset="1" stop-color="#1D4ED8" stop-opacity="0.97"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="nYellow" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FDE68A" stop-opacity="0.96"/>
|
||||||
|
<stop offset="0.5" stop-color="#F59E0B" stop-opacity="0.95"/>
|
||||||
|
<stop offset="1" stop-color="#B45309" stop-opacity="0.97"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="nRed" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FCA5A5" stop-opacity="0.96"/>
|
||||||
|
<stop offset="0.5" stop-color="#EF4444" stop-opacity="0.95"/>
|
||||||
|
<stop offset="1" stop-color="#B91C1C" stop-opacity="0.97"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="rim" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.85"/>
|
||||||
|
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0.15"/>
|
||||||
|
<stop offset="1" stop-color="#1F2937" stop-opacity="0.45"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="pipe" x1="8" y1="8" x2="56" y2="56" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#D1D5DB" stop-opacity="0.9"/>
|
||||||
|
<stop offset="1" stop-color="#9CA3AF" stop-opacity="0.9"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="sheen">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF"/>
|
||||||
|
<stop offset="0.6" stop-color="#FFFFFF" stop-opacity="0.7"/>
|
||||||
|
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- grey glass pipes -->
|
||||||
|
<g fill="none" stroke="url(#pipe)" stroke-width="5" stroke-linecap="round">
|
||||||
|
<path d="M32 11 L11 32"/>
|
||||||
|
<path d="M11 32 H53"/>
|
||||||
|
<path d="M53 32 L32 53"/>
|
||||||
|
</g>
|
||||||
|
<g fill="none" stroke="#FFFFFF" stroke-width="1.3" stroke-linecap="round" opacity="0.55">
|
||||||
|
<path d="M26.4 15.6 L15.6 26.4"/>
|
||||||
|
<path d="M18 31.2 H46"/>
|
||||||
|
<path d="M47.7 36.8 L36.8 47.7"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- top node: purple -->
|
||||||
|
<g>
|
||||||
|
<circle cx="32" cy="11" r="9.1" fill="url(#nPurple)" stroke="url(#rim)" stroke-width="1.4"/>
|
||||||
|
<ellipse cx="30.4" cy="7.6" rx="4.7" ry="2.4" fill="url(#sheen)" opacity="0.7"/>
|
||||||
|
</g>
|
||||||
|
<!-- left node: blue -->
|
||||||
|
<g>
|
||||||
|
<circle cx="11" cy="32" r="9.1" fill="url(#nBlue)" stroke="url(#rim)" stroke-width="1.4"/>
|
||||||
|
<ellipse cx="9.4" cy="28.6" rx="4.7" ry="2.4" fill="url(#sheen)" opacity="0.7"/>
|
||||||
|
</g>
|
||||||
|
<!-- right node: yellow -->
|
||||||
|
<g>
|
||||||
|
<circle cx="53" cy="32" r="9.1" fill="url(#nYellow)" stroke="url(#rim)" stroke-width="1.4"/>
|
||||||
|
<ellipse cx="51.4" cy="28.6" rx="4.7" ry="2.4" fill="url(#sheen)" opacity="0.7"/>
|
||||||
|
</g>
|
||||||
|
<!-- bottom node: red -->
|
||||||
|
<g>
|
||||||
|
<circle cx="32" cy="53" r="9.1" fill="url(#nRed)" stroke="url(#rim)" stroke-width="1.4"/>
|
||||||
|
<ellipse cx="30.4" cy="49.6" rx="4.7" ry="2.4" fill="url(#sheen)" opacity="0.7"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
1
frontend/public/icons/file-types/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
16
frontend/public/icons/file-types/chat.svg
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="chatBody" x1="4" y1="3.5" x2="21" y2="19.5" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#7DD3FC"/>
|
||||||
|
<stop offset="0.48" stop-color="#2588F0"/>
|
||||||
|
<stop offset="1" stop-color="#0F56B8"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="chatGlass" x1="5" y1="5" x2="15.8" y2="12.4" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.72"/>
|
||||||
|
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0.12"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d="M2.8 7.1c0-2.05 1.65-3.7 3.7-3.7h11c2.05 0 3.7 1.65 3.7 3.7v5.7c0 2.05-1.65 3.7-3.7 3.7H8.35l-4.3 3.2c-.52.36-1.25 0-1.25-.63V12.8V7.1Z" fill="#0B3F8C" opacity="0.22"/>
|
||||||
|
<path d="M2.8 6.65c0-2.05 1.65-3.7 3.7-3.7h11c2.05 0 3.7 1.65 3.7 3.7v5.7c0 2.05-1.65 3.7-3.7 3.7H8.35l-4.3 3.2c-.52.36-1.25 0-1.25-.63v-6.27v-5.7Z" fill="url(#chatBody)"/>
|
||||||
|
<path d="M5.25 5.85c0-.5.4-.9.9-.9h8.95c.9 0 1.24 1.18.49 1.66l-9.25 5.98c-.48.31-1.09-.04-1.09-.61V5.85Z" fill="url(#chatGlass)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
54
frontend/public/icons/file-types/excel.svg
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" role="img" aria-label="Excel document">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="excelDocumentGradient" x1="9.615" y1="3" x2="60.385" y2="69" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#34D399" stop-opacity="0.97" />
|
||||||
|
<stop offset="1" stop-color="#087F5B" stop-opacity="0.97" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="excelRim" x1="36" y1="3" x2="36" y2="69" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.85" />
|
||||||
|
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0.18" />
|
||||||
|
<stop offset="1" stop-color="#065F46" stop-opacity="0.45" />
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="excelSoft"><feGaussianBlur stdDeviation="1.8" /></filter>
|
||||||
|
<clipPath id="excelClip">
|
||||||
|
<path d="M21.615 3h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12V15c0-6.627 5.373-12 12-12Z" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<path
|
||||||
|
fill="#065F46"
|
||||||
|
opacity="0.2"
|
||||||
|
d="M21.615 3.9h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12v-42c0-6.627 5.373-12 12-12Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="url(#excelDocumentGradient)"
|
||||||
|
d="M21.615 3h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12V15c0-6.627 5.373-12 12-12Z"
|
||||||
|
/>
|
||||||
|
<g clip-path="url(#excelClip)">
|
||||||
|
<ellipse cx="33" cy="8.5" rx="28" ry="7" fill="#FFFFFF" opacity="0.22" filter="url(#excelSoft)" />
|
||||||
|
<ellipse cx="13" cy="42" rx="4.5" ry="15" fill="#FFFFFF" opacity="0.12" filter="url(#excelSoft)" />
|
||||||
|
</g>
|
||||||
|
<path fill="#74DDB5" d="M45.385 3v18h15L45.385 3Z" />
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="url(#excelRim)"
|
||||||
|
stroke-width="1.8"
|
||||||
|
d="M21.615 3h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12V15c0-6.627 5.373-12 12-12Z"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="20.5"
|
||||||
|
y="34"
|
||||||
|
width="29"
|
||||||
|
height="22"
|
||||||
|
rx="2.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="#FFFFFF"
|
||||||
|
stroke-width="3.25"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="#FFFFFF"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-width="3.25"
|
||||||
|
d="M35 34v22M20.5 45h29"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2 KiB |
41
frontend/public/icons/file-types/pdf.svg
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" role="img" aria-label="PDF document">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="pdfDocumentGradient" x1="9.615" y1="3" x2="60.385" y2="69" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#FF6B6B" stop-opacity="0.97" />
|
||||||
|
<stop offset="1" stop-color="#C1121F" stop-opacity="0.97" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="pdfRim" x1="36" y1="3" x2="36" y2="69" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.85" />
|
||||||
|
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0.18" />
|
||||||
|
<stop offset="1" stop-color="#7F0D16" stop-opacity="0.45" />
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="pdfSoft"><feGaussianBlur stdDeviation="1.8" /></filter>
|
||||||
|
<clipPath id="pdfClip">
|
||||||
|
<path d="M21.615 3h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12V15c0-6.627 5.373-12 12-12Z" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<path
|
||||||
|
fill="#7F0D16"
|
||||||
|
opacity="0.2"
|
||||||
|
d="M21.615 3.9h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12v-42c0-6.627 5.373-12 12-12Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="url(#pdfDocumentGradient)"
|
||||||
|
d="M21.615 3h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12V15c0-6.627 5.373-12 12-12Z"
|
||||||
|
/>
|
||||||
|
<g clip-path="url(#pdfClip)">
|
||||||
|
<ellipse cx="33" cy="8.5" rx="28" ry="7" fill="#FFFFFF" opacity="0.22" filter="url(#pdfSoft)" />
|
||||||
|
<ellipse cx="13" cy="42" rx="4.5" ry="15" fill="#FFFFFF" opacity="0.12" filter="url(#pdfSoft)" />
|
||||||
|
</g>
|
||||||
|
<path fill="#FF9A9A" d="M45.385 3v18h15L45.385 3Z" />
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="url(#pdfRim)"
|
||||||
|
stroke-width="1.8"
|
||||||
|
d="M21.615 3h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12V15c0-6.627 5.373-12 12-12Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FFFFFF"
|
||||||
|
d="M19.5 32a2.5 2.5 0 0 1 2.5-2.5h26a2.5 2.5 0 0 1 0 5H22a2.5 2.5 0 0 1-2.5-2.5Zm0 10.5A2.5 2.5 0 0 1 22 40h26a2.5 2.5 0 0 1 0 5H22a2.5 2.5 0 0 1-2.5-2.5Zm0 10.5a2.5 2.5 0 0 1 2.5-2.5h26a2.5 2.5 0 0 1 0 5H22a2.5 2.5 0 0 1-2.5-2.5Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2 KiB |
47
frontend/public/icons/file-types/ppt.svg
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" role="img" aria-label="PowerPoint document">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="pptDocumentGradient" x1="9.615" y1="3" x2="60.385" y2="69" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#FF7043" stop-opacity="0.97" />
|
||||||
|
<stop offset="1" stop-color="#B3261E" stop-opacity="0.97" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="pptRim" x1="36" y1="3" x2="36" y2="69" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.85" />
|
||||||
|
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0.18" />
|
||||||
|
<stop offset="1" stop-color="#7A1712" stop-opacity="0.45" />
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="pptSoft"><feGaussianBlur stdDeviation="1.8" /></filter>
|
||||||
|
<clipPath id="pptClip">
|
||||||
|
<path d="M21.615 3h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12V15c0-6.627 5.373-12 12-12Z" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<path
|
||||||
|
fill="#7A1712"
|
||||||
|
opacity="0.2"
|
||||||
|
d="M21.615 3.9h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12v-42c0-6.627 5.373-12 12-12Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="url(#pptDocumentGradient)"
|
||||||
|
d="M21.615 3h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12V15c0-6.627 5.373-12 12-12Z"
|
||||||
|
/>
|
||||||
|
<g clip-path="url(#pptClip)">
|
||||||
|
<ellipse cx="33" cy="8.5" rx="28" ry="7" fill="#FFFFFF" opacity="0.22" filter="url(#pptSoft)" />
|
||||||
|
<ellipse cx="13" cy="42" rx="4.5" ry="15" fill="#FFFFFF" opacity="0.12" filter="url(#pptSoft)" />
|
||||||
|
</g>
|
||||||
|
<path fill="#FF9E80" d="M45.385 3v18h15L45.385 3Z" />
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="url(#pptRim)"
|
||||||
|
stroke-width="1.8"
|
||||||
|
d="M21.615 3h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12V15c0-6.627 5.373-12 12-12Z"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="18"
|
||||||
|
y="34.2875"
|
||||||
|
width="34"
|
||||||
|
height="22.425"
|
||||||
|
rx="3"
|
||||||
|
fill="none"
|
||||||
|
stroke="#FFFFFF"
|
||||||
|
stroke-width="3.25"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
41
frontend/public/icons/file-types/word.svg
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" role="img" aria-label="Word document">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="wordDocumentGradient" x1="9.615" y1="3" x2="60.385" y2="69" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#43A5F5" stop-opacity="0.97" />
|
||||||
|
<stop offset="1" stop-color="#064BAE" stop-opacity="0.97" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="wordRim" x1="36" y1="3" x2="36" y2="69" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.85" />
|
||||||
|
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0.18" />
|
||||||
|
<stop offset="1" stop-color="#04347A" stop-opacity="0.45" />
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="wordSoft"><feGaussianBlur stdDeviation="1.8" /></filter>
|
||||||
|
<clipPath id="wordClip">
|
||||||
|
<path d="M21.615 3h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12V15c0-6.627 5.373-12 12-12Z" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<path
|
||||||
|
fill="#04347A"
|
||||||
|
opacity="0.2"
|
||||||
|
d="M21.615 3.9h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12v-42c0-6.627 5.373-12 12-12Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="url(#wordDocumentGradient)"
|
||||||
|
d="M21.615 3h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12V15c0-6.627 5.373-12 12-12Z"
|
||||||
|
/>
|
||||||
|
<g clip-path="url(#wordClip)">
|
||||||
|
<ellipse cx="33" cy="8.5" rx="28" ry="7" fill="#FFFFFF" opacity="0.22" filter="url(#wordSoft)" />
|
||||||
|
<ellipse cx="13" cy="42" rx="4.5" ry="15" fill="#FFFFFF" opacity="0.12" filter="url(#wordSoft)" />
|
||||||
|
</g>
|
||||||
|
<path fill="#63A2E2" d="M45.385 3v18h15L45.385 3Z" />
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="url(#wordRim)"
|
||||||
|
stroke-width="1.8"
|
||||||
|
d="M21.615 3h23.77l15 18v36c0 6.627-5.373 12-12 12h-26.77c-6.627 0-12-5.373-12-12V15c0-6.627 5.373-12 12-12Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FFFFFF"
|
||||||
|
d="M19.5 32a2.5 2.5 0 0 1 2.5-2.5h26a2.5 2.5 0 0 1 0 5H22a2.5 2.5 0 0 1-2.5-2.5Zm0 10.5A2.5 2.5 0 0 1 22 40h26a2.5 2.5 0 0 1 0 5H22a2.5 2.5 0 0 1-2.5-2.5Zm0 10.5a2.5 2.5 0 0 1 2.5-2.5h26a2.5 2.5 0 0 1 0 5H22a2.5 2.5 0 0 1-2.5-2.5Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2 KiB |
24
frontend/public/workflow.svg
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="nodePurple" x1="4" y1="4" x2="10" y2="10" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#C9B9FF"/>
|
||||||
|
<stop offset="1" stop-color="#6A55E8"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="nodeCyan" x1="14" y1="4" x2="20" y2="10" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#8DE2FF"/>
|
||||||
|
<stop offset="1" stop-color="#0E8DD0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="nodeOrange" x1="8" y1="14" x2="16" y2="20" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#FFD88A"/>
|
||||||
|
<stop offset="1" stop-color="#EA6F2F"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d="M8.55 8.35c2.44 1.3 4.46 1.3 6.9 0M8.25 9.1c1.15 2.95 2.05 4.45 3.75 6.4M15.75 9.1c-1.15 2.95-2.05 4.45-3.75 6.4" stroke="#617187" stroke-width="1.45" stroke-linecap="round" opacity="0.72"/>
|
||||||
|
<circle cx="6.8" cy="7.25" r="3.45" fill="#3B2E99" opacity="0.2"/>
|
||||||
|
<circle cx="17.2" cy="7.25" r="3.45" fill="#075985" opacity="0.2"/>
|
||||||
|
<rect x="8.35" y="14.05" width="7.3" height="6.05" rx="2.05" fill="#9A3412" opacity="0.2"/>
|
||||||
|
<circle cx="6.8" cy="6.9" r="3.35" fill="url(#nodePurple)"/>
|
||||||
|
<circle cx="17.2" cy="6.9" r="3.35" fill="url(#nodeCyan)"/>
|
||||||
|
<rect x="8.35" y="13.65" width="7.3" height="6.05" rx="2.05" fill="url(#nodeOrange)"/>
|
||||||
|
<path d="M5.75 5.9h2.1M16.15 5.9h2.1M10.65 15.75h2.7" stroke="#FFFFFF" stroke-width="0.9" stroke-linecap="round" opacity="0.86"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -1,4 +1,4 @@
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/app/lib/utils";
|
||||||
import { accountGlassSectionClassName } from "./accountStyles";
|
import { accountGlassSectionClassName } from "./accountStyles";
|
||||||
|
|
||||||
export function AccountSection({
|
export function AccountSection({
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/app/lib/utils";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
type AccountToggleSize = "sm" | "md";
|
type AccountToggleSize = "sm" | "md";
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/app/lib/utils";
|
||||||
|
|
||||||
export const accountGlassInputClassName = cn(
|
export const accountGlassInputClassName = cn(
|
||||||
"rounded-lg px-3 text-gray-900 placeholder:text-gray-400",
|
"rounded-lg px-3 text-gray-900 placeholder:text-gray-400",
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Eye, EyeOff } from "lucide-react";
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/app/components/ui/input";
|
||||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
import { useUserProfile } from "@/app/contexts/UserProfileContext";
|
||||||
import {
|
import {
|
||||||
MfaVerificationPopup,
|
MfaVerificationPopup,
|
||||||
needsMfaVerification,
|
needsMfaVerification,
|
||||||
} from "@/app/components/shared/MfaVerificationPopup";
|
} from "@/app/components/popups/MfaVerificationPopup";
|
||||||
import { isMfaRequiredError } from "@/app/lib/mikeApi";
|
import { isMfaRequiredError } from "@/app/lib/mikeApi";
|
||||||
import {
|
import {
|
||||||
accountGlassIconButtonClassName,
|
accountGlassIconButtonClassName,
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,19 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Check,
|
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Loader2,
|
Loader2,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/app/components/ui/input";
|
||||||
import { Modal } from "@/app/components/shared/Modal";
|
import { Modal } from "@/app/components/modals/Modal";
|
||||||
|
import { NewMcpModal } from "@/app/components/account/NewMcpModal";
|
||||||
import {
|
import {
|
||||||
MfaVerificationPopup,
|
MfaVerificationPopup,
|
||||||
needsMfaVerification,
|
needsMfaVerification,
|
||||||
} from "@/app/components/shared/MfaVerificationPopup";
|
} from "@/app/components/popups/MfaVerificationPopup";
|
||||||
import {
|
import {
|
||||||
type McpConnectorSummary,
|
type McpConnectorSummary,
|
||||||
MikeApiError,
|
MikeApiError,
|
||||||
|
|
@ -31,7 +30,6 @@ import {
|
||||||
updateMcpConnector,
|
updateMcpConnector,
|
||||||
} from "@/app/lib/mikeApi";
|
} from "@/app/lib/mikeApi";
|
||||||
import {
|
import {
|
||||||
accountGlassDangerButtonClassName,
|
|
||||||
accountGlassIconButtonClassName,
|
accountGlassIconButtonClassName,
|
||||||
accountGlassInputClassName,
|
accountGlassInputClassName,
|
||||||
accountGlassPrimaryButtonClassName,
|
accountGlassPrimaryButtonClassName,
|
||||||
|
|
@ -634,7 +632,7 @@ export default function ConnectorsPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AddMcpConnectorModal
|
<NewMcpModal
|
||||||
open={addOpen}
|
open={addOpen}
|
||||||
draft={addDraft}
|
draft={addDraft}
|
||||||
step={addStep}
|
step={addStep}
|
||||||
|
|
@ -793,129 +791,6 @@ function ConnectorRow({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AddMcpConnectorModal({
|
|
||||||
open,
|
|
||||||
draft,
|
|
||||||
step,
|
|
||||||
result,
|
|
||||||
error,
|
|
||||||
authMessage,
|
|
||||||
showToken,
|
|
||||||
showAdvanced,
|
|
||||||
onDraftChange,
|
|
||||||
onShowTokenChange,
|
|
||||||
onShowAdvancedChange,
|
|
||||||
onClose,
|
|
||||||
onSubmit,
|
|
||||||
onOpenConnector,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
draft: AddDraft;
|
|
||||||
step: AddStep;
|
|
||||||
result: McpConnectorSummary | null;
|
|
||||||
error: string | null;
|
|
||||||
authMessage: string | null;
|
|
||||||
showToken: boolean;
|
|
||||||
showAdvanced: boolean;
|
|
||||||
onDraftChange: (draft: AddDraft) => void;
|
|
||||||
onShowTokenChange: (show: boolean) => void;
|
|
||||||
onShowAdvancedChange: (show: boolean) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
onSubmit: () => Promise<void>;
|
|
||||||
onOpenConnector: (connectorId: string) => void;
|
|
||||||
}) {
|
|
||||||
const canSubmit =
|
|
||||||
draft.name.trim().length > 0 &&
|
|
||||||
draft.serverUrl.trim().length > 0 &&
|
|
||||||
step !== "working" &&
|
|
||||||
step !== "auth";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={open}
|
|
||||||
onClose={onClose}
|
|
||||||
breadcrumbs={[
|
|
||||||
"Connectors",
|
|
||||||
step === "success"
|
|
||||||
? "Connector added"
|
|
||||||
: step === "auth"
|
|
||||||
? "Authenticate connector"
|
|
||||||
: "Add MCP connector",
|
|
||||||
]}
|
|
||||||
size="lg"
|
|
||||||
primaryAction={
|
|
||||||
step === "success" && result
|
|
||||||
? {
|
|
||||||
label: "View connector",
|
|
||||||
onClick: () => onOpenConnector(result.id),
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
label:
|
|
||||||
step === "working"
|
|
||||||
? "Connecting..."
|
|
||||||
: step === "auth"
|
|
||||||
? "Authorizing..."
|
|
||||||
: "Connect",
|
|
||||||
icon:
|
|
||||||
step === "working" || step === "auth" ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : undefined,
|
|
||||||
onClick: () => void onSubmit(),
|
|
||||||
disabled: !canSubmit,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cancelAction={
|
|
||||||
step === "working" || step === "auth"
|
|
||||||
? false
|
|
||||||
: { label: step === "success" ? "Done" : "Cancel", onClick: onClose }
|
|
||||||
}
|
|
||||||
footerStatus={
|
|
||||||
error ? (
|
|
||||||
<div className="rounded-xl border border-white/70 bg-white/75 px-3 py-2 text-sm text-red-600 shadow-[0_12px_32px_rgba(15,23,42,0.10),inset_0_1px_0_rgba(255,255,255,0.75)] backdrop-blur-xl">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{step === "success" && result ? (
|
|
||||||
<SuccessToolsList connector={result} />
|
|
||||||
) : step === "auth" ? (
|
|
||||||
<ConnectorAuthScreen
|
|
||||||
message={
|
|
||||||
authMessage ??
|
|
||||||
"Complete authorization in the popup to finish connecting this MCP server."
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4 pb-4">
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
The assistant will have access to this MCP server and
|
|
||||||
its enabled tools.
|
|
||||||
</p>
|
|
||||||
<ConnectorForm
|
|
||||||
draft={draft}
|
|
||||||
showToken={showToken}
|
|
||||||
showAdvanced={showAdvanced}
|
|
||||||
showTokenNote
|
|
||||||
tokenPlaceholder="Bearer token"
|
|
||||||
disabled={step === "working"}
|
|
||||||
onDraftChange={(next) =>
|
|
||||||
onDraftChange({
|
|
||||||
name: next.name,
|
|
||||||
serverUrl: next.serverUrl,
|
|
||||||
bearerToken: next.bearerToken,
|
|
||||||
customHeaders: next.customHeaders,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onShowTokenChange={onShowTokenChange}
|
|
||||||
onShowAdvancedChange={onShowAdvancedChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function McpConnectorDetailsModal({
|
function McpConnectorDetailsModal({
|
||||||
connector,
|
connector,
|
||||||
draft,
|
draft,
|
||||||
|
|
@ -1020,7 +895,7 @@ function McpConnectorDetailsModal({
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{connector && (
|
{connector && (
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-5 pb-4">
|
<div className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto pb-4">
|
||||||
<ConnectorForm
|
<ConnectorForm
|
||||||
draft={draft}
|
draft={draft}
|
||||||
showToken={showToken}
|
showToken={showToken}
|
||||||
|
|
@ -1290,41 +1165,6 @@ function ConnectorForm({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SuccessToolsList({ connector }: { connector: McpConnectorSummary }) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full min-h-0 flex-1 flex-col gap-4 pb-4">
|
|
||||||
<div className="flex items-start gap-3 rounded-xl border border-green-100/80 bg-green-50/80 px-3 py-3 text-green-800 shadow-[0_3px_9px_rgba(15,23,42,0.03),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.05)] backdrop-blur-xl">
|
|
||||||
<Check className="mt-0.5 h-4 w-4 shrink-0 text-green-600" />
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-sm font-medium">
|
|
||||||
{connector.name} is connected.{" "}
|
|
||||||
<span className="font-normal text-green-700">
|
|
||||||
{connector.tools.length} tools discovered.
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ScrollableToolList connector={connector} fill />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConnectorAuthScreen({ message }: { message: string }) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 pb-4 text-center">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl border border-white/70 bg-white/75 text-gray-700 shadow-[0_3px_9px_rgba(15,23,42,0.03),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.05)] backdrop-blur-xl">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
</div>
|
|
||||||
<div className="max-w-sm space-y-1">
|
|
||||||
<h3 className="text-sm font-medium text-gray-900">
|
|
||||||
Authentication required
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-500">{message}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolListSkeleton({
|
function ToolListSkeleton({
|
||||||
count,
|
count,
|
||||||
fill = false,
|
fill = false,
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,15 @@
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Check } from "lucide-react";
|
import { Check } from "lucide-react";
|
||||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
import { useUserProfile } from "@/app/contexts/UserProfileContext";
|
||||||
|
import { useQuickActionsPreference } from "@/app/components/assistant/quickActionsPreferences";
|
||||||
import { AccountSection } from "../AccountSection";
|
import { AccountSection } from "../AccountSection";
|
||||||
|
import { AccountToggle } from "../AccountToggle";
|
||||||
|
|
||||||
export default function FeaturesPage() {
|
export default function FeaturesPage() {
|
||||||
const { profile, updateLegalResearchUs } = useUserProfile();
|
const { profile, updateLegalResearchUs } = useUserProfile();
|
||||||
|
const { visibleActions, showAllQuickActions, hideAllQuickActions } =
|
||||||
|
useQuickActionsPreference();
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
|
@ -26,6 +30,7 @@ export default function FeaturesPage() {
|
||||||
const hasChanges =
|
const hasChanges =
|
||||||
draftLegalResearchUs !== null &&
|
draftLegalResearchUs !== null &&
|
||||||
draftLegalResearchUs !== persistedLegalResearchUs;
|
draftLegalResearchUs !== persistedLegalResearchUs;
|
||||||
|
const quickActionsEnabled = Object.values(visibleActions).some(Boolean);
|
||||||
|
|
||||||
const handleUpdateLegalResearch = async () => {
|
const handleUpdateLegalResearch = async () => {
|
||||||
if (saving) return;
|
if (saving) return;
|
||||||
|
|
@ -46,6 +51,38 @@ export default function FeaturesPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-2xl font-medium font-serif text-gray-900">
|
||||||
|
Assistant
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<AccountSection>
|
||||||
|
<div className="flex flex-col gap-3 px-4 py-5 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900">
|
||||||
|
Quick actions
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Show the quick actions row on the assistant
|
||||||
|
start screen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AccountToggle
|
||||||
|
checked={quickActionsEnabled}
|
||||||
|
size="md"
|
||||||
|
onChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
showAllQuickActions();
|
||||||
|
} else {
|
||||||
|
hideAllQuickActions();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AccountSection>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-2xl font-medium font-serif text-gray-900">
|
<h2 className="text-2xl font-medium font-serif text-gray-900">
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/app/contexts/AuthContext";
|
||||||
import { accountTabButtonClassName } from "./accountStyles";
|
import { accountTabButtonClassName } from "./accountStyles";
|
||||||
|
|
||||||
interface TabDef {
|
interface TabDef {
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,15 @@ import { useEffect, useRef, useState } from "react";
|
||||||
import { AlertCircle, Check, ChevronDown, Loader2 } from "lucide-react";
|
import { AlertCircle, Check, ChevronDown, Loader2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/app/components/ui/dropdown-menu";
|
||||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
import {
|
||||||
|
LiquidDropdownContent,
|
||||||
|
LiquidDropdownItem,
|
||||||
|
} from "@/app/components/ui/liquid-dropdown";
|
||||||
|
import { useUserProfile } from "@/app/contexts/UserProfileContext";
|
||||||
import type { ApiKeyState } from "@/app/lib/mikeApi";
|
import type { ApiKeyState } from "@/app/lib/mikeApi";
|
||||||
import {
|
import {
|
||||||
MODELS,
|
MODELS,
|
||||||
|
|
@ -178,7 +180,7 @@ function ModelPreferenceDropdown({
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<LiquidDropdownContent
|
||||||
className="z-50"
|
className="z-50"
|
||||||
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
|
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
|
||||||
align="start"
|
align="start"
|
||||||
|
|
@ -198,7 +200,7 @@ function ModelPreferenceDropdown({
|
||||||
? isModelAvailable(m.id, apiKeys)
|
? isModelAvailable(m.id, apiKeys)
|
||||||
: true;
|
: true;
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<LiquidDropdownItem
|
||||||
key={m.id}
|
key={m.id}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onSelect={() => onChange(m.id)}
|
onSelect={() => onChange(m.id)}
|
||||||
|
|
@ -219,13 +221,13 @@ function ModelPreferenceDropdown({
|
||||||
{m.id === value && available && (
|
{m.id === value && available && (
|
||||||
<Check className="h-3.5 w-3.5 text-gray-600 ml-1" />
|
<Check className="h-3.5 w-3.5 text-gray-600 ml-1" />
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</LiquidDropdownItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</DropdownMenuContent>
|
</LiquidDropdownContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||