Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
# Database Configuration
# PostgreSQL connection string for Supabase or your own PostgreSQL instance
# Used ONLY by `npx prisma db push` and `npm run seed`. The running app reaches
# Supabase over REST (SUPABASE_URL + SUPABASE_ANON_KEY) and never opens this
# connection, so the app still works if this is stale.
#
# On Supabase, use the SESSION-mode pooler on port 5432:
# postgresql://postgres.<project-ref>:<password>@aws-<n>-<region>.pooler.supabase.com:5432/postgres?sslmode=require
# Not the direct `db.<project-ref>.supabase.co` host — Supabase has retired it for
# restored projects — and not the transaction pooler on 6543, which Prisma
# migrations cannot use. Copy the exact string from
# Supabase Dashboard → Settings → Database → Connection string → Session pooler.
DATABASE_URL="postgresql://postgres:password@localhost:5432/dealsentry"

# Supabase Configuration
# Get these from your Supabase project settings: https://app.supabase.com
SUPABASE_URL="https://your-project.supabase.co"
SUPABASE_ANON_KEY="your-supabase-anon-key-here"
# Optional but recommended: the server prefers this key when set, so the API
# keeps working after you enable Row Level Security on the tables.
# Server-side only — never ship it to the browser.
# SUPABASE_SERVICE_ROLE_KEY="your-supabase-service-role-key"

# Azure OpenAI Configuration (for AI-powered features)
# Required for proposal analysis and AI generation
Expand Down
122 changes: 122 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# DealSentry Architecture

DealSentry is an AI-assisted proposal compliance and risk review system. This
document explains how the pieces fit together and, more importantly, *why* —
including the trade-offs that were made deliberately.

## System overview

```mermaid
flowchart LR
subgraph Browser
SPA["React 18 SPA<br/>(Vite, Tailwind, shadcn/ui,<br/>TanStack Query)"]
end

subgraph "Express API (Node 20, tsx)"
API["REST routers<br/>/api/proposals /rules /analyze<br/>/files /integrations /oauth ..."]
MW["Middleware<br/>helmet · CORS allowlist ·<br/>rate limits · session resolver"]
PDF["PDF export<br/>(Puppeteer / headless Chrome)"]
end

subgraph Supabase
PG[("PostgreSQL<br/>+ pgvector")]
REST["PostgREST API"]
STORE["Storage<br/>(proposal-files bucket)"]
end

subgraph Azure["Azure OpenAI"]
GPT["gpt-4o<br/>(analysis + generation)"]
EMB["text-embedding-ada-002<br/>(semantic search)"]
end

CRM["Salesforce · HubSpot · Gmail<br/>(OAuth 2.0, demo mode available)"]

SPA -- "/api/* (same-origin,<br/>Vite proxy in dev)" --> MW --> API
API -- "supabase-js (REST)" --> REST --> PG
API --> STORE
API --> GPT
API --> EMB
API <--> CRM
PDF --> API
```

## Key design decisions

### 1. Two database access paths, on purpose

- **Runtime**: the Express API talks to Supabase over **PostgREST**
(`supabase-js`). No connection pool to manage, works on serverless-ish free
tiers, and survives the API host and database being in different regions.
- **Schema & seeds**: **Prisma** is used *only* for `db push`, `db execute`
(the manual SQL in `prisma/manual/`) and `npm run seed`, over the Supabase
**session-mode pooler** (port 5432 — Prisma migrations cannot run over the
transaction pooler on 6543).

The trade-off: no compile-time query types at runtime (PostgREST returns
untyped JSON). That's the price of running well on a free tier; the routers
type their own row interfaces at the boundary instead.

### 2. Demo-first authentication

There is deliberately **no login screen**: every request is served as a default
user (`DEMO_USER_EMAIL`, falling back to the first ADMIN). The full JWT +
bcrypt stack still exists (`/api/auth/login`, `/register`,
`/change-password`) and valid bearer tokens win over the default identity, so
real multi-user auth can be re-enabled by rendering the login page again.

### 3. AI with guardrails, not AI instead of rules

`/api/analyze` sends the proposal *content* (source of truth) plus the active
compliance rules to gpt-4o and asks for structured findings. Hard limits
(max 25% discount, 90-day payment terms, mandatory legal clauses, $10k minimum
deal) are enforced twice: stated in the prompt *and* re-checked/capped in
code after generation (`/api/proposals/generate` clamps the discount range).
Results are normalized (`normalizeAnalysis`) before they become a RiskReport.

### 4. Semantic search via pgvector

Every proposal gets a 1536-dim embedding on create (best-effort — failures
never block the write). Search calls the `match_proposals` SQL function
(cosine distance, ivfflat index). If the extension/function is missing the API
answers `available: false` and the client falls back to substring search —
the feature degrades, it doesn't break.

Setup lives in `prisma/manual/semantic_search.sql`; backfill with
`npm run backfill:embeddings`.

### 5. Integrations with a real demo mode

Salesforce/HubSpot/Gmail connect over standard OAuth 2.0. With
`*_DEMO_MODE=true`, connect stores `{demo: true}` credentials and **sync
imports canned deals as proposals**, so the connect → sync → analyze → approve
story is demonstrable without any external accounts. Real-mode sync handles
token refresh and dedupes on the CRM record id.

### 6. Deployment (Render, Docker)

One container serves both the API and the built SPA (Express serves `dist/` in
production, so CORS is a non-issue for same-origin traffic). The runtime image
installs system Chromium for PDF export (`PUPPETEER_EXECUTABLE_PATH`), because
the free tier's 512 MB can't afford Puppeteer's bundled download at build time.
`render.yaml` is a full Blueprint — secrets are dashboard-only (`sync: false`).

## Security model (current state and roadmap)

| Area | Today | Next step |
| --- | --- | --- |
| API auth | Default-user demo mode; JWT honored when present | Re-enable login UI for multi-user |
| DB access | Server-side key via supabase-js; anon key works, service-role preferred when set | Enable RLS per table; drop anon write policies |
| Secrets | `.env` git-ignored; Render dashboard for prod | Rotate any key that was ever committed |
| Responses | Password hashes stripped from every user payload; mutations admin-gated | Field-level DTOs |

## Repository map

```
server.ts Express bootstrap, static SPA serving, error handling
src/api/ One router per resource + middleware/ + lib/
src/lib/ API client (frontend), supabase client (backend), utils
src/pages/ components/ React SPA
prisma/ schema.prisma, seed.ts, manual/*.sql (pgvector, storage)
scripts/ backfill-embeddings, cleanup-integrations, migrate-auth
tests/ Vitest + Supertest API tests
```
68 changes: 0 additions & 68 deletions CREDENTIALS.md

This file was deleted.

15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,21 @@ Typical development time was about 2 to 4 focused hours per day during active im
```bash
npm install
npx prisma generate
npx prisma migrate deploy
npx prisma db push # sync the schema (this repo has no migration files)
npm run seed
```

Then apply the one-time manual SQL (idempotent, safe to re-run):

```bash
# pgvector + semantic search function (required for proposal search)
npx prisma db execute --file prisma/manual/semantic_search.sql
npm run backfill:embeddings

# Storage bucket + policies (required for document upload)
npx prisma db execute --file prisma/manual/storage_bucket.sql
```

### Run

```bash
Expand All @@ -63,6 +74,8 @@ On Windows, you can also use `start.bat` to launch the backend and frontend toge

## Documentation

- [ARCHITECTURE.md](ARCHITECTURE.md) — system design and trade-offs
- [docs/DEMO_SCRIPT.md](docs/DEMO_SCRIPT.md) — rehearsed demo storyline
- [SETUP.md](SETUP.md)
- [COMPLIANCE_RULES.md](COMPLIANCE_RULES.md)
- [docs/SERVER_STABILITY.md](docs/SERVER_STABILITY.md)
Expand Down
76 changes: 76 additions & 0 deletions docs/DEMO_SCRIPT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# DealSentry — 7-minute demo script

A rehearsed storyline that shows every major capability without dead ends.
Practice it twice before presenting; every step below is verified working.

> **Before the demo (2 min):** open the deployed URL once to wake the Render
> free-tier instance (cold start ≈ 30s), and keep a second tab on the
> Dashboard. Locally: `npm run dev:full`, then http://localhost:8080.

## 1. The problem (30s)

"Sales teams send out proposals with pricing and legal problems — discounts
nobody approved, missing indemnification clauses, 120-day payment terms.
DealSentry catches these before the proposal leaves the building."

## 2. Dashboard tour (30s)

- Point at the analytics: status breakdown, average readiness score,
needs-attention count — all computed from live data.

## 3. AI generation (90s) — *the wow moment*

- New Proposal → AI generate, type:
*"Proposal for TechNova Inc, $75,000 data platform modernization, 10%
discount, net 60 payment, healthcare industry"*
- Show the generated document: full sections, all five mandatory legal
clauses. Point out that the generator **enforces** policy (caps discounts at
25%, payment terms at 90 days) — AI with guardrails, not instead of them.

## 4. Catching a risky proposal (2 min) — *the core story*

- Upload `sample_proposal/` docx (or create one) with deliberate violations:
35% discount, Net 120 payment terms, no legal clauses.
- Run analysis → walk through the risk report:
- **CRITICAL — Discount violation** (35% > 25% policy)
- **HIGH — Payment terms** (120 > 90 days)
- **HIGH — Missing legal clauses**
- Readiness score + legal/pricing/structural risk breakdown.
- Show the AI recommendations, then **Reject** it. Open the Audit page —
every action is logged with actor and before/after.

## 5. Semantic search (45s)

- Search "cloud migration" — results rank by *meaning* (pgvector cosine
similarity over Azure OpenAI embeddings), not keywords. Mention the graceful
fallback to substring search when embeddings are unavailable.

## 6. CRM integration (45s)

- Integrations → Connect Salesforce (demo mode) → Sync.
- Three deals import as proposals instantly; each can now be analyzed.
Mention: real mode is the same OAuth flow with token refresh.

## 7. Boardroom-ready output (30s)

- Open an analyzed proposal → Export PDF. Show the cover page, compliance
appendix with the quality scores, and page numbering. (Headless Chrome
server-side.)

## 8. Close (30s)

"React + Express + Supabase + Azure OpenAI, deployed on Render from a Docker
blueprint, CI-tested, with policy enforced in code and AI explaining the why.
Everything you saw is in the repo — including the architecture doc."

## Q&A cheat sheet

- **Why no login?** Demo mode by design; the JWT/bcrypt stack is live and
honored when a token is present (`docs/AUTH_QUICKSTART.md`).
- **What stops the AI hallucinating compliance?** Deterministic re-checks in
code: discounts clamped, thresholds hardcoded, findings normalized.
- **How would you scale it?** Enable RLS + service-role key (already
supported), move PDF export to a worker, add Redis for rate limits.
- **Weakest point?** Schema drift between Prisma schema and the live DB —
known, documented in ARCHITECTURE.md, and contained because runtime access
goes through PostgREST.
8 changes: 8 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,12 @@ export default tseslint.config(
"no-useless-escape": "warn",
},
},
{
// shadcn/ui generated components export variants/hooks alongside the
// component by design; fast-refresh purity doesn't apply to them.
files: ["src/components/ui/**/*.{ts,tsx}"],
rules: {
"react-refresh/only-export-components": "off",
},
},
);
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"server": "tsx --watch server.ts",
"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 .",
"lint": "eslint . --max-warnings 0",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
Expand Down
5 changes: 5 additions & 0 deletions prisma/manual/semantic_search.sql
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ 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);

-- 2b. Tenant column referenced by match_proposals below. The API writes it
-- conditionally (only for users with a companyId), so it must exist even
-- on databases that predate company scoping.
alter table "Proposal" add column if not exists company_id text;

-- 3. Approximate-nearest-neighbour index (cosine distance).
-- Tune `lists` upward as the table grows (≈ rows/1000).
create index if not exists proposal_embedding_idx
Expand Down
24 changes: 24 additions & 0 deletions prisma/manual/storage_bucket.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
-- Storage setup for document upload (/api/files). Run ONCE against your
-- Supabase Postgres (Supabase Studio → SQL editor, or:
-- npx prisma db execute --file prisma/manual/storage_bucket.sql
-- ). Idempotent / safe to re-run.

-- 1. The bucket the API uploads proposal documents into.
insert into storage.buckets (id, name, public)
values ('proposal-files', 'proposal-files', true)
on conflict (id) do nothing;

-- 2. The app talks to Storage with the anon key, so anon needs object access
-- scoped to this bucket. (If you later move to a service-role key
-- server-side, these policies can be dropped.)
drop policy if exists "proposal-files anon read" on storage.objects;
create policy "proposal-files anon read" on storage.objects
for select to anon using (bucket_id = 'proposal-files');

drop policy if exists "proposal-files anon insert" on storage.objects;
create policy "proposal-files anon insert" on storage.objects
for insert to anon with check (bucket_id = 'proposal-files');

drop policy if exists "proposal-files anon delete" on storage.objects;
create policy "proposal-files anon delete" on storage.objects
for delete to anon using (bucket_id = 'proposal-files');
6 changes: 6 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ model Proposal {
renewalDate DateTime?
autoRenew Boolean @default(false)

// Tenant scoping column used by the API's company checks. Nullable: rows
// created in single-tenant/demo mode have no company. Added by
// prisma/manual/semantic_search.sql on databases that predate it — kept here
// so `prisma db push` doesn't try to drop it.
companyId String? @map("company_id")

// 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)")?
Expand Down
Loading
Loading