diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..721d0c5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +node_modules +dist +build +coverage +.git +.github +.env +.env.* +!.env.example +*.log +npm-debug.log* +.vscode +.idea +Thumbs.db +.DS_Store diff --git a/.env.example b/.env.example index 9fc55de..312d943 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,16 @@ AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" OPENAI_API_KEY="your-azure-openai-api-key-here" AZURE_OPENAI_DEPLOYMENT="gpt-4o" AZURE_OPENAI_API_VERSION="2024-12-01-preview" +# Embedding deployment — required only for semantic search (Phase 4) +AZURE_OPENAI_EMBEDDING_DEPLOYMENT="text-embedding-ada-002" + +# Email Notifications (Optional) +# Sending is OFF unless EMAIL_ENABLED="true" AND a provider key is set. +# Default provider is Resend (https://resend.com). +EMAIL_ENABLED="false" +EMAIL_PROVIDER="resend" +RESEND_API_KEY="" +EMAIL_FROM="DealSentry " # Authentication # Secret key for JWT token signing - CHANGE THIS IN PRODUCTION! diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..73bb807 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build-and-test: + runs-on: ubuntu-latest + env: + # Tests/build don't drive a real browser; skip the large Chromium download. + PUPPETEER_SKIP_DOWNLOAD: "true" + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Generate Prisma client + run: npx prisma generate + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm test + + - name: Build + run: npm run build diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9e93b4a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +# syntax=docker/dockerfile:1 + +# ---- Builder: install deps, generate Prisma client, build the SPA ---- +FROM node:20-slim AS builder +WORKDIR /app + +# Puppeteer downloads its own Chromium by default; we use the system Chromium +# in the runtime stage instead, so skip the (large) download here. +ENV PUPPETEER_SKIP_DOWNLOAD=true + +COPY package*.json ./ +COPY prisma ./prisma +RUN npm ci + +COPY . . +RUN npx prisma generate && npm run build + +# ---- Runtime: system Chromium + app source, run server via tsx ---- +FROM node:20-slim AS runtime +WORKDIR /app + +ENV NODE_ENV=production +ENV PUPPETEER_SKIP_DOWNLOAD=true +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium + +# Chromium + fonts so the PDF export (Puppeteer) works in the container. +RUN apt-get update && apt-get install -y --no-install-recommends \ + chromium \ + fonts-liberation \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Bring over installed deps (incl. tsx) and the generated Prisma client + build. +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/dist ./dist +COPY package*.json tsconfig.json server.ts ./ +COPY src ./src +COPY prisma ./prisma + +EXPOSE 3001 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \ + CMD node -e "const p=process.env.PORT||3001;fetch('http://localhost:'+p+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +CMD ["npx", "tsx", "server.ts"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f913572 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +# Runs the DealSentry app (API + built SPA on port 3001). +# +# The runtime data layer talks to Supabase over REST (not a direct Postgres +# connection), so configuration comes entirely from .env — there is no local +# database service to stand up. Copy .env.example to .env and fill it in first. +services: + app: + build: . + image: dealsentry:latest + ports: + - "3001:3001" + env_file: + - .env + environment: + NODE_ENV: production + API_PORT: "3001" + restart: unless-stopped diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..5626b6b --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,106 @@ +# Deployment Guide + +DealSentry ships as a single Node service: the Express API also serves the +built React SPA (on `NODE_ENV=production`). It depends on a Supabase project +(Postgres + storage) and Azure OpenAI. + +## 1. Prerequisites + +- A Supabase project (database + a `proposal-files` storage bucket). +- An Azure OpenAI deployment (e.g. `gpt-4o`). +- Node 20+ (for non-container runs) or Docker. + +## 2. Configure environment + +Copy the template and fill in real values: + +```bash +cp .env.example .env +``` + +Key variables (see `.env.example` for the full list): + +| Variable | Purpose | +|---|---| +| `DATABASE_URL` | Supabase Postgres connection string (used by Prisma migrations) | +| `SUPABASE_URL`, `SUPABASE_ANON_KEY` | Supabase REST/storage client | +| `AZURE_OPENAI_ENDPOINT`, `OPENAI_API_KEY`, `AZURE_OPENAI_DEPLOYMENT` | AI generation & analysis | +| `NEXTAUTH_SECRET` | JWT signing secret — generate a fresh 32+ byte value | +| `API_PORT` | Server port (default `3001`) | +| `PRODUCTION_URL` | Allowed CORS origin in production | + +Generate a strong JWT secret: + +```bash +node -e "console.log(require('crypto').randomBytes(48).toString('base64'))" +``` + +> Security: `.env` is gitignored and must never be committed. Rotate any +> credential that has been shared in plaintext (DB password, Azure key, +> `NEXTAUTH_SECRET`). + +## 3. Apply database schema + +```bash +npx prisma generate +npx prisma migrate deploy +npm run seed # optional: demo users, rules, templates, sample proposals +``` + +## 4a. Run with Docker (recommended) + +```bash +docker compose up --build +``` + +This builds the SPA, installs system Chromium (for PDF export), and serves the +app on `http://localhost:3001`. Configuration is read from `.env`. + +## 4b. Run with Node directly + +```bash +npm ci +npm run build # builds the SPA into dist/ +NODE_ENV=production npx tsx server.ts +``` + +The server serves the API under `/api/*` and the SPA for all other paths. + +## 4c. Deploy to Render (hosted) + +The repo ships a `render.yaml` Blueprint that runs the Dockerfile as a web service. + +1. **Rotate secrets first** — the Supabase password, Azure OpenAI key. (`NEXTAUTH_SECRET` + is auto-generated by Render via `generateValue`, so the old one is replaced.) +2. In Render: **New → Blueprint**, connect this GitHub repo, pick the branch. Render + reads `render.yaml` and creates the `dealsentry` service. +3. Fill in the `sync: false` env vars in the dashboard: `DATABASE_URL`, `SUPABASE_URL`, + `SUPABASE_ANON_KEY`, `AZURE_OPENAI_ENDPOINT`, `OPENAI_API_KEY`. +4. Deploy. Once it's live, copy the service URL (e.g. `https://dealsentry.onrender.com`) + into **both** `PRODUCTION_URL` and `FRONTEND_URL`, then redeploy so CORS + OAuth + redirects use the real domain. +5. Schema: we reuse the existing Supabase project, so the tables already exist — no + migration step needed on first deploy. (For semantic search, run + `prisma/manual/semantic_search.sql` once; see `docs/SEMANTIC_SEARCH.md`.) + +Notes: +- Use at least the **starter** plan; bump to **standard** (2 GB) if PDF export OOMs + (headless Chromium is memory-hungry). Avoid the **free** plan — it idles down. +- Render injects `PORT`; the server listens on it automatically. + +## 5. Verify + +```bash +curl -s http://localhost:3001/api/health # -> {"status":"ok",...} +# On Render: curl -s https://.onrender.com/api/health +``` + +Then open `http://localhost:3001`, log in (seeded `admin@dealsentry.ai`), +create a proposal, run analysis, and export a PDF — the PDF path exercises the +containerized Chromium, confirming the image is complete. + +## CI + +`.github/workflows/ci.yml` runs on every push/PR to `main`: install → +`prisma generate` → lint → typecheck → test → build. Keep it green before +deploying. diff --git a/docs/SEMANTIC_SEARCH.md b/docs/SEMANTIC_SEARCH.md new file mode 100644 index 0000000..4a21082 --- /dev/null +++ b/docs/SEMANTIC_SEARCH.md @@ -0,0 +1,54 @@ +# Semantic Search (Phase 4) + +Meaning-based search over proposal content using Azure OpenAI embeddings + +pgvector. Until the setup below is done, the Proposals search box transparently +falls back to title/client substring matching — nothing breaks. + +## How it works + +- On proposal **create** and **AI generate**, the API computes an embedding of + `title + content` and stores it in `Proposal.embedding` (best-effort). +- `GET /api/proposals/search?q=...` embeds the query and ranks proposals by + cosine similarity via the `match_proposals` Postgres function, scoped to the + caller's company (admins see all). +- The frontend (`src/pages/Proposals.tsx`) calls this when the query is ≥3 chars + and ranks by similarity; if the endpoint reports `available:false`, it uses the + substring filter instead. + +Code: `src/api/lib/embeddings.ts`, search route in `src/api/proposals.ts`, +`proposalsApi.search` in `src/lib/api-client.ts`. + +## One-time setup + +1. **Configure the embedding deployment** in `.env`: + ``` + AZURE_OPENAI_EMBEDDING_DEPLOYMENT="text-embedding-ada-002" + ``` + (Endpoint + key are shared with the existing Azure OpenAI config.) + +2. **Run the SQL migration** against your Supabase Postgres — Supabase Studio → + SQL editor, or psql. This enables pgvector, adds the `embedding` column + an + index, and creates the `match_proposals` function: + ``` + prisma/manual/semantic_search.sql + ``` + +3. **Backfill embeddings** for existing proposals: + ```bash + npm run backfill:embeddings + ``` + +4. Restart the API. New proposals embed automatically; search now ranks by + meaning. + +## Notes + +- `text-embedding-ada-002` → 1536-dim vectors. If you switch models, update the + dimension in both the SQL (`vector(1536)`) and `EMBEDDING_DIM` in + `src/api/lib/embeddings.ts`. +- The `ivfflat` index `lists` parameter (default 100) should grow roughly with + `rows / 1000` for best recall/speed. +- `Proposal.embedding` is declared in `schema.prisma` as + `Unsupported("vector(1536)")` for documentation; the column is actually created + by the manual SQL migration (the runtime uses the Supabase REST client, not the + Prisma client, for data access). diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..846ee6f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,18 +1,46 @@ -import { defineConfig, globalIgnores } from "eslint/config"; -import nextVitals from "eslint-config-next/core-web-vitals"; -import nextTs from "eslint-config-next/typescript"; +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; -const eslintConfig = defineConfig([ - ...nextVitals, - ...nextTs, - // Override default ignores of eslint-config-next. - globalIgnores([ - // Default ignores of eslint-config-next: - ".next/**", - "out/**", - "build/**", - "next-env.d.ts", - ]), -]); - -export default eslintConfig; +// Flat config for this Vite + React + TypeScript project. (The previous config +// pulled in eslint-config-next, which was never a dependency and broke linting.) +export default tseslint.config( + { ignores: ["dist", "build", "coverage", "node_modules"] }, + { + files: ["**/*.{ts,tsx}"], + extends: [js.configs.recommended, ...tseslint.configs.recommended], + languageOptions: { + ecmaVersion: 2020, + globals: { ...globals.browser, ...globals.node }, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + // The API/JSON boundaries intentionally use `any`; keep it advisory. + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + // Legitimate patterns for this stack: + // - namespace: required for the Express Request augmentation + // - empty-object-type: shadcn/ui component interfaces + // - require-imports: tailwind config plugins + "@typescript-eslint/no-namespace": "off", + "@typescript-eslint/no-empty-object-type": "off", + "@typescript-eslint/no-require-imports": "off", + "@typescript-eslint/no-unsafe-function-type": "warn", + "no-useless-catch": "warn", + "no-useless-escape": "warn", + }, + }, +); diff --git a/package-lock.json b/package-lock.json index 42dfb0a..0a2f2d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,7 +55,9 @@ "dotenv": "^17.2.3", "embla-carousel-react": "^8.6.0", "express": "^5.2.1", + "express-rate-limit": "^8.5.2", "framer-motion": "^12.29.2", + "helmet": "^8.2.0", "input-otp": "^1.4.2", "jsonwebtoken": "^9.0.3", "lucide-react": "^0.462.0", @@ -72,6 +74,7 @@ "react-resizable-panels": "^2.1.9", "react-router-dom": "^6.30.1", "recharts": "^2.15.4", + "resend": "^6.14.0", "sonner": "^1.7.4", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", @@ -87,6 +90,7 @@ "@types/pg": "^8.16.0", "@types/react": "^18.3.23", "@types/react-dom": "^18.3.7", + "@types/supertest": "^7.2.0", "@types/uuid": "^10.0.0", "@vitejs/plugin-react-swc": "^3.11.0", "autoprefixer": "^10.4.21", @@ -95,11 +99,13 @@ "eslint-plugin-react-refresh": "^0.4.20", "globals": "^15.15.0", "postcss": "^8.5.6", + "supertest": "^7.2.2", "tailwindcss": "^3.4.17", "tsx": "^4.21.0", "typescript": "^5.8.3", "typescript-eslint": "^8.38.0", "vite": "^5.4.19", + "vitest": "^4.1.9", "wait-on": "^9.0.4" } }, @@ -207,6 +213,40 @@ "@electric-sql/pglite": "0.3.15" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -993,6 +1033,38 @@ "node": ">=16" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1028,6 +1100,26 @@ "node": ">= 8" } }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@prisma/adapter-pg": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.3.0.tgz", @@ -2897,6 +2989,263 @@ "node": ">=14.0.0" } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -3254,6 +3603,12 @@ "win32" ] }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -3572,6 +3927,17 @@ "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", "license": "MIT" }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/bcryptjs": { "version": "2.4.6", "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", @@ -3588,6 +3954,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -3597,6 +3974,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -3669,6 +4053,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3723,6 +4114,13 @@ "@types/node": "*" } }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -3814,6 +4212,30 @@ "@types/node": "*" } }, + "node_modules/@types/superagent": { + "version": "8.1.10", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.10.tgz", + "integrity": "sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -4162,6 +4584,92 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.11", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", @@ -4325,6 +4833,23 @@ "node": ">=10" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", @@ -4736,6 +5261,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4894,6 +5429,16 @@ "node": ">= 6" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -4977,6 +5522,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -4995,6 +5547,13 @@ "node": ">=6.6.0" } }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -5327,6 +5886,16 @@ "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", "license": "MIT" }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-node-es": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", @@ -5339,6 +5908,17 @@ "integrity": "sha512-pM27vqEfxSxRkTMnF+XCmxSEb6duO5R+t8A9DEEJgy4Wz2RVanje2mmj99B6A3zv2r/qGfYlOvYznUhuokizmg==", "license": "BSD-3-Clause" }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -5581,6 +6161,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -5879,6 +6466,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -5912,6 +6509,16 @@ "bare-events": "^2.7.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -5955,6 +6562,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express/node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -6064,6 +6689,19 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -6220,6 +6858,24 @@ "node": ">= 6" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -6544,6 +7200,18 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz", + "integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, "node_modules/hono": { "version": "4.11.4", "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", @@ -6718,9 +7386,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" @@ -6986,16 +7654,277 @@ "type-check": "~0.4.0" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 0.8.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "license": "MIT", - "dependencies": { - "immediate": "~3.0.5" + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/lilconfig": { @@ -7146,6 +8075,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/mammoth": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.11.0.tgz", @@ -7218,6 +8157,16 @@ "node": ">= 8" } }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -7231,6 +8180,19 @@ "node": ">=8.6" } }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -7360,9 +8322,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "funding": [ { "type": "github", @@ -7507,6 +8469,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -7895,10 +8871,16 @@ "pathe": "^2.0.3" } }, + "node_modules/postal-mime": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", + "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==", + "license": "MIT-0" + }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "funding": [ { "type": "opencollective", @@ -7915,7 +8897,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -8752,6 +9734,27 @@ "node": ">=0.10.0" } }, + "node_modules/resend": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.14.0.tgz", + "integrity": "sha512-jVdpUgOoWGLjaP64lo8KwzHT9gY4w6Dl8c36CIb2F+ayYOMLr3khqs8xrNjXM2k19b+lPoj0VWQFhVNLiToBjA==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.4", + "standardwebhooks": "1.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -8810,6 +9813,47 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.57.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz", @@ -9139,6 +10183,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -9242,6 +10293,23 @@ "node": ">= 0.6" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -9344,6 +10412,42 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -9626,6 +10730,13 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -9636,13 +10747,13 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -9669,9 +10780,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -9680,6 +10791,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -10548,6 +11669,221 @@ "@esbuild/win32-x64": "0.21.5" } }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/wait-on": { "version": "9.0.4", "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.4.tgz", @@ -10606,6 +11942,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 87637aa..d265ec1 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,12 @@ "start": "npm run build && npx tsx server.ts", "dev:full": "concurrently --kill-others \"npm run server\" \"wait-on http://localhost:3001/api/health && npm run dev\"", "lint": "eslint .", - "seed": "tsx prisma/seed.ts" + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "typecheck": "tsc --noEmit", + "seed": "tsx prisma/seed.ts", + "backfill:embeddings": "tsx scripts/backfill-embeddings.ts" }, "dependencies": { "@hookform/resolvers": "^3.10.0", @@ -61,7 +66,9 @@ "dotenv": "^17.2.3", "embla-carousel-react": "^8.6.0", "express": "^5.2.1", + "express-rate-limit": "^8.5.2", "framer-motion": "^12.29.2", + "helmet": "^8.2.0", "input-otp": "^1.4.2", "jsonwebtoken": "^9.0.3", "lucide-react": "^0.462.0", @@ -78,6 +85,7 @@ "react-resizable-panels": "^2.1.9", "react-router-dom": "^6.30.1", "recharts": "^2.15.4", + "resend": "^6.14.0", "sonner": "^1.7.4", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", @@ -93,6 +101,7 @@ "@types/pg": "^8.16.0", "@types/react": "^18.3.23", "@types/react-dom": "^18.3.7", + "@types/supertest": "^7.2.0", "@types/uuid": "^10.0.0", "@vitejs/plugin-react-swc": "^3.11.0", "autoprefixer": "^10.4.21", @@ -101,11 +110,13 @@ "eslint-plugin-react-refresh": "^0.4.20", "globals": "^15.15.0", "postcss": "^8.5.6", + "supertest": "^7.2.2", "tailwindcss": "^3.4.17", "tsx": "^4.21.0", "typescript": "^5.8.3", "typescript-eslint": "^8.38.0", "vite": "^5.4.19", + "vitest": "^4.1.9", "wait-on": "^9.0.4" } } diff --git a/prisma/manual/semantic_search.sql b/prisma/manual/semantic_search.sql new file mode 100644 index 0000000..717494d --- /dev/null +++ b/prisma/manual/semantic_search.sql @@ -0,0 +1,35 @@ +-- Semantic search setup (Phase 4). Run ONCE against your Supabase Postgres +-- (Supabase Studio → SQL editor, or psql). Idempotent / safe to re-run. +-- +-- After running this, restart the API and run the backfill: +-- npm run backfill:embeddings +-- New/updated proposals embed automatically on create. + +-- 1. pgvector extension +create extension if not exists vector; + +-- 2. Embedding column on Proposal (text-embedding-ada-002 => 1536 dims) +alter table "Proposal" add column if not exists embedding vector(1536); + +-- 3. Approximate-nearest-neighbour index (cosine distance). +-- Tune `lists` upward as the table grows (≈ rows/1000). +create index if not exists proposal_embedding_idx + on "Proposal" using ivfflat (embedding vector_cosine_ops) with (lists = 100); + +-- 4. Similarity search function used by GET /api/proposals/search. +-- filter_company NULL => no company filter (admins see all). +create or replace function match_proposals( + query_embedding vector(1536), + match_count int default 20, + filter_company text default null +) +returns table (id text, similarity float) +language sql stable +as $$ + select p.id, 1 - (p.embedding <=> query_embedding) as similarity + from "Proposal" p + where p.embedding is not null + and (filter_company is null or p.company_id::text = filter_company) + order by p.embedding <=> query_embedding + limit match_count; +$$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0ef9f64..8d1115f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -53,7 +53,11 @@ model Proposal { contractEndDate DateTime? renewalDate DateTime? autoRenew Boolean @default(false) - + + // Semantic search embedding (pgvector). Added via prisma/manual/semantic_search.sql; + // populated best-effort on create and by scripts/backfill-embeddings.ts. + embedding Unsupported("vector(1536)")? + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..2d84c3f --- /dev/null +++ b/render.yaml @@ -0,0 +1,57 @@ +# Render Blueprint — deploys DealSentry as a Docker web service. +# In Render: New → Blueprint → connect this repo. Render reads this file, +# builds the Dockerfile, and provisions the service. Fill in the `sync: false` +# secrets in the dashboard (they are intentionally not stored in git). +# +# Puppeteer/Chromium (PDF export) is memory-hungry — use at least the `starter` +# plan; bump to `standard` (2 GB) if PDF exports OOM. Avoid the free plan: it +# spins down on idle, which is bad for a persistent API. +services: + - type: web + name: dealsentry + runtime: docker + dockerfilePath: ./Dockerfile + plan: starter + healthCheckPath: /api/health + autoDeploy: true + envVars: + - key: NODE_ENV + value: production + # JWT signing secret — Render generates a strong value (rotates the old one). + - key: NEXTAUTH_SECRET + generateValue: true + + # --- Secrets: set these in the Render dashboard (rotate first!) --- + - key: DATABASE_URL + sync: false + - key: SUPABASE_URL + sync: false + - key: SUPABASE_ANON_KEY + sync: false + - key: AZURE_OPENAI_ENDPOINT + sync: false + - key: OPENAI_API_KEY + sync: false + + # --- Non-secret config (override in dashboard if needed) --- + - key: AZURE_OPENAI_DEPLOYMENT + value: gpt-4o + - key: AZURE_OPENAI_API_VERSION + value: 2024-12-01-preview + - key: AZURE_OPENAI_EMBEDDING_DEPLOYMENT + value: text-embedding-ada-002 + + # Set both to the service's public URL after the first deploy + # (e.g. https://dealsentry.onrender.com). Used for CORS + OAuth redirects. + - key: PRODUCTION_URL + sync: false + - key: FRONTEND_URL + sync: false + + # --- Email (optional; off until enabled) --- + - key: EMAIL_ENABLED + value: "false" + - key: RESEND_API_KEY + sync: false + - key: EMAIL_FROM + value: DealSentry diff --git a/scripts/backfill-embeddings.ts b/scripts/backfill-embeddings.ts new file mode 100644 index 0000000..ef23b75 --- /dev/null +++ b/scripts/backfill-embeddings.ts @@ -0,0 +1,59 @@ +/** + * Backfill content embeddings for existing proposals (Phase 4 semantic search). + * + * Prerequisite: run prisma/manual/semantic_search.sql first (adds the pgvector + * column + index + RPC). Then: npm run backfill:embeddings + * + * Idempotent: proposals that already have an embedding are skipped. + */ + +import { config } from 'dotenv'; +config(); + +import { supabase } from '../src/lib/supabase'; +import { generateEmbedding } from '../src/api/lib/embeddings'; + +async function main() { + const { data: rows, error } = await supabase + .from('Proposal') + .select('id, title, content, embedding'); + + if (error) { + console.error('Failed to load proposals (did you run the SQL migration?):', error.message); + process.exit(1); + } + + let embedded = 0; + let skipped = 0; + let failed = 0; + + for (const p of rows || []) { + if (p.embedding) { + skipped++; + continue; + } + const vector = await generateEmbedding(`${p.title}\n\n${p.content}`); + if (!vector) { + console.warn(`No embedding produced for ${p.id}; skipping.`); + failed++; + continue; + } + const { error: upErr } = await supabase.from('Proposal').update({ embedding: vector }).eq('id', p.id); + if (upErr) { + console.error(`Update failed for ${p.id}:`, upErr.message); + failed++; + continue; + } + embedded++; + console.log(`Embedded ${p.id} (${embedded})`); + } + + console.log(`\nDone. embedded=${embedded} skipped=${skipped} failed=${failed}`); +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/server.ts b/server.ts index bb1ae41..07669b7 100644 --- a/server.ts +++ b/server.ts @@ -1,9 +1,12 @@ import express from 'express'; import cors from 'cors'; +import helmet from 'helmet'; import { config } from 'dotenv'; import path from 'path'; import { fileURLToPath } from 'url'; +import { authLimiter, aiLimiter } from './src/api/middleware/rateLimit'; + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -21,9 +24,12 @@ import integrationsRouter from './src/api/integrations'; import authRouter from './src/api/auth'; import oauthRouter from './src/api/oauth'; import filesRouter from './src/api/files'; +import analyticsRouter from './src/api/analytics'; +import notificationsRouter from './src/api/notifications'; const app = express(); -const PORT = process.env.API_PORT || 3001; +// Render (and most PaaS) inject PORT; fall back to API_PORT for local dev. +const PORT = process.env.PORT || process.env.API_PORT || 3001; // CORS configuration - restrict to production domain in production // In development: allows localhost origins for local testing @@ -40,6 +46,13 @@ const allowedOrigins = isDevelopment ] : [process.env.PRODUCTION_URL || 'https://your-production-domain.com']; +// Security headers. crossOriginResourcePolicy is relaxed so the SPA/API can +// serve cross-origin assets (e.g. PDF/file downloads) without being blocked. +app.use(helmet({ + contentSecurityPolicy: false, + crossOriginResourcePolicy: { policy: 'cross-origin' }, +})); + // Middleware app.use(cors({ origin: (origin, callback) => { @@ -63,16 +76,29 @@ app.get('/api/health', (req, res) => { }); // Routes -app.use('/api/auth', authRouter); +app.use('/api/auth', authLimiter, authRouter); app.use('/api/proposals', proposalsRouter); app.use('/api/rules', rulesRouter); app.use('/api/templates', templatesRouter); app.use('/api/audit', auditRouter); -app.use('/api/analyze', analyzeRouter); +app.use('/api/analyze', aiLimiter, analyzeRouter); app.use('/api/users', usersRouter); app.use('/api/integrations', integrationsRouter); app.use('/api/oauth', oauthRouter); app.use('/api/files', filesRouter); +app.use('/api/analytics', analyticsRouter); +app.use('/api/notifications', notificationsRouter); + +// In production, serve the built SPA from dist/ and let client-side routing +// handle any non-API path (Express 5: use a catch-all middleware, not '*'). +if (process.env.NODE_ENV === 'production') { + const distPath = path.join(__dirname, 'dist'); + app.use(express.static(distPath)); + app.use((req, res, next) => { + if (req.path.startsWith('/api')) return next(); + res.sendFile(path.join(distPath, 'index.html')); + }); +} // Error handler app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => { diff --git a/src/App.tsx b/src/App.tsx index 15e4cc3..8459ea3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,7 @@ import Compliance from "@/pages/Compliance"; import Integrations from "@/pages/Integrations"; import Settings from "@/pages/Settings"; import Audit from "@/pages/Audit"; +import Analytics from "@/pages/Analytics"; import NotFound from "@/pages/NotFound"; const queryClient = new QueryClient(); @@ -45,6 +46,7 @@ function AppContent() { } /> } /> } /> + } /> } /> diff --git a/src/api/analytics.ts b/src/api/analytics.ts new file mode 100644 index 0000000..486f48e --- /dev/null +++ b/src/api/analytics.ts @@ -0,0 +1,63 @@ +import { Router, Request, Response } from 'express'; +import { supabase } from '../lib/supabase'; +import { requireAuth, isAdmin } from './middleware/auth'; +import { buildAnalyticsSummary, type AnalyticsProposal } from './lib/analytics'; + +const router = Router(); + +interface ProposalAnalyticsRow { + status: string; + createdAt: string; + metadata: AnalyticsProposal['metadata']; + RiskReport?: Array<{ + readinessScore?: number; + legalRisk?: number; + pricingRisk?: number; + structuralRisk?: number; + }>; +} + +// GET /api/analytics/summary — chart-ready aggregates (admin: all; others: own company) +router.get('/summary', requireAuth, async (req: Request, res: Response) => { + try { + let query = supabase + .from('Proposal') + .select(` + status, + createdAt, + metadata, + RiskReport (readinessScore, legalRisk, pricingRisk, structuralRisk) + `); + + if (!isAdmin(req) && req.user?.companyId) { + query = query.eq('company_id', req.user.companyId); + } + + const { data: rows, error } = await query; + if (error) throw error; + + const proposals: AnalyticsProposal[] = (rows || []).map((r: ProposalAnalyticsRow) => { + const risk = r.RiskReport?.[0]; + return { + status: r.status, + createdAt: r.createdAt, + readinessScore: risk?.readinessScore ?? 0, + riskReport: risk + ? { + legalRisk: risk.legalRisk, + pricingRisk: risk.pricingRisk, + structuralRisk: risk.structuralRisk, + } + : null, + metadata: r.metadata, + }; + }); + + res.json(buildAnalyticsSummary(proposals)); + } catch (error) { + console.error('Error building analytics summary:', error); + res.status(500).json({ error: 'Failed to build analytics summary' }); + } +}); + +export default router; diff --git a/src/api/analyze.ts b/src/api/analyze.ts index 1350492..79a318d 100644 --- a/src/api/analyze.ts +++ b/src/api/analyze.ts @@ -2,6 +2,7 @@ import { Router, Request, Response } from 'express'; import { AzureOpenAI } from 'openai'; import { supabase } from '../lib/supabase'; import { requireAuth, canAccessCompany } from './middleware/auth'; +import { normalizeAnalysis, shouldAutoReview } from './lib/compliance'; const router = Router(); @@ -140,7 +141,7 @@ Respond in JSON format: throw new Error('No response from AI'); } - const analysis = JSON.parse(responseContent); + const analysis = normalizeAnalysis(JSON.parse(responseContent)); // Check if risk report already exists const { data: existingReport } = await supabase @@ -156,12 +157,12 @@ Respond in JSON format: const { data, error: updateError } = await supabase .from('RiskReport') .update({ - readinessScore: analysis.readinessScore || 50, - legalRisk: analysis.legalRisk || 20, - pricingRisk: analysis.pricingRisk || 20, - structuralRisk: analysis.structuralRisk || 20, - findings: analysis.findings || [], - recommendations: analysis.recommendations || [], + readinessScore: analysis.readinessScore, + legalRisk: analysis.legalRisk, + pricingRisk: analysis.pricingRisk, + structuralRisk: analysis.structuralRisk, + findings: analysis.findings, + recommendations: analysis.recommendations, }) .eq('id', existingReport.id) .select() @@ -176,28 +177,28 @@ Respond in JSON format: .insert({ id: crypto.randomUUID(), proposalId, - readinessScore: analysis.readinessScore || 50, - legalRisk: analysis.legalRisk || 20, - pricingRisk: analysis.pricingRisk || 20, - structuralRisk: analysis.structuralRisk || 20, - findings: analysis.findings || [], - recommendations: analysis.recommendations || [], + readinessScore: analysis.readinessScore, + legalRisk: analysis.legalRisk, + pricingRisk: analysis.pricingRisk, + structuralRisk: analysis.structuralRisk, + findings: analysis.findings, + recommendations: analysis.recommendations, }) .select() .single(); - + if (insertError) throw insertError; riskReport = data; } // Update proposal with readiness score and status const updateData: { readinessScore: number; updatedAt: string; status?: string } = { - readinessScore: analysis.readinessScore || 50, + readinessScore: analysis.readinessScore, updatedAt: new Date().toISOString(), }; - - // Update status based on score - if (analysis.readinessScore >= 80) { + + // Auto-advance to review once the proposal is healthy enough. + if (shouldAutoReview(analysis.readinessScore)) { updateData.status = 'IN_REVIEW'; } diff --git a/src/api/audit.ts b/src/api/audit.ts index 64c6f54..8b29477 100644 --- a/src/api/audit.ts +++ b/src/api/audit.ts @@ -1,50 +1,18 @@ -import { Router, Request, Response } from 'express'; -import { supabase } from '../lib/supabase'; - -const router = Router(); - -// GET audit logs -router.get('/', async (req: Request, res: Response) => { - try { - const { data: logs, error } = await supabase - .from('AuditLog') - .select(` - *, - User:actorId (name, email, role), - Proposal:proposalId (title) - `) - .order('timestamp', { ascending: false }) - .limit(100); - - if (error) throw error; - - const formatted = (logs || []).map((log: { - id: string; - action: string; - timestamp: string; - actorId: string; - User: { name: string | null; email: string; role: string }; - proposalId: string | null; - Proposal: { title: string } | null; - }) => ({ - id: log.id, - action: log.action, - timestamp: log.timestamp, - actorId: log.actorId, - actor: { - name: log.User?.name, - email: log.User?.email, - role: log.User?.role, - }, - proposalId: log.proposalId, - proposal: log.Proposal ? { title: log.Proposal.title } : null, - })); - - res.json(formatted); - } catch (error) { - console.error('Error fetching audit logs:', error); - res.status(500).json({ error: 'Failed to fetch audit logs' }); - } -}); - -export default router; +import { Router, Request, Response } from 'express'; +import { requireAuth } from './middleware/auth'; +import { getScopedAuditLogs } from './lib/auditQuery'; + +const router = Router(); + +// GET audit logs (auth required; admins see all, others see own + company) +router.get('/', requireAuth, async (req: Request, res: Response) => { + try { + const logs = await getScopedAuditLogs(req, 100); + res.json(logs); + } catch (error) { + console.error('Error fetching audit logs:', error); + res.status(500).json({ error: 'Failed to fetch audit logs' }); + } +}); + +export default router; diff --git a/src/api/auth.ts b/src/api/auth.ts index d22d97a..28e1b11 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -9,6 +9,14 @@ const router = Router(); const JWT_SECRET = process.env.NEXTAUTH_SECRET || 'default-secret-change-in-production'; const SALT_ROUNDS = 10; +/** Shape of the signed JWT payload issued at login/register. */ +interface JwtPayload { + userId: string; + email: string; + role: string; + companyId: string | null; +} + // POST login router.post('/login', async (req: Request, res: Response) => { try { @@ -147,7 +155,7 @@ router.get('/verify', async (req: Request, res: Response) => { return res.status(401).json({ error: 'No token provided' }); } - const decoded = jwt.verify(token, JWT_SECRET) as any; + const decoded = jwt.verify(token, JWT_SECRET) as JwtPayload; const { data: user, error } = await supabase .from('User') @@ -188,7 +196,12 @@ router.get('/verify', async (req: Request, res: Response) => { router.post('/change-password', requireAuth, async (req: Request, res: Response) => { try { const { currentPassword, newPassword } = req.body; - const userId = (req as any).user.userId; + // requireAuth populates req.user with the AuthUser shape (id, not userId). + const userId = req.user?.id; + + if (!userId) { + return res.status(401).json({ error: 'Authentication required' }); + } if (!currentPassword || !newPassword) { return res.status(400).json({ error: 'Current password and new password are required' }); @@ -239,23 +252,4 @@ router.post('/change-password', requireAuth, async (req: Request, res: Response) } }); -// Middleware to protect routes -export const authMiddleware = async (req: Request, res: Response, next: Function) => { - try { - const token = req.headers.authorization?.replace('Bearer ', ''); - - if (!token) { - return res.status(401).json({ error: 'No token provided' }); - } - - const decoded = jwt.verify(token, JWT_SECRET) as any; - (req as any).user = decoded; - - next(); - } catch (error) { - console.error('Auth middleware error:', error); - res.status(401).json({ error: 'Invalid token' }); - } -}; - export default router; diff --git a/src/api/integrations.ts b/src/api/integrations.ts index 657f06d..cb06075 100644 --- a/src/api/integrations.ts +++ b/src/api/integrations.ts @@ -1,6 +1,7 @@ import { Router, Request, Response } from 'express'; import { supabase } from '../lib/supabase'; import { requireAuth, isAdmin, canAccessCompany } from './middleware/auth'; +import { logger } from './lib/logger'; const router = Router(); @@ -22,7 +23,7 @@ router.get('/', requireAuth, async (req: Request, res: Response) => { if (error) throw error; - console.log('GET /api/integrations - Returning:', { + logger.debug('GET /api/integrations - Returning:', { userId, count: integrations?.length || 0, integrations: integrations?.map((i: any) => ({ @@ -194,6 +195,8 @@ router.post('/:id/sync', requireAuth, async (req: Request, res: Response) => { try { const { id } = req.params; const userId = req.user?.id; + // Imported proposals inherit the integration owner's company for tenant scoping. + const intCompanyId = req.user?.companyId ?? null; if (!userId) { return res.status(401).json({ error: 'User not authenticated' }); @@ -238,7 +241,7 @@ router.post('/:id/sync', requireAuth, async (req: Request, res: Response) => { // If unauthorized and we have a refresh token, try to refresh if (response.status === 401 && refreshToken) { - console.log('Access token expired, refreshing...'); + logger.debug('Access token expired, refreshing...'); const tokenResponse = await fetch('https://api.hubapi.com/oauth/v1/token', { method: 'POST', @@ -269,7 +272,7 @@ router.post('/:id/sync', requireAuth, async (req: Request, res: Response) => { }) .eq('id', id); - console.log('Token refreshed successfully'); + logger.debug('Token refreshed successfully'); // Retry the API call with new token response = await fetch( @@ -423,7 +426,7 @@ router.post('/:id/sync', requireAuth, async (req: Request, res: Response) => { const clientId = process.env.GOOGLE_CLIENT_ID; const clientSecret = process.env.GOOGLE_CLIENT_SECRET; if (refreshToken && clientId && clientSecret) { - console.log('Gmail access token expired, refreshing...'); + logger.debug('Gmail access token expired, refreshing...'); const tokenResponse = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, @@ -451,7 +454,7 @@ router.post('/:id/sync', requireAuth, async (req: Request, res: Response) => { updatedAt: new Date().toISOString(), }) .eq('id', id); - console.log('Gmail token refreshed successfully'); + logger.debug('Gmail token refreshed successfully'); searchResponse = await fetch( `https://gmail.googleapis.com/gmail/v1/users/me/messages?q=${encodeURIComponent(query)}&maxResults=50`, { headers: gmailHeaders() } @@ -686,7 +689,7 @@ router.post('/:id/sync', requireAuth, async (req: Request, res: Response) => { const baseUrl = isSandbox ? 'https://test.salesforce.com' : 'https://login.salesforce.com'; const tokenUrl = `${baseUrl}/services/oauth2/token`; - console.log('Salesforce access token expired, refreshing...'); + logger.debug('Salesforce access token expired, refreshing...'); const tokenResponse = await fetch(tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, @@ -715,7 +718,7 @@ router.post('/:id/sync', requireAuth, async (req: Request, res: Response) => { updatedAt: new Date().toISOString(), }) .eq('id', id); - console.log('Salesforce token refreshed successfully'); + logger.debug('Salesforce token refreshed successfully'); response = await fetch( `${newInstanceUrl}/services/data/${apiVersion}/query?q=${encodeURIComponent(query)}`, { headers: sfHeaders() } diff --git a/src/api/lib/analytics.test.ts b/src/api/lib/analytics.test.ts new file mode 100644 index 0000000..4ef518a --- /dev/null +++ b/src/api/lib/analytics.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect } from 'vitest'; +import { + buildAnalyticsSummary, + discountBucket, + weekStart, + type AnalyticsProposal, +} from './analytics'; + +const NOW = new Date('2026-06-22T00:00:00.000Z'); +const daysAgo = (n: number) => new Date(NOW.getTime() - n * 24 * 60 * 60 * 1000).toISOString(); + +function p(overrides: Partial = {}): AnalyticsProposal { + return { + status: 'PENDING', + createdAt: daysAgo(1), + readinessScore: 80, + riskReport: { legalRisk: 10, pricingRisk: 20, structuralRisk: 30 }, + metadata: { discount: 5, dealSize: 1000, region: 'North America' }, + ...overrides, + }; +} + +describe('discountBucket', () => { + it('buckets against the 25% compliance threshold', () => { + expect(discountBucket(0)).toBe('0%'); + expect(discountBucket(5)).toBe('1-10%'); + expect(discountBucket(10)).toBe('1-10%'); + expect(discountBucket(15)).toBe('11-20%'); + expect(discountBucket(25)).toBe('21-25%'); + expect(discountBucket(30)).toBe('>25%'); + }); +}); + +describe('weekStart', () => { + it('returns the Monday of the week (UTC)', () => { + // 2026-06-22 is a Monday + expect(weekStart(new Date('2026-06-22T12:00:00Z'))).toBe('2026-06-22'); + // 2026-06-24 (Wed) -> same Monday + expect(weekStart(new Date('2026-06-24T12:00:00Z'))).toBe('2026-06-22'); + // 2026-06-21 (Sun) -> previous Monday + expect(weekStart(new Date('2026-06-21T12:00:00Z'))).toBe('2026-06-15'); + }); +}); + +describe('buildAnalyticsSummary', () => { + it('counts statuses and totals', () => { + const s = buildAnalyticsSummary( + [ + p({ status: 'PENDING' }), + p({ status: 'APPROVED' }), + p({ status: 'APPROVED' }), + p({ status: 'REJECTED' }), + p({ status: 'IN_REVIEW' }), + ], + NOW + ); + expect(s.totals).toEqual({ total: 5, pending: 1, inReview: 1, approved: 2, rejected: 1 }); + expect(s.statusBreakdown).toEqual([ + { status: 'PENDING', count: 1 }, + { status: 'IN_REVIEW', count: 1 }, + { status: 'APPROVED', count: 2 }, + { status: 'REJECTED', count: 1 }, + ]); + }); + + it('computes risk averages (rounded)', () => { + const s = buildAnalyticsSummary( + [ + p({ readinessScore: 80, riskReport: { legalRisk: 10, pricingRisk: 20, structuralRisk: 30 } }), + p({ readinessScore: 60, riskReport: { legalRisk: 20, pricingRisk: 40, structuralRisk: 10 } }), + ], + NOW + ); + expect(s.riskAverages).toEqual({ readiness: 70, legal: 15, pricing: 30, structural: 20 }); + }); + + it('counts needsAttention as readiness < 60', () => { + const s = buildAnalyticsSummary( + [p({ readinessScore: 59 }), p({ readinessScore: 60 }), p({ readinessScore: 30 })], + NOW + ); + expect(s.headline.needsAttention.value).toBe(2); + }); + + it('computes period-over-period deltas (current 30d vs prior 30d)', () => { + const s = buildAnalyticsSummary( + [ + p({ createdAt: daysAgo(5) }), // current window + p({ createdAt: daysAgo(10) }), // current window + p({ createdAt: daysAgo(40) }), // previous window + ], + NOW + ); + // current=2, previous=1 -> change +1, +100% + expect(s.headline.total.delta.change).toBe(1); + expect(s.headline.total.delta.changePct).toBe(100); + expect(s.headline.total.delta.up).toBe(true); + }); + + it('zero previous period yields 0% (no divide-by-zero)', () => { + const s = buildAnalyticsSummary([p({ createdAt: daysAgo(2) })], NOW); + expect(s.headline.total.delta.changePct).toBe(0); + }); + + it('builds a 12-week zero-filled created-per-week series', () => { + // daysAgo(0) === NOW === Monday 2026-06-22, so both land in the final week. + const s = buildAnalyticsSummary([p({ createdAt: daysAgo(0) }), p({ createdAt: daysAgo(0) })], NOW); + expect(s.createdPerWeek).toHaveLength(12); + // chronological + const weeks = s.createdPerWeek.map((w) => w.weekStart); + expect([...weeks].sort()).toEqual(weeks); + // this week (Monday 2026-06-22) has the 2 recent proposals + expect(s.createdPerWeek[s.createdPerWeek.length - 1]).toEqual({ weekStart: '2026-06-22', count: 2 }); + }); + + it('distributes discounts into all five buckets', () => { + const s = buildAnalyticsSummary( + [ + p({ metadata: { discount: 0 } }), + p({ metadata: { discount: 8 } }), + p({ metadata: { discount: 18 } }), + p({ metadata: { discount: 24 } }), + p({ metadata: { discount: 40 } }), + ], + NOW + ); + expect(s.discountDistribution).toEqual([ + { bucket: '0%', count: 1 }, + { bucket: '1-10%', count: 1 }, + { bucket: '11-20%', count: 1 }, + { bucket: '21-25%', count: 1 }, + { bucket: '>25%', count: 1 }, + ]); + }); + + it('sums deal value by region, descending, defaulting blank region to Unknown', () => { + const s = buildAnalyticsSummary( + [ + p({ metadata: { dealSize: 1000, region: 'EMEA' } }), + p({ metadata: { dealSize: 3000, region: 'APAC' } }), + p({ metadata: { dealSize: 500, region: '' } }), + p({ metadata: { dealSize: 0, region: 'EMEA' } }), // ignored (no value) + ], + NOW + ); + expect(s.dealValueByRegion).toEqual([ + { region: 'APAC', total: 3000 }, + { region: 'EMEA', total: 1000 }, + { region: 'Unknown', total: 500 }, + ]); + }); + + it('handles an empty dataset without throwing', () => { + const s = buildAnalyticsSummary([], NOW); + expect(s.totals.total).toBe(0); + expect(s.riskAverages.readiness).toBe(0); + expect(s.dealValueByRegion).toEqual([]); + expect(s.createdPerWeek).toHaveLength(12); + }); +}); diff --git a/src/api/lib/analytics.ts b/src/api/lib/analytics.ts new file mode 100644 index 0000000..d04db70 --- /dev/null +++ b/src/api/lib/analytics.ts @@ -0,0 +1,209 @@ +/** + * Pure analytics aggregation for the dashboard. Functions take plain proposal + * rows and return chart-ready shapes — no DB or network access — so they are + * cheap to unit-test (see analytics.test.ts). The route layer + * (src/api/analytics.ts) fetches rows and calls buildAnalyticsSummary. + */ + +import { MAX_DISCOUNT_PERCENT } from './compliance'; + +export interface AnalyticsProposal { + status: string; + createdAt: string; + readinessScore: number; + riskReport?: { + legalRisk?: number; + pricingRisk?: number; + structuralRisk?: number; + } | null; + metadata?: { + discount?: number; + dealSize?: number; + region?: string; + } | null; +} + +export interface Delta { + /** Absolute change vs the previous period. */ + change: number; + /** Percentage change vs the previous period (0 when previous was 0). */ + changePct: number; + /** Direction for UI styling. */ + up: boolean; +} + +export interface AnalyticsSummary { + totals: { total: number; pending: number; inReview: number; approved: number; rejected: number }; + headline: { + total: { value: number; delta: Delta }; + pending: { value: number; delta: Delta }; + avgReadiness: { value: number; delta: Delta }; + needsAttention: { value: number; delta: Delta }; + }; + statusBreakdown: { status: string; count: number }[]; + riskAverages: { readiness: number; legal: number; pricing: number; structural: number }; + createdPerWeek: { weekStart: string; count: number }[]; + discountDistribution: { bucket: string; count: number }[]; + dealValueByRegion: { region: string; total: number }[]; +} + +const STATUSES = ['PENDING', 'IN_REVIEW', 'APPROVED', 'REJECTED'] as const; +const NEEDS_ATTENTION_BELOW = 60; +const PERIOD_DAYS = 30; +const WEEKS_BACK = 12; +const DAY_MS = 24 * 60 * 60 * 1000; + +function round(n: number): number { + return Math.round(n); +} + +function avg(values: number[]): number { + if (values.length === 0) return 0; + return values.reduce((a, b) => a + b, 0) / values.length; +} + +function makeDelta(current: number, previous: number): Delta { + const change = current - previous; + const changePct = previous === 0 ? 0 : round((change / previous) * 100); + return { change, changePct, up: change >= 0 }; +} + +/** Monday (UTC) of the week containing `d`, as an ISO date string (YYYY-MM-DD). */ +export function weekStart(d: Date): string { + const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); + const day = date.getUTCDay(); // 0=Sun..6=Sat + const diff = (day === 0 ? -6 : 1) - day; // shift back to Monday + date.setUTCDate(date.getUTCDate() + diff); + return date.toISOString().slice(0, 10); +} + +/** Bucket a discount percentage against the compliance thresholds. */ +export function discountBucket(discount: number): string { + if (discount <= 0) return '0%'; + if (discount <= 10) return '1-10%'; + if (discount <= 20) return '11-20%'; + if (discount <= MAX_DISCOUNT_PERCENT) return `21-${MAX_DISCOUNT_PERCENT}%`; + return `>${MAX_DISCOUNT_PERCENT}%`; +} + +export function buildAnalyticsSummary( + proposals: AnalyticsProposal[], + now: Date = new Date() +): AnalyticsSummary { + const total = proposals.length; + + // Status counts + const statusCounts: Record = {}; + for (const s of STATUSES) statusCounts[s] = 0; + for (const p of proposals) { + statusCounts[p.status] = (statusCounts[p.status] ?? 0) + 1; + } + + const totals = { + total, + pending: statusCounts['PENDING'] ?? 0, + inReview: statusCounts['IN_REVIEW'] ?? 0, + approved: statusCounts['APPROVED'] ?? 0, + rejected: statusCounts['REJECTED'] ?? 0, + }; + + // Risk averages + const riskAverages = { + readiness: round(avg(proposals.map((p) => p.readinessScore || 0))), + legal: round(avg(proposals.map((p) => p.riskReport?.legalRisk ?? 0))), + pricing: round(avg(proposals.map((p) => p.riskReport?.pricingRisk ?? 0))), + structural: round(avg(proposals.map((p) => p.riskReport?.structuralRisk ?? 0))), + }; + + // Period-over-period deltas: current 30d window vs the prior 30d window. + const nowMs = now.getTime(); + const currentStart = nowMs - PERIOD_DAYS * DAY_MS; + const prevStart = nowMs - 2 * PERIOD_DAYS * DAY_MS; + const inWindow = (p: AnalyticsProposal, from: number, to: number) => { + const t = new Date(p.createdAt).getTime(); + return t >= from && t < to; + }; + const current = proposals.filter((p) => inWindow(p, currentStart, nowMs)); + const previous = proposals.filter((p) => inWindow(p, prevStart, currentStart)); + + const needsAttentionCount = (rows: AnalyticsProposal[]) => + rows.filter((p) => (p.readinessScore || 0) < NEEDS_ATTENTION_BELOW).length; + const pendingCount = (rows: AnalyticsProposal[]) => + rows.filter((p) => p.status === 'PENDING').length; + + const headline = { + total: { value: total, delta: makeDelta(current.length, previous.length) }, + pending: { + value: totals.pending, + delta: makeDelta(pendingCount(current), pendingCount(previous)), + }, + avgReadiness: { + value: riskAverages.readiness, + delta: makeDelta( + round(avg(current.map((p) => p.readinessScore || 0))), + round(avg(previous.map((p) => p.readinessScore || 0))) + ), + }, + needsAttention: { + value: needsAttentionCount(proposals), + delta: makeDelta(needsAttentionCount(current), needsAttentionCount(previous)), + }, + }; + + // Created per week over the last WEEKS_BACK weeks (zero-filled, chronological). + const weekCounts: Record = {}; + for (let i = WEEKS_BACK - 1; i >= 0; i--) { + const d = new Date(nowMs - i * 7 * DAY_MS); + weekCounts[weekStart(d)] = 0; + } + for (const p of proposals) { + const ws = weekStart(new Date(p.createdAt)); + if (ws in weekCounts) weekCounts[ws] += 1; + } + const createdPerWeek = Object.keys(weekCounts) + .sort() + .map((weekStartKey) => ({ weekStart: weekStartKey, count: weekCounts[weekStartKey] })); + + // Discount distribution + const discountBuckets: Record = { + '0%': 0, + '1-10%': 0, + '11-20%': 0, + [`21-${MAX_DISCOUNT_PERCENT}%`]: 0, + [`>${MAX_DISCOUNT_PERCENT}%`]: 0, + }; + for (const p of proposals) { + const discount = Number(p.metadata?.discount); + if (!Number.isNaN(discount)) { + discountBuckets[discountBucket(discount)] += 1; + } + } + const discountDistribution = Object.keys(discountBuckets).map((bucket) => ({ + bucket, + count: discountBuckets[bucket], + })); + + // Deal value by region (descending by total) + const regionTotals: Record = {}; + for (const p of proposals) { + const dealSize = Number(p.metadata?.dealSize); + if (Number.isNaN(dealSize) || dealSize <= 0) continue; + const region = p.metadata?.region?.trim() || 'Unknown'; + regionTotals[region] = (regionTotals[region] ?? 0) + dealSize; + } + const dealValueByRegion = Object.keys(regionTotals) + .map((region) => ({ region, total: round(regionTotals[region]) })) + .sort((a, b) => b.total - a.total); + + const statusBreakdown = STATUSES.map((status) => ({ status, count: statusCounts[status] ?? 0 })); + + return { + totals, + headline, + statusBreakdown, + riskAverages, + createdPerWeek, + discountDistribution, + dealValueByRegion, + }; +} diff --git a/src/api/lib/auditQuery.ts b/src/api/lib/auditQuery.ts new file mode 100644 index 0000000..7a452ac --- /dev/null +++ b/src/api/lib/auditQuery.ts @@ -0,0 +1,84 @@ +/** + * Shared, company-scoped AuditLog querying used by both the audit page + * (/api/audit) and the notifications bell (/api/notifications). + * + * Scoping: admins see everything; everyone else sees audit entries they are the + * actor of, plus entries on proposals belonging to their company. + */ + +import { Request } from 'express'; +import { supabase } from '../../lib/supabase'; +import { isAdmin } from '../middleware/auth'; + +export interface FormattedAuditLog { + id: string; + action: string; + timestamp: string; + actorId: string; + actor: { name: string | null; email: string; role: string }; + proposalId: string | null; + proposal: { title: string } | null; +} + +interface AuditRow { + id: string; + action: string; + timestamp: string; + actorId: string; + User: { name: string | null; email: string; role: string } | null; + proposalId: string | null; + Proposal: { title: string } | null; +} + +function format(log: AuditRow): FormattedAuditLog { + return { + id: log.id, + action: log.action, + timestamp: log.timestamp, + actorId: log.actorId, + actor: { + name: log.User?.name ?? null, + email: log.User?.email ?? '', + role: log.User?.role ?? '', + }, + proposalId: log.proposalId, + proposal: log.Proposal ? { title: log.Proposal.title } : null, + }; +} + +export async function getScopedAuditLogs(req: Request, limit = 100): Promise { + let query = supabase + .from('AuditLog') + .select(` + *, + User:actorId (name, email, role), + Proposal:proposalId (title) + `) + .order('timestamp', { ascending: false }) + .limit(limit); + + if (!isAdmin(req)) { + const uid = req.user?.id ?? ''; + const companyId = req.user?.companyId; + + if (companyId) { + // Restrict to the user's own actions OR audit entries on their company's proposals. + const { data: companyProposals } = await supabase + .from('Proposal') + .select('id') + .eq('company_id', companyId); + const ids = (companyProposals || []).map((p: { id: string }) => p.id); + + const orParts = [`actorId.eq.${uid}`]; + if (ids.length > 0) orParts.push(`proposalId.in.(${ids.join(',')})`); + query = query.or(orParts.join(',')); + } else { + // No company context: only the user's own actions. + query = query.eq('actorId', uid); + } + } + + const { data, error } = await query; + if (error) throw error; + return (data || []).map(format); +} diff --git a/src/api/lib/compliance.test.ts b/src/api/lib/compliance.test.ts new file mode 100644 index 0000000..c6b725e --- /dev/null +++ b/src/api/lib/compliance.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from 'vitest'; +import { + classifyRiskLevel, + normalizeAnalysis, + shouldAutoReview, + markdownToHtml, + MAX_DISCOUNT_PERCENT, + MIN_DEAL_SIZE, + AUTO_REVIEW_THRESHOLD, +} from './compliance'; + +describe('classifyRiskLevel', () => { + it('classifies high scores as Low Risk (green)', () => { + expect(classifyRiskLevel(100).label).toBe('Low Risk'); + expect(classifyRiskLevel(70).label).toBe('Low Risk'); + expect(classifyRiskLevel(70).color).toBe('#22c55e'); + }); + + it('classifies mid scores as Medium Risk (amber)', () => { + expect(classifyRiskLevel(69).label).toBe('Medium Risk'); + expect(classifyRiskLevel(40).label).toBe('Medium Risk'); + expect(classifyRiskLevel(55).color).toBe('#f59e0b'); + }); + + it('classifies low scores as High Risk (red)', () => { + expect(classifyRiskLevel(39).label).toBe('High Risk'); + expect(classifyRiskLevel(0).label).toBe('High Risk'); + expect(classifyRiskLevel(0).color).toBe('#ef4444'); + }); + + it('treats the band boundaries as inclusive on the upper band', () => { + // 70 -> Low, 69 -> Medium, 40 -> Medium, 39 -> High + expect(classifyRiskLevel(70).label).toBe('Low Risk'); + expect(classifyRiskLevel(69).label).toBe('Medium Risk'); + expect(classifyRiskLevel(40).label).toBe('Medium Risk'); + expect(classifyRiskLevel(39).label).toBe('High Risk'); + }); +}); + +describe('normalizeAnalysis', () => { + it('applies safe defaults for a null/empty analysis', () => { + expect(normalizeAnalysis(null)).toEqual({ + readinessScore: 50, + legalRisk: 20, + pricingRisk: 20, + structuralRisk: 20, + findings: [], + recommendations: [], + }); + expect(normalizeAnalysis({})).toEqual(normalizeAnalysis(null)); + }); + + it('preserves provided values', () => { + const result = normalizeAnalysis({ + readinessScore: 85, + legalRisk: 5, + pricingRisk: 10, + structuralRisk: 15, + findings: [{ level: 'LOW' }], + recommendations: [{ suggestion: 'tidy up' }], + }); + expect(result.readinessScore).toBe(85); + expect(result.legalRisk).toBe(5); + expect(result.findings).toHaveLength(1); + expect(result.recommendations).toHaveLength(1); + }); + + it('falls back to defaults when a score is zero/falsy (documented behaviour)', () => { + // 0 is falsy so it defaults — this mirrors the original `x || default` logic. + const result = normalizeAnalysis({ readinessScore: 0, legalRisk: 0 }); + expect(result.readinessScore).toBe(50); + expect(result.legalRisk).toBe(20); + }); +}); + +describe('shouldAutoReview', () => { + it('advances at or above the threshold', () => { + expect(shouldAutoReview(AUTO_REVIEW_THRESHOLD)).toBe(true); + expect(shouldAutoReview(100)).toBe(true); + }); + + it('does not advance below the threshold', () => { + expect(shouldAutoReview(AUTO_REVIEW_THRESHOLD - 1)).toBe(false); + expect(shouldAutoReview(0)).toBe(false); + }); +}); + +describe('markdownToHtml', () => { + it('returns empty string for falsy input', () => { + expect(markdownToHtml('')).toBe(''); + }); + + it('converts headers', () => { + expect(markdownToHtml('# Title')).toContain('

Title

'); + expect(markdownToHtml('## Sub')).toContain('

Sub

'); + expect(markdownToHtml('### Small')).toContain('

Small

'); + }); + + it('converts bold, italic and underline', () => { + expect(markdownToHtml('**bold**')).toContain('bold'); + expect(markdownToHtml('a *italic* b')).toContain('italic'); + expect(markdownToHtml('__under__')).toContain('under'); + }); + + it('converts bullet lines to list items', () => { + expect(markdownToHtml('- item')).toContain('
  • item
  • '); + expect(markdownToHtml('• item')).toContain('
  • item
  • '); + }); + + it('converts newlines to
    ', () => { + expect(markdownToHtml('a\nb')).toContain('
    '); + }); +}); + +describe('compliance constants', () => { + it('exposes the documented thresholds', () => { + expect(MAX_DISCOUNT_PERCENT).toBe(25); + expect(MIN_DEAL_SIZE).toBe(10_000); + expect(AUTO_REVIEW_THRESHOLD).toBe(80); + }); +}); diff --git a/src/api/lib/compliance.ts b/src/api/lib/compliance.ts new file mode 100644 index 0000000..0eb1dba --- /dev/null +++ b/src/api/lib/compliance.ts @@ -0,0 +1,104 @@ +/** + * Deterministic compliance & risk helpers shared by the analyze and proposal + * (PDF export) routes. The heavy risk *judgement* is delegated to the LLM; the + * helpers here are the deterministic glue around it — defaulting, classifying, + * and rendering — and are unit-tested. + */ + +/** Maximum discount percentage allowed before a proposal is a CRITICAL violation. */ +export const MAX_DISCOUNT_PERCENT = 25; + +/** Minimum deal size (USD) below which a proposal is a CRITICAL violation. */ +export const MIN_DEAL_SIZE = 10_000; + +/** Readiness score at/above which a proposal auto-advances to IN_REVIEW. */ +export const AUTO_REVIEW_THRESHOLD = 80; + +export interface RiskLevel { + color: string; + label: string; + bg: string; +} + +/** + * Classify a 0-100 score into a color-coded risk band. + * Higher score = healthier (Low Risk). Used by the PDF risk dashboard. + */ +export function classifyRiskLevel(score: number): RiskLevel { + if (score >= 70) return { color: '#22c55e', label: 'Low Risk', bg: '#f0fdf4' }; + if (score >= 40) return { color: '#f59e0b', label: 'Medium Risk', bg: '#fef3c7' }; + return { color: '#ef4444', label: 'High Risk', bg: '#fee2e2' }; +} + +export interface RawAnalysis { + readinessScore?: number; + legalRisk?: number; + pricingRisk?: number; + structuralRisk?: number; + findings?: unknown[]; + recommendations?: unknown[]; +} + +export interface NormalizedAnalysis { + readinessScore: number; + legalRisk: number; + pricingRisk: number; + structuralRisk: number; + findings: unknown[]; + recommendations: unknown[]; +} + +/** + * Apply safe defaults to a raw AI analysis object so a partial/garbled LLM + * response never persists null/undefined scores. Mirrors the historical + * `analysis.x || default` behaviour in one place. + */ +export function normalizeAnalysis(raw: RawAnalysis | null | undefined): NormalizedAnalysis { + const a = raw ?? {}; + return { + readinessScore: a.readinessScore || 50, + legalRisk: a.legalRisk || 20, + pricingRisk: a.pricingRisk || 20, + structuralRisk: a.structuralRisk || 20, + findings: a.findings || [], + recommendations: a.recommendations || [], + }; +} + +/** Whether a readiness score should auto-advance the proposal to IN_REVIEW. */ +export function shouldAutoReview(readinessScore: number): boolean { + return readinessScore >= AUTO_REVIEW_THRESHOLD; +} + +/** + * Convert a subset of markdown to HTML for rendering. Note: this does NOT + * escape HTML — callers that embed untrusted content elsewhere must escape + * separately. Preserved verbatim from the proposal PDF renderer. + */ +export function markdownToHtml(text: string): string { + if (!text) return ''; + + let html = text; + + // Convert headers (must be at start of line) + html = html.replace(/^### (.+)$/gm, '

    $1

    '); + html = html.replace(/^## (.+)$/gm, '

    $1

    '); + html = html.replace(/^# (.+)$/gm, '

    $1

    '); + + // Convert **bold** to bold (greedy match within lines) + html = html.replace(/\*\*([^\n]+?)\*\*/g, '$1'); + + // Convert *italic* to italic (single asterisk, not part of **) + html = html.replace(/(?$1'); + + // Convert __underline__ to underline + html = html.replace(/__([^\n]+?)__/g, '$1'); + + // Convert bullet points + html = html.replace(/^[•\-*] (.+)$/gm, '
  • $1
  • '); + + // Convert line breaks to
    for proper display + html = html.replace(/\n/g, '
    \n'); + + return html; +} diff --git a/src/api/lib/email.ts b/src/api/lib/email.ts new file mode 100644 index 0000000..e7ccc82 --- /dev/null +++ b/src/api/lib/email.ts @@ -0,0 +1,48 @@ +/** + * Email delivery. Default provider is Resend (set EMAIL_PROVIDER=smtp + add a + * nodemailer transport later for SMTP). Sending is OFF unless EMAIL_ENABLED is + * "true" AND a key is configured, and is NEVER attempted under tests — so dev + * and CI never send real mail. + */ + +import { Resend } from 'resend'; +import { logger } from './logger'; +import type { EmailMessage } from './emailTemplates'; + +const EMAIL_ENABLED = process.env.EMAIL_ENABLED === 'true'; +const FROM = process.env.EMAIL_FROM || 'DealSentry '; +const isTest = process.env.NODE_ENV === 'test'; + +let resendClient: Resend | null = null; +function getResend(): Resend { + if (!resendClient) resendClient = new Resend(process.env.RESEND_API_KEY); + return resendClient; +} + +/** + * Send an email. Returns true only if a message was actually dispatched. + * Best-effort: failures are logged, never thrown, so callers can fire-and-forget. + */ +export async function sendEmail(to: string, message: EmailMessage): Promise { + if (isTest || !EMAIL_ENABLED) { + logger.debug(`[email] skipped (enabled=${EMAIL_ENABLED}) → ${to}: ${message.subject}`); + return false; + } + if (!process.env.RESEND_API_KEY) { + logger.warn('[email] EMAIL_ENABLED is set but RESEND_API_KEY is missing; not sending.'); + return false; + } + try { + await getResend().emails.send({ + from: FROM, + to, + subject: message.subject, + html: message.html, + }); + logger.info(`[email] sent → ${to}: ${message.subject}`); + return true; + } catch (err) { + logger.error('[email] send failed', err); + return false; + } +} diff --git a/src/api/lib/emailTemplates.test.ts b/src/api/lib/emailTemplates.test.ts new file mode 100644 index 0000000..3b5549e --- /dev/null +++ b/src/api/lib/emailTemplates.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { buildStatusChangeEmail } from './emailTemplates'; + +describe('buildStatusChangeEmail', () => { + it('includes the proposal title and a friendly status label in the subject', () => { + const { subject } = buildStatusChangeEmail({ proposalTitle: 'Acme MSA', status: 'APPROVED' }); + expect(subject).toBe('Proposal "Acme MSA" is now Approved'); + }); + + it('falls back to the raw status when unknown', () => { + const { subject } = buildStatusChangeEmail({ proposalTitle: 'X', status: 'WEIRD' }); + expect(subject).toContain('WEIRD'); + }); + + it('greets the recipient by name when provided', () => { + const { html } = buildStatusChangeEmail({ proposalTitle: 'X', status: 'PENDING', recipientName: 'Dana' }); + expect(html).toContain('Hi Dana,'); + }); + + it('uses a generic greeting when no name', () => { + const { html } = buildStatusChangeEmail({ proposalTitle: 'X', status: 'PENDING' }); + expect(html).toContain('Hi,'); + }); + + it('escapes HTML in the proposal title (XSS safety)', () => { + const { html } = buildStatusChangeEmail({ + proposalTitle: '', + status: 'APPROVED', + }); + expect(html).not.toContain('