From f25f22081a37287751d75a91b2d01f616fecbb61 Mon Sep 17 00:00:00 2001 From: Roger Demello Date: Mon, 22 Jun 2026 21:18:39 +0530 Subject: [PATCH 1/8] chore: add test/CI/Docker foundation, rate limiting, structured logger Vitest+Supertest setup, GitHub Actions CI (lint/typecheck/test/build), multi-stage Dockerfile with Chromium for PDF export, helmet+rate-limit middleware, and a NODE_ENV-gated logger. Replaces the broken eslint-config-next config with a proper Vite flat config. --- .dockerignore | 15 +++ .github/workflows/ci.yml | 39 ++++++++ Dockerfile | 44 +++++++++ docker-compose.yml | 17 ++++ docs/DEPLOYMENT.md | 83 +++++++++++++++++ eslint.config.mjs | 62 +++++++++---- src/api/lib/logger.ts | 27 ++++++ src/api/middleware/rateLimit.ts | 30 ++++++ tests/auth.test.ts | 158 ++++++++++++++++++++++++++++++++ tests/setup.ts | 17 ++++ vitest.config.ts | 23 +++++ 11 files changed, 498 insertions(+), 17 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/ci.yml create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docs/DEPLOYMENT.md create mode 100644 src/api/lib/logger.ts create mode 100644 src/api/middleware/rateLimit.ts create mode 100644 tests/auth.test.ts create mode 100644 tests/setup.ts create mode 100644 vitest.config.ts 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/.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..6458e57 --- /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 "fetch('http://localhost:3001/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..84dcd5b --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,83 @@ +# 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. + +## 5. Verify + +```bash +curl -s http://localhost:3001/api/health # -> {"status":"ok",...} +``` + +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/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/src/api/lib/logger.ts b/src/api/lib/logger.ts new file mode 100644 index 0000000..648f5ee --- /dev/null +++ b/src/api/lib/logger.ts @@ -0,0 +1,27 @@ +/** + * Minimal leveled logger for the API layer. + * + * - debug: developer diagnostics; silenced in production and tests. + * - info: operational messages; silenced in tests to keep output clean. + * - warn / error: always emitted. + * + * Never pass secrets (tokens, passwords, credentials) to any level. + */ + +const isProduction = process.env.NODE_ENV === 'production'; +const isTest = process.env.NODE_ENV === 'test'; + +export const logger = { + debug: (...args: unknown[]): void => { + if (!isProduction && !isTest) console.log(...args); + }, + info: (...args: unknown[]): void => { + if (!isTest) console.log(...args); + }, + warn: (...args: unknown[]): void => { + console.warn(...args); + }, + error: (...args: unknown[]): void => { + console.error(...args); + }, +}; diff --git a/src/api/middleware/rateLimit.ts b/src/api/middleware/rateLimit.ts new file mode 100644 index 0000000..3991f17 --- /dev/null +++ b/src/api/middleware/rateLimit.ts @@ -0,0 +1,30 @@ +/** + * Rate limiters for sensitive endpoints. + * - authLimiter: guards against credential brute-force on auth routes. + * - aiLimiter: guards against runaway Azure OpenAI cost on AI routes. + * + * Limits are relaxed automatically outside production so local dev and tests + * are not throttled. + */ + +import rateLimit from 'express-rate-limit'; + +const isProduction = process.env.NODE_ENV === 'production'; + +/** Strict limiter for login/register/change-password. */ +export const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: isProduction ? 10 : 1000, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many attempts. Please try again later.' }, +}); + +/** Limiter for AI generation/analysis endpoints (cost protection). */ +export const aiLimiter = rateLimit({ + windowMs: 60 * 1000, // 1 minute + max: isProduction ? 20 : 1000, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many AI requests. Please slow down and try again shortly.' }, +}); diff --git a/tests/auth.test.ts b/tests/auth.test.ts new file mode 100644 index 0000000..f809fb6 --- /dev/null +++ b/tests/auth.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import bcrypt from 'bcryptjs'; + +// Shared, hoisted Supabase mock. The real client is a chainable query builder +// (`.from().select().eq().single()`); we return a single controllable result. +const { mockSingle, supabaseMock } = vi.hoisted(() => { + const mockSingle = vi.fn(); + const builder: Record = {}; + builder.select = vi.fn(() => builder); + builder.eq = vi.fn(() => builder); + builder.single = mockSingle; + const supabaseMock = { from: vi.fn(() => builder) }; + return { mockSingle, supabaseMock }; +}); + +vi.mock('../src/lib/supabase', () => ({ supabase: supabaseMock, default: supabaseMock })); + +// Imported after the mock is registered. +import { requireAuth, isAdmin, canAccessCompany } from '../src/api/middleware/auth'; +import authRouter from '../src/api/auth'; + +const JWT_SECRET = process.env.NEXTAUTH_SECRET as string; + +function buildApp() { + const app = express(); + app.use(express.json()); + app.use('/api/auth', authRouter); + app.get('/protected', requireAuth, (req, res) => { + res.json({ user: req.user }); + }); + return app; +} + +function signToken(payload: Record) { + return jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' }); +} + +beforeEach(() => { + mockSingle.mockReset(); +}); + +describe('requireAuth middleware', () => { + it('rejects requests with no token (401)', async () => { + const res = await request(buildApp()).get('/protected'); + expect(res.status).toBe(401); + expect(res.body.error).toMatch(/authentication required/i); + }); + + it('rejects an invalid/garbage token (401)', async () => { + const res = await request(buildApp()) + .get('/protected') + .set('Authorization', 'Bearer not-a-real-token'); + expect(res.status).toBe(401); + }); + + it('rejects an expired token (401, TOKEN_EXPIRED)', async () => { + const expired = jwt.sign({ userId: 'u1' }, JWT_SECRET, { expiresIn: -10 }); + const res = await request(buildApp()) + .get('/protected') + .set('Authorization', `Bearer ${expired}`); + expect(res.status).toBe(401); + expect(res.body.code).toBe('TOKEN_EXPIRED'); + }); + + it('accepts a valid token and attaches the DB user to req.user', async () => { + mockSingle.mockResolvedValue({ + data: { id: 'u1', email: 'a@b.com', role: 'SALES_REP', company_id: 'c1' }, + error: null, + }); + const token = signToken({ userId: 'u1' }); + const res = await request(buildApp()) + .get('/protected') + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(200); + expect(res.body.user).toMatchObject({ id: 'u1', role: 'SALES_REP', companyId: 'c1' }); + }); + + it('rejects a valid token whose user no longer exists (401)', async () => { + mockSingle.mockResolvedValue({ data: null, error: { message: 'not found' } }); + const token = signToken({ userId: 'ghost' }); + const res = await request(buildApp()) + .get('/protected') + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(401); + }); +}); + +describe('RBAC helpers', () => { + const adminReq = { user: { id: 'a', email: 'a', role: 'ADMIN', companyId: 'c1' } } as never; + const repReq = { user: { id: 'r', email: 'r', role: 'SALES_REP', companyId: 'c1' } } as never; + + it('isAdmin is true only for ADMIN role', () => { + expect(isAdmin(adminReq)).toBe(true); + expect(isAdmin(repReq)).toBe(false); + }); + + it('admin can access any company', () => { + expect(canAccessCompany('c2', adminReq)).toBe(true); + }); + + it('non-admin can access only their own company', () => { + expect(canAccessCompany('c1', repReq)).toBe(true); + expect(canAccessCompany('c2', repReq)).toBe(false); + }); + + it('null company is allowed (backward compat)', () => { + expect(canAccessCompany(null, repReq)).toBe(true); + }); +}); + +describe('POST /api/auth/login', () => { + it('400 when email/password missing', async () => { + const res = await request(buildApp()).post('/api/auth/login').send({ email: 'a@b.com' }); + expect(res.status).toBe(400); + }); + + it('401 on unknown user', async () => { + mockSingle.mockResolvedValue({ data: null, error: { message: 'no rows' } }); + const res = await request(buildApp()) + .post('/api/auth/login') + .send({ email: 'nobody@b.com', password: 'whatever' }); + expect(res.status).toBe(401); + }); + + it('401 on wrong password', async () => { + const hash = await bcrypt.hash('correct-password', 10); + mockSingle.mockResolvedValue({ + data: { id: 'u1', email: 'a@b.com', role: 'SALES_REP', password: hash, company_id: 'c1' }, + error: null, + }); + const res = await request(buildApp()) + .post('/api/auth/login') + .send({ email: 'a@b.com', password: 'wrong-password' }); + expect(res.status).toBe(401); + }); + + it('200 and returns a token on correct credentials', async () => { + const hash = await bcrypt.hash('correct-password', 10); + mockSingle.mockResolvedValue({ + data: { id: 'u1', email: 'a@b.com', name: 'A', role: 'SALES_REP', password: hash, company_id: 'c1' }, + error: null, + }); + const res = await request(buildApp()) + .post('/api/auth/login') + .send({ email: 'a@b.com', password: 'correct-password' }); + expect(res.status).toBe(200); + expect(res.body.token).toBeTruthy(); + expect(res.body.user).toMatchObject({ id: 'u1', role: 'SALES_REP', companyId: 'c1' }); + + // Token must be verifiable and carry the right claims. + const decoded = jwt.verify(res.body.token, JWT_SECRET) as Record; + expect(decoded.userId).toBe('u1'); + expect(decoded.role).toBe('SALES_REP'); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..9d23f7a --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,17 @@ +/** + * Global test setup. Runs before any test module is imported, so env vars + * read at module load time (JWT secret, Azure config) are deterministic. + */ + +process.env.NODE_ENV = 'test'; +process.env.NEXTAUTH_SECRET = 'test-secret-not-for-production'; + +// Azure OpenAI is always mocked in tests; provide dummy values so the client +// constructor does not throw on missing config. +process.env.AZURE_OPENAI_ENDPOINT = process.env.AZURE_OPENAI_ENDPOINT || 'https://test.openai.azure.com/'; +process.env.OPENAI_API_KEY = process.env.OPENAI_API_KEY || 'test-key'; +process.env.AZURE_OPENAI_DEPLOYMENT = process.env.AZURE_OPENAI_DEPLOYMENT || 'gpt-4o'; + +// Supabase client needs URL/key to construct; tests mock the data layer. +process.env.SUPABASE_URL = process.env.SUPABASE_URL || 'https://test.supabase.co'; +process.env.SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY || 'test-anon-key'; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..21f1a7a --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +// Vitest config kept separate from vite.config.ts so the app build and the +// test runner stay decoupled. API/business-logic tests run in a Node env. +export default defineConfig({ + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + test: { + environment: 'node', + globals: true, + include: ['src/**/*.{test,spec}.{ts,tsx}', 'tests/**/*.{test,spec}.ts'], + setupFiles: ['./tests/setup.ts'], + coverage: { + provider: 'v8', + include: ['src/api/**/*.ts'], + exclude: ['src/api/**/*.{test,spec}.ts'], + }, + }, +}); From 954d3dcb101f7240d32ee75c8892b1c63692fbd0 Mon Sep 17 00:00:00 2001 From: Roger Demello Date: Mon, 22 Jun 2026 21:18:40 +0530 Subject: [PATCH 2/8] refactor: type-safety cleanup + shared compliance helpers Extract deterministic risk/markdown logic into src/api/lib/compliance.ts (unit-tested). Replace as-any JWT casts, fix latent bugs (intCompanyId undefined, change-password req.user.id, apiOrigin typo), align frontend types. Remove dead authMiddleware. --- src/api/analyze.ts | 37 +++++----- src/api/auth.ts | 36 ++++------ src/api/integrations.ts | 17 +++-- src/api/lib/compliance.test.ts | 121 +++++++++++++++++++++++++++++++++ src/api/lib/compliance.ts | 104 ++++++++++++++++++++++++++++ src/api/oauth.ts | 25 +++++-- src/pages/Audit.tsx | 3 +- src/pages/Compliance.tsx | 6 +- src/pages/Home.tsx | 6 +- src/pages/Integrations.tsx | 2 +- 10 files changed, 295 insertions(+), 62 deletions(-) create mode 100644 src/api/lib/compliance.test.ts create mode 100644 src/api/lib/compliance.ts 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/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/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/oauth.ts b/src/api/oauth.ts index ed21f52..e6e77b4 100644 --- a/src/api/oauth.ts +++ b/src/api/oauth.ts @@ -1,6 +1,7 @@ import { Router, Request, Response } from 'express'; import { supabase } from '../lib/supabase'; import { requireAuth } from './middleware/auth'; +import { logger } from './lib/logger'; import jwt from 'jsonwebtoken'; const router = Router(); @@ -17,8 +18,18 @@ const requireAuthFromQuery = (req: Request, res: Response, next: Function) => { } try { - const decoded = jwt.verify(token, process.env.NEXTAUTH_SECRET!) as any; - req.user = { id: decoded.userId }; + const decoded = jwt.verify(token, process.env.NEXTAUTH_SECRET!) as { + userId: string; + email?: string; + role?: string; + companyId?: string | null; + }; + req.user = { + id: decoded.userId, + email: decoded.email ?? '', + role: decoded.role ?? '', + companyId: decoded.companyId ?? null, + }; next(); } catch (error) { return res.redirect(`${FRONTEND_URL}/integrations?error=unauthorized`); @@ -340,7 +351,7 @@ router.get('/hubspot/callback', async (req: Request, res: Response) => { .single(); if (existing) { - console.log('Updating existing HubSpot integration:', existing.id); + logger.debug('Updating existing HubSpot integration:', existing.id); const { data: updated, error: updateError } = await supabase .from('Integration') @@ -354,9 +365,9 @@ router.get('/hubspot/callback', async (req: Request, res: Response) => { .single(); if (updateError) throw updateError; - console.log('HubSpot integration updated successfully'); + logger.debug('HubSpot integration updated successfully'); } else { - console.log('Creating new HubSpot integration for user:', userId); + logger.debug('Creating new HubSpot integration for user:', userId); await supabase.from('Integration').insert({ id: (crypto as any).randomUUID(), @@ -369,7 +380,7 @@ router.get('/hubspot/callback', async (req: Request, res: Response) => { updatedAt: new Date().toISOString(), }); - console.log('HubSpot integration created successfully'); + logger.debug('HubSpot integration created successfully'); } } catch (err) { console.error('Failed to upsert HubSpot integration:', err); @@ -439,7 +450,7 @@ router.get('/gmail/authorize', requireAuthFromQuery, async (req: Request, res: R const clientId = process.env.GOOGLE_CLIENT_ID; const redirectUri = process.env.GOOGLE_REDIRECT_URI || 'http://localhost:3001/api/oauth/gmail/callback'; const scopes = 'https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/gmail.readonly'; - let authUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(scopes)}&access_type=offline&prompt=consent&state=${encodeURIComponent(userId)}`; + const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(scopes)}&access_type=offline&prompt=consent&state=${encodeURIComponent(userId)}`; res.redirect(authUrl); }); diff --git a/src/pages/Audit.tsx b/src/pages/Audit.tsx index ced41c6..554bf4b 100644 --- a/src/pages/Audit.tsx +++ b/src/pages/Audit.tsx @@ -10,8 +10,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { auditApi } from "@/lib/api-client"; -import { AuditLog } from "@/types"; +import { auditApi, type AuditLog } from "@/lib/api-client"; import { formatIST } from "@/lib/utils"; type Severity = "ALL" | "CRITICAL" | "WARNING" | "INFO"; diff --git a/src/pages/Compliance.tsx b/src/pages/Compliance.tsx index 5f7a369..ce350cb 100644 --- a/src/pages/Compliance.tsx +++ b/src/pages/Compliance.tsx @@ -19,9 +19,9 @@ import { } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; -import { Rule, RuleType } from "@/types"; +import { RuleType } from "@/types"; import { useToast } from "@/hooks/use-toast"; -import { rulesApi } from "@/lib/api-client"; +import { rulesApi, type Rule } from "@/lib/api-client"; const ruleTypeLabels: Record = { DISCOUNT: "Discount", @@ -152,7 +152,7 @@ export default function Compliance() { const openEditModal = (rule: Rule) => { setEditingRule(rule); setFormName(rule.name); - setFormType(rule.type); + setFormType(rule.type as RuleType); setFormDescription(rule.description || ""); setFormLogicValue(JSON.stringify(Object.values(rule.logic)[0])); setIsModalOpen(true); diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 951155e..a76ecc8 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -72,12 +72,12 @@ export default function Home() { const itemVariants = { hidden: { opacity: 0, y: 20 }, - visible: { - opacity: 1, + visible: { + opacity: 1, y: 0, transition: { duration: 0.5, ease: [0.22, 1, 0.36, 1] } }, - }; + } as const; return (
    diff --git a/src/pages/Integrations.tsx b/src/pages/Integrations.tsx index f898b08..863144c 100644 --- a/src/pages/Integrations.tsx +++ b/src/pages/Integrations.tsx @@ -282,7 +282,7 @@ export default function Integrations() { // Update database try { - await fetch(`${apiOrigin}/api/integrations/${integration.id}`, { + await fetch(`${base}/api/integrations/${integration.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ isActive: false }), From 001435917745cf109d3055ca101f519c67e625f0 Mon Sep 17 00:00:00 2001 From: Roger Demello Date: Mon, 22 Jun 2026 21:18:41 +0530 Subject: [PATCH 3/8] feat: analytics dashboard New /analytics page (recharts) backed by GET /api/analytics/summary (auth + company-scoped) with tested pure aggregation. Dashboard now shows real period-over-period deltas instead of hardcoded values. --- src/App.tsx | 2 + src/api/analytics.ts | 63 ++++++++++ src/api/lib/analytics.test.ts | 160 ++++++++++++++++++++++++++ src/api/lib/analytics.ts | 209 +++++++++++++++++++++++++++++++++ src/pages/Analytics.tsx | 211 ++++++++++++++++++++++++++++++++++ src/pages/Dashboard.tsx | 102 ++++++++++------ 6 files changed, 713 insertions(+), 34 deletions(-) create mode 100644 src/api/analytics.ts create mode 100644 src/api/lib/analytics.test.ts create mode 100644 src/api/lib/analytics.ts create mode 100644 src/pages/Analytics.tsx 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/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/pages/Analytics.tsx b/src/pages/Analytics.tsx new file mode 100644 index 0000000..e58abb4 --- /dev/null +++ b/src/pages/Analytics.tsx @@ -0,0 +1,211 @@ +import { useEffect, useState } from "react"; +import { motion } from "framer-motion"; +import { + ResponsiveContainer, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + AreaChart, + Area, + PieChart, + Pie, + Cell, +} from "recharts"; +import { FileText, Clock, BarChart3, AlertTriangle, TrendingUp, TrendingDown } from "lucide-react"; +import { analyticsApi, type AnalyticsSummary, type AnalyticsDelta } from "@/lib/api-client"; + +const STATUS_COLORS: Record = { + PENDING: "#f59e0b", + IN_REVIEW: "#3b82f6", + APPROVED: "#22c55e", + REJECTED: "#ef4444", +}; + +const formatCurrency = (n: number) => + new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", notation: "compact" }).format(n); + +function DeltaBadge({ delta, suffix = "" }: { delta: AnalyticsDelta; suffix?: string }) { + const Icon = delta.up ? TrendingUp : TrendingDown; + const positive = delta.up; + const text = delta.changePct !== 0 ? `${delta.up ? "+" : ""}${delta.changePct}%` : `${delta.change >= 0 ? "+" : ""}${delta.change}${suffix}`; + return ( + + + {text} + + ); +} + +function Panel({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
    +

    {title}

    + {children} +
    + ); +} + +export default function Analytics() { + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + analyticsApi + .getSummary() + .then((data) => { + if (!cancelled) setSummary(data); + }) + .catch((err) => { + if (!cancelled) setError(err?.message || "Failed to load analytics"); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + if (loading) { + return
    Loading analytics…
    ; + } + if (error) { + return
    {error}
    ; + } + if (!summary) return null; + + const { headline, statusBreakdown, riskAverages, createdPerWeek, discountDistribution, dealValueByRegion } = summary; + + const cards = [ + { label: "Total Proposals", value: headline.total.value, delta: headline.total.delta, icon: FileText, color: "text-primary", bg: "bg-primary/8", border: "border-l-primary" }, + { label: "Pending Review", value: headline.pending.value, delta: headline.pending.delta, icon: Clock, color: "text-amber-600", bg: "bg-amber-50", border: "border-l-amber-500" }, + { label: "Avg. Readiness", value: `${headline.avgReadiness.value}%`, delta: headline.avgReadiness.delta, icon: BarChart3, color: "text-emerald-600", bg: "bg-emerald-50", border: "border-l-emerald-500" }, + { label: "Needs Attention", value: headline.needsAttention.value, delta: headline.needsAttention.delta, icon: AlertTriangle, color: "text-red-600", bg: "bg-red-50", border: "border-l-red-500" }, + ]; + + const riskBars = [ + { name: "Legal", value: riskAverages.legal }, + { name: "Pricing", value: riskAverages.pricing }, + { name: "Structural", value: riskAverages.structural }, + ]; + + const hasRegions = dealValueByRegion.length > 0; + + return ( +
    + +

    Analytics

    +

    Proposal activity, risk, and pricing trends across your team.

    +
    + + {/* Headline stats */} +
    + {cards.map((c) => { + const Icon = c.icon; + return ( +
    +
    +
    + +
    + +
    +
    {c.value}
    +
    {c.label}
    +
    + ); + })} +
    + + {/* Charts */} +
    + + + + + + + + + + + v.slice(5)} /> + + + + + + + + + + + + {statusBreakdown.map((entry) => ( + + ))} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {hasRegions ? ( + + + + + + formatCurrency(v)} /> + + + + ) : ( +
    + No deal value data yet. +
    + )} +
    +
    +
    + ); +} diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 0c45ed9..21f0bcb 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,64 +1,96 @@ +import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { motion } from "framer-motion"; -import { Upload, FileText, TrendingUp, AlertTriangle, ArrowRight, Clock, BarChart3 } from "lucide-react"; +import { Upload, FileText, AlertTriangle, ArrowRight, Clock, BarChart3 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ProposalCard } from "@/components/proposals/ProposalCard"; import { useProposals } from "@/context/useProposals"; +import { analyticsApi, type AnalyticsDelta } from "@/lib/api-client"; + +/** Format a period-over-period delta into a short badge string. */ +function formatDelta(delta?: AnalyticsDelta): { trend: string; trendUp: boolean } | null { + if (!delta) return null; + const trend = + delta.changePct !== 0 + ? `${delta.up ? "+" : ""}${delta.changePct}%` + : `${delta.change >= 0 ? "+" : ""}${delta.change}`; + return { trend, trendUp: delta.up }; +} export default function Dashboard() { const { proposals } = useProposals(); - + const [deltas, setDeltas] = useState | null>(null); + + // Real period-over-period deltas come from the analytics summary; the card + // values stay client-side for instant render. + useEffect(() => { + let cancelled = false; + analyticsApi + .getSummary() + .then((s) => { + if (cancelled) return; + setDeltas({ + total: s.headline.total.delta, + pending: s.headline.pending.delta, + avgReadiness: s.headline.avgReadiness.delta, + needsAttention: s.headline.needsAttention.delta, + }); + }) + .catch(() => { + /* deltas are best-effort; cards still render without them */ + }); + return () => { + cancelled = true; + }; + }, []); + const recentProposals = proposals.slice(0, 3); const hasProposals = proposals.length > 0; // Stats const totalProposals = proposals.length; const pendingCount = proposals.filter(p => p.status === "PENDING").length; - const avgScore = totalProposals > 0 + const avgScore = totalProposals > 0 ? Math.round(proposals.reduce((acc, p) => acc + p.readinessScore, 0) / totalProposals) : 0; const atRiskCount = proposals.filter(p => p.readinessScore < 60).length; const stats = [ - { - label: "Total Proposals", - value: totalProposals, - icon: FileText, + { + label: "Total Proposals", + value: totalProposals, + icon: FileText, color: "text-primary", bgColor: "bg-primary/8", borderColor: "border-l-primary", - trend: "+12%", - trendUp: true + ...formatDelta(deltas?.total), }, - { - label: "Pending Review", - value: pendingCount, - icon: Clock, + { + label: "Pending Review", + value: pendingCount, + icon: Clock, color: "text-amber-600", bgColor: "bg-amber-50", borderColor: "border-l-amber-500", - trend: "-2", - trendUp: false + ...formatDelta(deltas?.pending), }, - { - label: "Avg. Score", - value: `${avgScore}%`, - icon: BarChart3, + { + label: "Avg. Score", + value: `${avgScore}%`, + icon: BarChart3, color: "text-emerald-600", bgColor: "bg-emerald-50", borderColor: "border-l-emerald-500", - trend: "+5%", - trendUp: true + ...formatDelta(deltas?.avgReadiness), }, - { - label: "Needs Attention", - value: atRiskCount, - icon: AlertTriangle, + { + label: "Needs Attention", + value: atRiskCount, + icon: AlertTriangle, color: "text-red-600", bgColor: "bg-red-50", borderColor: "border-l-red-500", - trend: "-1", - trendUp: false + ...formatDelta(deltas?.needsAttention), }, ]; @@ -117,13 +149,15 @@ export default function Dashboard() {
    - - {stat.trend} - + {stat.trend && ( + + {stat.trend} + + )}
    {stat.value} From 60e6220aa1a5d8ff04b7de635c4381ed032dfd8d Mon Sep 17 00:00:00 2001 From: Roger Demello Date: Mon, 22 Jun 2026 21:18:41 +0530 Subject: [PATCH 4/8] feat: in-app notifications + activity feed Notification bell with unread badge + feed sourced from AuditLog via shared getScopedAuditLogs. Secures GET /api/audit (previously unauthenticated + unscoped) and adds GET /api/notifications. --- src/api/audit.ts | 68 ++++++--------------- src/api/lib/auditQuery.ts | 84 +++++++++++++++++++++++++ src/api/notifications.ts | 27 ++++++++ src/components/NotificationBell.tsx | 80 ++++++++++++++++++++++++ src/components/layout/ClientLayout.tsx | 18 +++--- tests/notifications.test.ts | 85 ++++++++++++++++++++++++++ 6 files changed, 305 insertions(+), 57 deletions(-) create mode 100644 src/api/lib/auditQuery.ts create mode 100644 src/api/notifications.ts create mode 100644 src/components/NotificationBell.tsx create mode 100644 tests/notifications.test.ts 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/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/notifications.ts b/src/api/notifications.ts new file mode 100644 index 0000000..fb8c0da --- /dev/null +++ b/src/api/notifications.ts @@ -0,0 +1,27 @@ +import { Router, Request, Response } from 'express'; +import { requireAuth } from './middleware/auth'; +import { getScopedAuditLogs } from './lib/auditQuery'; + +const router = Router(); + +// GET /api/notifications?since= — recent scoped activity + unread count. +// "Unread" = events newer than the `since` timestamp the client last saw. +router.get('/', requireAuth, async (req: Request, res: Response) => { + try { + const sinceRaw = typeof req.query.since === 'string' ? req.query.since : undefined; + const sinceMs = sinceRaw ? new Date(sinceRaw).getTime() : NaN; + + const items = await getScopedAuditLogs(req, 20); + + const unreadCount = Number.isNaN(sinceMs) + ? items.length + : items.filter((i) => new Date(i.timestamp).getTime() > sinceMs).length; + + res.json({ items, unreadCount }); + } catch (error) { + console.error('Error fetching notifications:', error); + res.status(500).json({ error: 'Failed to fetch notifications' }); + } +}); + +export default router; diff --git a/src/components/NotificationBell.tsx b/src/components/NotificationBell.tsx new file mode 100644 index 0000000..5e6fb4a --- /dev/null +++ b/src/components/NotificationBell.tsx @@ -0,0 +1,80 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Bell } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { notificationsApi } from "@/lib/api-client"; +import { formatIST } from "@/lib/utils"; + +const LAST_SEEN_KEY = "notifications:lastSeenAt"; +const POLL_MS = 30_000; + +export default function NotificationBell() { + const [lastSeen, setLastSeen] = useState( + () => localStorage.getItem(LAST_SEEN_KEY) || undefined + ); + const [open, setOpen] = useState(false); + + const { data } = useQuery({ + queryKey: ["notifications", lastSeen], + queryFn: () => notificationsApi.get(lastSeen), + refetchInterval: POLL_MS, + refetchOnWindowFocus: true, + }); + + const items = data?.items ?? []; + const unread = data?.unreadCount ?? 0; + + const handleOpenChange = (next: boolean) => { + setOpen(next); + // Opening the feed marks everything currently visible as seen. + if (next) { + const now = new Date().toISOString(); + localStorage.setItem(LAST_SEEN_KEY, now); + setLastSeen(now); + } + }; + + return ( + + + + + +
    +

    Activity

    +
    +
    + {items.length === 0 ? ( +
    No recent activity.
    + ) : ( + items.map((item) => ( +
    +

    + {item.action} + {item.proposal?.title ? ( + · {item.proposal.title} + ) : null} +

    +

    + {item.actor?.name || item.actor?.email || "Someone"} ·{" "} + {formatIST(item.timestamp, "MMM d, h:mm a")} +

    +
    + )) + )} +
    +
    +
    + ); +} diff --git a/src/components/layout/ClientLayout.tsx b/src/components/layout/ClientLayout.tsx index 7c89840..1ae25ff 100644 --- a/src/components/layout/ClientLayout.tsx +++ b/src/components/layout/ClientLayout.tsx @@ -2,11 +2,12 @@ import { ReactNode, useEffect, useState } from "react"; import { useNavigate, useLocation, Link } from "react-router-dom"; import { motion } from "framer-motion"; import { - LayoutDashboard, - FileText, - Shield, - Link2, - ClipboardList, + LayoutDashboard, + FileText, + Shield, + Link2, + BarChart3, + ClipboardList, LogOut, ChevronRight, Sparkles, @@ -16,6 +17,7 @@ import { Settings as SettingsIcon } from "lucide-react"; import { isAuthenticated, getCurrentUser, clearAuthData } from "@/lib/auth-utils"; +import NotificationBell from "@/components/NotificationBell"; interface ClientLayoutProps { children: ReactNode; @@ -30,6 +32,7 @@ interface NavItem { const navItems: NavItem[] = [ { href: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, + { href: "/analytics", label: "Analytics", icon: BarChart3 }, { href: "/proposals", label: "Proposals", icon: FileText }, { href: "/compliance", label: "Compliance", icon: Shield }, { href: "/integrations", label: "Integrations", icon: Link2 }, @@ -181,14 +184,15 @@ export default function ClientLayout({ children }: ClientLayoutProps) { {/* Main Content */}
    - {/* Home Button - Top Right Corner */} + {/* Top Right Corner: notifications + home */} {location.pathname !== "/" && ( + {isLoggedIn && } { + const mockSingle = vi.fn(); + const builder: Record = {}; + builder.select = vi.fn(() => builder); + builder.eq = vi.fn(() => builder); + builder.single = mockSingle; + const supabaseMock = { from: vi.fn(() => builder) }; + return { mockSingle, supabaseMock }; +}); +vi.mock("../src/lib/supabase", () => ({ supabase: supabaseMock, default: supabaseMock })); + +// Mock the scoped-query helper so the route's unread-count logic is isolated. +const { getScopedAuditLogs } = vi.hoisted(() => ({ getScopedAuditLogs: vi.fn() })); +vi.mock("../src/api/lib/auditQuery", () => ({ getScopedAuditLogs })); + +import notificationsRouter from "../src/api/notifications"; + +const JWT_SECRET = process.env.NEXTAUTH_SECRET as string; + +function buildApp() { + const app = express(); + app.use(express.json()); + app.use("/api/notifications", notificationsRouter); + return app; +} + +function authedUser() { + mockSingle.mockResolvedValue({ + data: { id: "u1", email: "a@b.com", role: "SALES_REP", company_id: "c1" }, + error: null, + }); + return jwt.sign({ userId: "u1" }, JWT_SECRET, { expiresIn: "1h" }); +} + +const log = (id: string, isoOffsetMin: number) => ({ + id, + action: "Changed status", + timestamp: new Date(Date.UTC(2026, 5, 22, 12, 0) - isoOffsetMin * 60_000).toISOString(), + actorId: "u2", + actor: { name: "Bob", email: "bob@b.com", role: "ADMIN" }, + proposalId: "p1", + proposal: { title: "Acme" }, +}); + +beforeEach(() => { + mockSingle.mockReset(); + getScopedAuditLogs.mockReset(); +}); + +describe("GET /api/notifications", () => { + it("401 without a token", async () => { + const res = await request(buildApp()).get("/api/notifications"); + expect(res.status).toBe(401); + }); + + it("returns all items as unread when no `since` is given", async () => { + const token = authedUser(); + getScopedAuditLogs.mockResolvedValue([log("a", 5), log("b", 60)]); + const res = await request(buildApp()) + .get("/api/notifications") + .set("Authorization", `Bearer ${token}`); + expect(res.status).toBe(200); + expect(res.body.items).toHaveLength(2); + expect(res.body.unreadCount).toBe(2); + }); + + it("counts only items newer than `since` as unread", async () => { + const token = authedUser(); + // 'a' is 5 min before noon, 'b' is 60 min before noon. + getScopedAuditLogs.mockResolvedValue([log("a", 5), log("b", 60)]); + const since = new Date(Date.UTC(2026, 5, 22, 11, 30)).toISOString(); // 30 min before noon + const res = await request(buildApp()) + .get(`/api/notifications?since=${encodeURIComponent(since)}`) + .set("Authorization", `Bearer ${token}`); + expect(res.status).toBe(200); + // only 'a' (5 min before noon) is newer than 11:30 + expect(res.body.unreadCount).toBe(1); + }); +}); From 11b8cc639fccbd26c5c4a0cbb92efbc96eb00305 Mon Sep 17 00:00:00 2001 From: Roger Demello Date: Mon, 22 Jun 2026 21:18:42 +0530 Subject: [PATCH 5/8] feat: email notification provider (Resend, gated off) Pluggable email layer with tested pure templates. Sends only when EMAIL_ENABLED=true and a key is set; never under tests. --- src/api/lib/email.ts | 48 ++++++++++++++++++++++++++ src/api/lib/emailTemplates.test.ts | 33 ++++++++++++++++++ src/api/lib/emailTemplates.ts | 55 ++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 src/api/lib/email.ts create mode 100644 src/api/lib/emailTemplates.test.ts create mode 100644 src/api/lib/emailTemplates.ts 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('