Files
bizmatch-app/README.md
2026-07-26 17:45:58 -05:00

188 lines
8.0 KiB
Markdown

# BizMatch Phase 2 — Broker Workflow App
Module 1: foundation (Docker Compose, PostgreSQL, schema, migrations, login).
Module 2: business scan (NAS -> DB) and the first UI.
Module 3: recursive file listing, PDF streaming from the NAS and the ported
pdf.js viewer.
The UI and all domain constants are English.
## Setup on 192.168.100.99 (Ubuntu 24.04)
Prerequisite: Docker + Compose plugin (`sudo apt install docker.io docker-compose-v2`).
```bash
# Unpack the project, then:
cd bizmatch-app
cp .env.example .env # optionally adjust DB_PASSWORD
docker compose up -d --build
```
The app applies all migrations on start and then listens on
`http://192.168.100.99:8090`.
> **Upgrading from Module 1:** `001_init.sql` was rewritten in place (German
> constants -> English). Applying it needs a fresh database:
> `docker compose down -v && docker compose up -d --build`. The DB held no
> production data yet, so there is nothing to migrate.
First smoke test:
```bash
curl http://localhost:8090/api/health
# -> {"ok":true}
# Create the three staff members (adjust the names):
curl -X POST localhost:8090/api/staff -H 'content-type: application/json' -d '{"name":"Chris"}'
curl -X POST localhost:8090/api/staff -H 'content-type: application/json' -d '{"name":"..."}'
```
## Dev mode (without the app container)
```bash
docker compose up -d db # database only
npm install
set -a; source .env; set +a
npm run dev # tsx watch, migrations run on start
# second terminal — frontend with hot reload, /api is proxied to :8090
cd web && npm install && npm run dev
```
## NAS mount
Mount it on the host via NFS, e.g. in `/etc/fstab`:
```
<truenas-ip>:/mnt/<pool>/bizmatch /mnt/bizmatch-nas nfs ro,soft,timeo=100 0 0
```
The compose file already passes `NAS_ROOT` (default `/mnt/bizmatch-nas`) into the
app container. Write access (NDA filing) comes in module 6 — then replace `ro`
with `rw` and limit the permissions to the two write paths.
### Business directories
Directly below `NAS_ROOT` there are three status directories; every immediate
subdirectory of those is one business. The directory names are configurable
(they contain spaces and are treated as opaque strings):
| Env var | Default | Business status |
| ------------------ | -------------- | --------------- |
| `NAS_DIR_ACTIVE` | `AAA = ACTIVE` | ACTIVE |
| `NAS_DIR_SOLD` | `AAA = SOLD` | SOLD |
| `NAS_DIR_INACTIVE` | `AAA = INACTIVE` | INACTIVE |
The scan is idempotent: existing rows are matched by name and only updated when
`nas_path` or `status` changed. Businesses that exist in the DB but no longer on
disk are kept and only reported as a warning in the log. A missing configured
directory aborts the scan with an error naming the path.
## Moving to the AI machine (later)
1. `docker compose down` on .99
2. Take a dump: `docker compose exec db pg_dump -U bizmatch bizmatch > backup.sql`
3. Copy the project folder to the AI machine, run `docker compose up -d --build`
4. Restore the dump: `docker compose exec -T db psql -U bizmatch bizmatch < backup.sql`
## API (as of module 3)
| Method | Path | Purpose | Session |
| ------ | --------------------------- | ------------------------------------------------ | ------- |
| GET | /api/health | liveness incl. DB check | no |
| GET | /api/staff | staff list | no |
| POST | /api/staff | create staff member `{name}` | no |
| POST | /api/login | login via `{staff_id}` → session cookie | no |
| GET | /api/me | signed-in staff member | yes |
| POST | /api/logout | sign out | yes |
| POST | /api/businesses/scan | scan the NAS → `{scanned, inserted, updated, missing}` | yes |
| GET | /api/businesses | list `?status=&search=` + counts per status | yes |
| GET | /api/businesses/:id | single business incl. `nas_path` | yes |
| GET | /api/businesses/:id/files | recursive listing, max depth 3 (PDFs first) | yes |
| GET | /api/businesses/:id/file | stream one file, `?path=<relative>` | yes |
Everything except health, staff (GET+POST) and login requires the session
cookie; without it the API answers `401`.
### File listing and streaming
`/files` walks the business directory recursively (max depth 3), skipping
dotfiles, dot-directories and symlinks, and returns
`{ path, size, mtime }` with `path` relative to the business directory and
always posix-separated. PDFs come first, then everything else, each group
alphabetical.
`/file?path=…` streams one of those files straight from disk
(`createReadStream`, never buffered):
* the path is resolved against `nas_path` and then `realpath`-validated to be
inside `realpath(business dir)`. Absolute paths, `..`, leading dots, empty
paths and symlinks pointing out of the tree get `400`; a missing file `404`.
* single-range HTTP `Range` requests answer `206` with `Content-Range`,
unsatisfiable ones `416`.
* `ETag` is derived from mtime + size, `If-None-Match` answers `304`.
* `.pdf` is served as `application/pdf` (inline), anything else as
`application/octet-stream` with `Content-Disposition: attachment`.
## Frontend
`web/` is a Vite + React + TypeScript app with Tailwind v4 (no router, no state
library). Views: login ("Who is working?"), business list (tabs with counts,
search, "Scan NAS now") and business detail — a master-detail split filling the
viewport: file table left, PDF viewer right.
In dev, Vite proxies `/api` to `http://localhost:8090`. In production the
Fastify app serves `web/dist` via `@fastify/static` with an SPA fallback to
`index.html` for all non-`/api` routes; the Dockerfile builds the frontend in
its own stage and copies `web/dist` into the runtime image.
### PDF viewer
The viewer is the proven one from the phase-1 Deno desktop app (see
`viewer-phase1/`), ported nearly byte-identical. It lives in
`web/public/viewer/` as plain, unbundled ES modules — Vite serves `public/`
as-is, so the same files work in dev and prod. The React app embeds it in an
`<iframe>`:
```
/viewer/index.html?file=<urlencoded /api/businesses/:id/file?path=...>
```
The page refuses any `file` value that is not a root-relative `/api/` path, and
the iframe is same-origin, so the normal session cookie authenticates it.
`web/public/pdfjs/` holds the pdf.js runtime, copied out of
`node_modules/pdfjs-dist` (pinned to exactly 6.1.200) by
`web/scripts/copy-pdfjs.mjs`, which runs on `predev` and `prebuild` — also
inside the Docker web stage. The directory is generated and git-ignored:
```
web/public/pdfjs/legacy/ pdf.min.mjs + pdf.worker.min.mjs
web/public/pdfjs/wasm/ CCITT-G4/JBIG2, JPEG2000 and ICC decoders
web/public/pdfjs/standard_fonts/ standardFontDataUrl
web/public/pdfjs/iccs/ iccUrl
```
The `wasm/` directory is what makes scanned B/W pages render at all; without it
pdf.js fails the decoders silently and shows blank white canvases.
## Structure
```
migrations/ numbered SQL migrations (001_init.sql = full schema)
src/
config.ts env configuration
db.ts pg pool + query helpers
migrate.ts migration runner (transactional, advisory lock)
business-scan.ts NAS scan, recursive listing, safe file path resolution
server.ts Fastify app (health, staff, login, businesses, file, static)
web/
scripts/copy-pdfjs.mjs pdfjs-dist -> public/pdfjs/ (predev + prebuild)
public/viewer/ standalone, unbundled pdf.js viewer page
public/pdfjs/ generated, git-ignored pdf.js runtime
src/api.ts typed API client
src/App.tsx session gate + view switch
src/views/ Login, Businesses, BusinessDetail
viewer-phase1/ reference copy of the phase-1 desktop viewer
```