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: 15 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/"
OPENAI_API_KEY="your-azure-openai-api-key-here"
AZURE_OPENAI_DEPLOYMENT="gpt-4o"
AZURE_OPENAI_API_VERSION="2024-12-01-preview"
# Embedding deployment — required only for semantic search (Phase 4)
AZURE_OPENAI_EMBEDDING_DEPLOYMENT="text-embedding-ada-002"

# Email Notifications (Optional)
# Sending is OFF unless EMAIL_ENABLED="true" AND a provider key is set.
# Default provider is Resend (https://resend.com).
EMAIL_ENABLED="false"
EMAIL_PROVIDER="resend"
RESEND_API_KEY=""
EMAIL_FROM="DealSentry <noreply@dealsentry.ai>"

# Authentication
# Secret key for JWT token signing - CHANGE THIS IN PRODUCTION!
Expand Down
39 changes: 39 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# syntax=docker/dockerfile:1

# ---- Builder: install deps, generate Prisma client, build the SPA ----
FROM node:20-slim AS builder
WORKDIR /app

# Puppeteer downloads its own Chromium by default; we use the system Chromium
# in the runtime stage instead, so skip the (large) download here.
ENV PUPPETEER_SKIP_DOWNLOAD=true

COPY package*.json ./
COPY prisma ./prisma
RUN npm ci

COPY . .
RUN npx prisma generate && npm run build

# ---- Runtime: system Chromium + app source, run server via tsx ----
FROM node:20-slim AS runtime
WORKDIR /app

ENV NODE_ENV=production
ENV PUPPETEER_SKIP_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium

# Chromium + fonts so the PDF export (Puppeteer) works in the container.
RUN apt-get update && apt-get install -y --no-install-recommends \
chromium \
fonts-liberation \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*

# Bring over installed deps (incl. tsx) and the generated Prisma client + build.
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package*.json tsconfig.json server.ts ./
COPY src ./src
COPY prisma ./prisma

EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
CMD node -e "const p=process.env.PORT||3001;fetch('http://localhost:'+p+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"

CMD ["npx", "tsx", "server.ts"]
17 changes: 17 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
106 changes: 106 additions & 0 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Deployment Guide

DealSentry ships as a single Node service: the Express API also serves the
built React SPA (on `NODE_ENV=production`). It depends on a Supabase project
(Postgres + storage) and Azure OpenAI.

## 1. Prerequisites

- A Supabase project (database + a `proposal-files` storage bucket).
- An Azure OpenAI deployment (e.g. `gpt-4o`).
- Node 20+ (for non-container runs) or Docker.

## 2. Configure environment

Copy the template and fill in real values:

```bash
cp .env.example .env
```

Key variables (see `.env.example` for the full list):

| Variable | Purpose |
|---|---|
| `DATABASE_URL` | Supabase Postgres connection string (used by Prisma migrations) |
| `SUPABASE_URL`, `SUPABASE_ANON_KEY` | Supabase REST/storage client |
| `AZURE_OPENAI_ENDPOINT`, `OPENAI_API_KEY`, `AZURE_OPENAI_DEPLOYMENT` | AI generation & analysis |
| `NEXTAUTH_SECRET` | JWT signing secret — generate a fresh 32+ byte value |
| `API_PORT` | Server port (default `3001`) |
| `PRODUCTION_URL` | Allowed CORS origin in production |

Generate a strong JWT secret:

```bash
node -e "console.log(require('crypto').randomBytes(48).toString('base64'))"
```

> Security: `.env` is gitignored and must never be committed. Rotate any
> credential that has been shared in plaintext (DB password, Azure key,
> `NEXTAUTH_SECRET`).

## 3. Apply database schema

```bash
npx prisma generate
npx prisma migrate deploy
npm run seed # optional: demo users, rules, templates, sample proposals
```

## 4a. Run with Docker (recommended)

```bash
docker compose up --build
```

This builds the SPA, installs system Chromium (for PDF export), and serves the
app on `http://localhost:3001`. Configuration is read from `.env`.

## 4b. Run with Node directly

```bash
npm ci
npm run build # builds the SPA into dist/
NODE_ENV=production npx tsx server.ts
```

The server serves the API under `/api/*` and the SPA for all other paths.

## 4c. Deploy to Render (hosted)

The repo ships a `render.yaml` Blueprint that runs the Dockerfile as a web service.

1. **Rotate secrets first** — the Supabase password, Azure OpenAI key. (`NEXTAUTH_SECRET`
is auto-generated by Render via `generateValue`, so the old one is replaced.)
2. In Render: **New → Blueprint**, connect this GitHub repo, pick the branch. Render
reads `render.yaml` and creates the `dealsentry` service.
3. Fill in the `sync: false` env vars in the dashboard: `DATABASE_URL`, `SUPABASE_URL`,
`SUPABASE_ANON_KEY`, `AZURE_OPENAI_ENDPOINT`, `OPENAI_API_KEY`.
4. Deploy. Once it's live, copy the service URL (e.g. `https://dealsentry.onrender.com`)
into **both** `PRODUCTION_URL` and `FRONTEND_URL`, then redeploy so CORS + OAuth
redirects use the real domain.
5. Schema: we reuse the existing Supabase project, so the tables already exist — no
migration step needed on first deploy. (For semantic search, run
`prisma/manual/semantic_search.sql` once; see `docs/SEMANTIC_SEARCH.md`.)

Notes:
- Use at least the **starter** plan; bump to **standard** (2 GB) if PDF export OOMs
(headless Chromium is memory-hungry). Avoid the **free** plan — it idles down.
- Render injects `PORT`; the server listens on it automatically.

## 5. Verify

```bash
curl -s http://localhost:3001/api/health # -> {"status":"ok",...}
# On Render: curl -s https://<your-service>.onrender.com/api/health
```

Then open `http://localhost:3001`, log in (seeded `admin@dealsentry.ai`),
create a proposal, run analysis, and export a PDF — the PDF path exercises the
containerized Chromium, confirming the image is complete.

## CI

`.github/workflows/ci.yml` runs on every push/PR to `main`: install →
`prisma generate` → lint → typecheck → test → build. Keep it green before
deploying.
54 changes: 54 additions & 0 deletions docs/SEMANTIC_SEARCH.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Semantic Search (Phase 4)

Meaning-based search over proposal content using Azure OpenAI embeddings +
pgvector. Until the setup below is done, the Proposals search box transparently
falls back to title/client substring matching — nothing breaks.

## How it works

- On proposal **create** and **AI generate**, the API computes an embedding of
`title + content` and stores it in `Proposal.embedding` (best-effort).
- `GET /api/proposals/search?q=...` embeds the query and ranks proposals by
cosine similarity via the `match_proposals` Postgres function, scoped to the
caller's company (admins see all).
- The frontend (`src/pages/Proposals.tsx`) calls this when the query is ≥3 chars
and ranks by similarity; if the endpoint reports `available:false`, it uses the
substring filter instead.

Code: `src/api/lib/embeddings.ts`, search route in `src/api/proposals.ts`,
`proposalsApi.search` in `src/lib/api-client.ts`.

## One-time setup

1. **Configure the embedding deployment** in `.env`:
```
AZURE_OPENAI_EMBEDDING_DEPLOYMENT="text-embedding-ada-002"
```
(Endpoint + key are shared with the existing Azure OpenAI config.)

2. **Run the SQL migration** against your Supabase Postgres — Supabase Studio →
SQL editor, or psql. This enables pgvector, adds the `embedding` column + an
index, and creates the `match_proposals` function:
```
prisma/manual/semantic_search.sql
```

3. **Backfill embeddings** for existing proposals:
```bash
npm run backfill:embeddings
```

4. Restart the API. New proposals embed automatically; search now ranks by
meaning.

## Notes

- `text-embedding-ada-002` → 1536-dim vectors. If you switch models, update the
dimension in both the SQL (`vector(1536)`) and `EMBEDDING_DIM` in
`src/api/lib/embeddings.ts`.
- The `ivfflat` index `lists` parameter (default 100) should grow roughly with
`rows / 1000` for best recall/speed.
- `Proposal.embedding` is declared in `schema.prisma` as
`Unsupported("vector(1536)")` for documentation; the column is actually created
by the manual SQL migration (the runtime uses the Supabase REST client, not the
Prisma client, for data access).
62 changes: 45 additions & 17 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -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",
},
},
);
Loading
Loading