AI offerte management for Dutch SMBs. Reads inbox + WhatsApp, extracts quote requests, drafts replies in the owner's tone, generates quote PDFs, and tracks deadlines and expiry dates so nothing goes cold.
- Frontend: TanStack Start (React 19) + MUI v9 + TanStack Query
- Backend: NestJS 11 + Prisma 7 + Postgres 16
- AI: OpenAI Responses API via the official
openaiSDK (direct OpenAI or Azure OpenAI EU). Locked to OpenAI for MVP; provider-swap seam in place for a future change. - Background jobs: Inngest (delta-sync workers, push handlers, scheduled crons)
- Opportunity pipeline: Gmail/Graph
RawMessagerows are classified, extracted, and materialized into tenant-scopedOpportunityrows with server-enforced status transitions. Owners can dismiss false positives (not_a_quote | duplicate | spam | other); the admin classifier-quality dashboard turns those dismissals into precision metrics. - Admin dashboards (gated by
ADMIN_EMAILSenv allowlist):/admin/ai-usage— per-call token + USD cost grouped by provider / model / org / status./admin/classifier-quality— classifier precision per (org, model) with per-reason breakdown, recent dismissals withclassifiedAiCallIddeep-link, and bulk-mail filter recall.
- Build: Turborepo + pnpm workspaces
- Deploy: DigitalOcean App Platform (EU)
apps/
├── api/ NestJS — REST API, Prisma, AI orchestration
└── web/ TanStack Start — frontend + SSR
Prerequisites: Node 22+, Docker. pnpm is activated via Node's bundled corepack — run corepack enable once.
# install
pnpm install
# env
cp apps/api/.env.example apps/api/.env
cp apps/web/.env.example apps/web/.env
# local Postgres
cd apps/api && pnpm db:up
# everything in dev mode
cd ../.. && pnpm dev- API: http://localhost:3001 (Swagger at
/docs) - Web: http://localhost:3000
Optional — enable AI features locally: drop OPENAI_API_KEY=sk-... into apps/api/.env. The classifier / extractor accuracy harnesses (pnpm test:ai from apps/api/) skip silently without it; with it, you get the live-API harness + an HTML report under apps/api/.ai-reports/index.html. The opportunities pipeline also needs an AI provider configured; without one, raw messages remain unclassified until a later processing run.
Root (runs across all apps via turbo):
| Script | What |
|---|---|
pnpm dev |
api + web in watch mode |
pnpm build |
builds both apps |
pnpm typecheck |
tsc on both |
pnpm lint |
eslint on both |
pnpm format |
prettier --write |
apps/api:
| Script | What |
|---|---|
db:up / db:down |
start/stop local Postgres |
db:generate |
regenerate Prisma client |
db:migrate |
run dev migration |
db:studio |
open Prisma Studio |
The app spec at .do/app.yaml describes everything: two services (api + web) behind one
load balancer, a managed Postgres component, and a PRE_DEPLOY job that runs
prisma migrate deploy before each release goes live. Routing inside the app is by path
(/api/* → api component, / → web component) so both share the same hostname — no CORS
configuration, cookies just work.
Prerequisites: a DigitalOcean account and doctl CLI installed + authenticated.
-
Validate the spec locally:
doctl apps spec validate .do/app.yaml
-
Create the app:
doctl apps create --spec .do/app.yaml
Note the printed
App ID— you'll need it for updates. -
Set secrets in the Dashboard (Apps → offertum → Settings → App-Level Environment Variables). These can't be in the spec because they're secret values:
AUTH_SECRET— generate withopenssl rand -base64 32STRIPE_SECRET_KEY,STRIPE_PRICE_ID,STRIPE_WEBHOOK_SECRETRESEND_API_KEYGOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET(leave blank to disable that provider)MICROSOFT_CLIENT_ID,MICROSOFT_CLIENT_SECRET(leave blank to disable)
After the first deploy attempt these will be visible as empty SECRET fields; fill them in and trigger a new deploy.
-
Point Stripe webhooks at
https://<your-app>.ondigitalocean.app/api/billing/webhook. Copy the signing secret intoSTRIPE_WEBHOOK_SECRET. -
Custom domain (optional): in App Platform Dashboard → Settings → Domains.
After editing .do/app.yaml:
doctl apps update <APP_ID> --spec .do/app.yamlPushing to main auto-deploys via deploy_on_push: true regardless of spec changes; you only need the explicit update when the spec itself changes (e.g. new env vars, scaling settings).
App Platform keeps the last several builds. To roll back:
Via Dashboard (fastest):
- Apps → offertum → Activity tab → find the last good deployment → click → Rollback to this deployment.
Via CLI:
doctl apps list-deployments <APP_ID> # find a good deployment id
doctl apps create-deployment <APP_ID> \
--force-rebuild \
--restore-from-deployment <DEPLOYMENT_ID>Database migrations are not auto-reverted. If a bad deploy ran a destructive migration, you have to write + apply a follow-up migration manually:
# In a local clone, generate a corrective migration:
cd apps/api
pnpm exec prisma migrate dev --name revert_<thing>
# Push to main — the next PRE_DEPLOY job runs it against prod.For schema changes that aren't safely reversible (dropping a column with live data), pause new deploys before merging:
doctl apps update-deployment-policy <APP_ID> --deploy-on-push=false…investigate, then re-enable.
.github/workflows/ci.yml runs typecheck + lint + tests + build on every PR and push. Merges to main only proceed after CI is green; App Platform then picks up the commit and deploys.