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
66 changes: 66 additions & 0 deletions .dependency-cruiser.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Architecture check (ADR-0001 D1, D7) – runs in `pnpm verify` via `pnpm depcruise`.
// A module's public API is its `index.ts`; everything else inside a module is private.
/** @type {import('dependency-cruiser').IConfiguration} */
module.exports = {
forbidden: [
{
name: "no-deep-import-across-modules",
severity: "error",
comment: "Import another feature module only through its public entry file src/features/<module>/index.ts.",
from: { path: "^src/features/([^/]+)/" },
to: { path: "^src/features/([^/]+)/.+", pathNot: ["^src/features/$1/", "^src/features/[^/]+/index\\.ts$"] },
},
{
name: "no-deep-import-into-modules-from-outside",
severity: "error",
comment: "App routes, db and config use feature modules only through src/features/<module>/index.ts.",
from: { path: "^src/", pathNot: "^src/features/" },
to: { path: "^src/features/[^/]+/.+", pathNot: "^src/features/[^/]+/index\\.ts$" },
},
{
name: "raw-db-client-only-in-db-and-tenancy",
severity: "error",
comment: "No raw database client outside src/db and src/features/tenancy (ADR-0001 D7); features get a tenant-scoped transaction.",
from: { path: "^src/", pathNot: ["^src/db/", "^src/features/tenancy/", "\\.test\\.ts$"] },
to: { path: "(^|/)node_modules/(pg|postgres|drizzle-orm/node-postgres)(/|$)" },
},
{
name: "no-db-connection-in-features",
severity: "error",
comment: "Feature modules never open connections or run migrations: createDatabase()/runMigrations() belong to the composition roots (src/app/_server, src/*.ts entrypoints). Types and table definitions (src/db/schema) are fine.",
from: { path: "^src/features/" },
to: { path: "^src/db/(index|client|migrate)\\.ts$", dependencyTypesNot: ["type-only"] },
},
{
name: "not-to-unresolvable",
severity: "error",
comment: "Every import must resolve – otherwise the boundary rules above would silently check nothing.",
from: {},
to: { couldNotResolve: true },
},
{
name: "features-do-not-import-app",
severity: "error",
comment: "Feature modules never depend on the Next.js app layer.",
from: { path: "^src/features/" },
to: { path: "^src/app/" },
},
{
name: "no-circular",
severity: "error",
from: {},
to: { circular: true },
},
],
options: {
doNotFollow: { path: "node_modules" },
exclude: { path: "(^|/)\\.next/" },
tsPreCompilationDeps: true,
tsConfig: { fileName: "tsconfig.json" },
enhancedResolveOptions: {
exportsFields: ["exports"],
conditionNames: ["import", "require", "node", "default", "types"],
extensions: [".ts", ".tsx", ".js", ".mjs", ".cjs", ".json"],
},
},
};
12 changes: 12 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.git
.claude
.next
node_modules
services
docs
tests
coverage
.env
.env.*
!.env.example
*.log
34 changes: 34 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# RequestFlow – every runtime variable, documented. Copy to `.env` for local overrides.
# The values below are LOCAL DEFAULTS for the machine-local Docker stack with synthetic data only
# (the same defaults as compose.yaml). Real environments set their own secrets in a secret manager;
# never commit a `.env` (it is git-ignored).

# --- PostgreSQL (container) --------------------------------------------------------------------
# Superuser of the local container only; the app never connects with it.
POSTGRES_PASSWORD=postgres-local-dev
# Passwords for the two app roles, created by docker/postgres/init/01-roles.sh on first start.
APP_OWNER_PASSWORD=owner-local-dev
APP_RW_PASSWORD=rw-local-dev
# Host port of the local database.
POSTGRES_PORT=54329

# --- App database connections ------------------------------------------------------------------
# Runtime role (web + worker): app_rw – no superuser, NOBYPASSRLS, row-level security applies.
DATABASE_URL=postgres://app_rw:rw-local-dev@127.0.0.1:54329/requestflow
# Owner role for migrations only (deploy step `pnpm setup:deploy`, integration-test setup).
MIGRATION_DATABASE_URL=postgres://app_owner:owner-local-dev@127.0.0.1:54329/requestflow

# --- Object storage (S3 API; local SeaweedFS) --------------------------------------------------
S3_ENDPOINT=http://127.0.0.1:8333
S3_REGION=eu-central-1
S3_BUCKET=requestflow-documents
S3_ACCESS_KEY_ID=local-access-key
S3_SECRET_ACCESS_KEY=local-secret-key
# SeaweedFS and most self-hosted S3 servers need path-style URLs.
S3_FORCE_PATH_STYLE=true
# Host port of the local S3 gateway.
S3_PORT=8333

# --- Web ---------------------------------------------------------------------------------------
# Host port of the web container.
WEB_PORT=3000
50 changes: 50 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,21 @@ jobs:
cache: pnpm
- run: pnpm install --frozen-lockfile
if: steps.diff.outputs.code == 'true' && hashFiles('package.json') != ''
# Integration tests run against real PostgreSQL + SeaweedFS from compose.yaml (same images,
# init script and local-default credentials as on a developer machine – no CI secrets).
- name: Start PostgreSQL + SeaweedFS
if: steps.diff.outputs.code == 'true' && hashFiles('package.json') != ''
run: |
docker compose up -d postgres storage
for _ in $(seq 1 60); do
s3="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8333/ || true)"
if docker compose exec -T postgres pg_isready -h 127.0.0.1 -U postgres -d requestflow >/dev/null 2>&1 && [ "$s3" != "000" ]; then
echo "postgres ready, storage answers HTTP $s3"; exit 0
fi
sleep 2
done
docker compose logs --tail 50
exit 1
- run: pnpm verify
if: steps.diff.outputs.code == 'true' && hashFiles('package.json') != ''

Expand Down Expand Up @@ -108,3 +123,38 @@ jobs:
uv run ruff format --check .
uv run pyright
uv run pytest -q

# Image + compose smoke (ADR-0001 D11): only when the Dockerfile or the compose file changes.
compose-smoke:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Detect image changes
id: diff
run: |
if git diff --name-only "origin/${{ github.base_ref }}...HEAD" | grep -qE '(^|/)Dockerfile$|^compose\.yaml$|^\.dockerignore$|^docker/'; then
echo "image=true" >> "$GITHUB_OUTPUT"
else
echo "image=false" >> "$GITHUB_OUTPUT"
fi
- name: docker compose up → /api/health
if: steps.diff.outputs.image == 'true'
run: |
if ! docker compose up -d --build; then
docker compose ps -a
docker compose logs --tail 80
exit 1
fi
for _ in $(seq 1 60); do
if curl -fsS http://127.0.0.1:3000/api/health; then echo; docker compose ps; exit 0; fi
sleep 3
done
docker compose ps
docker compose logs --tail 80
exit 1
- name: Worker is running
if: steps.diff.outputs.image == 'true'
run: docker compose ps --status running --services | grep -qx worker
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ __pycache__/
*.log
.DS_Store
Thumbs.db

# Agent worktrees (Claude Code)
.claude/worktrees/
10 changes: 3 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,10 @@ as a real customer engagement for a mid-sized machine-building company. **All da

## Commands & proof

> **Foundation phase:** there is no product code yet. Until the first code issue lands, `verify` is
> `bash scripts/doku-check.sh`. The commands below are the agreed targets; the first code issue
> creates them and removes this note.

- Setup: `pnpm install && uv sync --project services/ai` · Start: `docker compose up`
- `verify:changed` – inner loop: format, typecheck, focused tests of the touched files. `pnpm verify:changed -- <path>` · AI service: `uv run --project services/ai pytest <path>`
- Setup: `pnpm install && uv sync --project services/ai` · Start: `docker compose up` (runs the `setup` deploy step: migrations as `app_owner` + bucket) · Local services only: `docker compose up -d postgres storage`, then `pnpm setup:deploy`
- `verify:changed` – inner loop: lint (ESLint incl. style rules), typecheck, focused tests of the touched files. `pnpm verify:changed -- <path>` · AI service: `uv run --project services/ai pytest <path>`
- `verify` – canonical PR proof: lint, types, unit tests **and integration tests against real Postgres + S3 storage** (they prove tenant isolation and exactly-once export on every PR), architecture check (dependency-cruiser), build, `pnpm audit`, plus ruff/pyright/pytest for `services/ai`. `pnpm verify` (needs `docker compose up -d postgres storage`). A PR is not `ready-for-review` while verify fails, cannot run, or the exception is not justified in the PR.
- `verify:full` – Playwright smoke flow + full AI eval run. `pnpm verify:full` – before a release, after risky refactors or with PR label `verify-full`.
- `verify:full` – Playwright smoke flow + full AI eval run (until the smoke flow exists it equals `verify`). `pnpm verify:full` – before a release, after risky refactors or with PR label `verify-full`.
- **AI eval gate** (ADR-0001 D8): additionally runs path-targeted in CI on every change under `services/ai/` (prompts, parsing, extraction, model config) – a regression on a key field fails the PR.
- Test and verify output is trimmed automatically (`scripts/quiet-run.sh` via the hook `filter-test-output.sh`): exit code unchanged, full log path printed; prefix `FLUORY_FULL_OUTPUT=1` once when the cause is unclear.
- **Docs guard:** `scripts/doku-check.sh` – runs in CI and in `/finish-work`.
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,11 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Newest entry o
This file records what changes **in the product** – process and session state live in the PR plain-language section.

## [Unreleased]

### Added
- Runnable local stack: `docker compose up` starts PostgreSQL 17, SeaweedFS (S3), a one-shot `setup`
step (migrations + private bucket), the web app and a no-op worker.
- `GET /api/health` reports database and storage status (200 / 503, no connection details).
- Database roles `app_owner` (migrations) and `app_rw` (runtime, no RLS bypass); schema `app`.
- Verify commands `pnpm verify:changed`, `pnpm verify`, `pnpm verify:full`; CI runs integration
tests against real PostgreSQL + SeaweedFS.
33 changes: 33 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# TS app image (ADR-0001 D11): one image, two commands.
# web – Next.js standalone server (default CMD)
# worker – `node /ops/node_modules/tsx/dist/cli.mjs src/worker.ts` (working dir /ops)
# setup – `node /ops/node_modules/tsx/dist/cli.mjs src/setup.ts` (migrations + bucket, explicit deploy step)
FROM node:24-slim AS base
ENV PNPM_HOME=/pnpm PATH=/pnpm:$PATH NEXT_TELEMETRY_DISABLED=1 COREPACK_ENABLE_DOWNLOAD_PROMPT=0
RUN corepack enable
WORKDIR /app

FROM base AS deps
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile

FROM deps AS build
COPY . .
RUN pnpm build

FROM base AS prod-deps
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod

FROM node:24-slim AS runtime
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 HOSTNAME=0.0.0.0 PORT=3000
WORKDIR /app
COPY --from=build --chown=node:node /app/.next/standalone ./
COPY --from=build --chown=node:node /app/.next/static ./.next/static
# Worker and deploy step run the TypeScript sources with tsx from a separate production install.
COPY --from=prod-deps --chown=node:node /app/node_modules /ops/node_modules
COPY --chown=node:node package.json tsconfig.json /ops/
COPY --chown=node:node src /ops/src
USER node
EXPOSE 3000
CMD ["node", "server.js"]
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ AI-assisted intake of quote requests for industrial sales teams: extract structu
e-mails and PDF/Excel/Word attachments, review every value **beside its source**, and export each
approved request **exactly once** to an ERP.

> **Status:** foundation phase – architecture decided, no product code yet.
> **Status:** app skeleton – the stack runs and is checked in CI; product features follow issue by issue.
> **Reference project:** built like a real customer engagement for a mid-sized machine-building
> company; the customer is fictional and **all data in this repository is synthetic**.

Expand Down Expand Up @@ -41,8 +41,23 @@ Rationale, alternatives and trade-offs: [ADR-0001](docs/decisions/ADR-0001-pilot

## Getting started

Not runnable yet. The first code issue adds `docker compose up` (PostgreSQL, S3-compatible storage,
web, worker, AI service) and the `verify` commands listed in [AGENTS.md](AGENTS.md).
Requirements: Docker, Node 24 with corepack (`corepack enable` → pnpm 9.15.9).

```bash
cp .env.example .env # optional – the defaults are local, synthetic-data-only values
docker compose up --build # postgres, storage, setup (migrations + bucket), web, worker
curl localhost:3000/api/health # {"status":"ok","checks":{"database":"ok","storage":"ok"}}
```

Developing against local services only:

```bash
pnpm install
docker compose up -d postgres storage
pnpm setup:deploy # migrations as app_owner + bucket (needs the .env.example variables)
pnpm dev
pnpm verify # lint, types, unit + integration tests, architecture check, build, audit
```

## How this repository is run

Expand Down
90 changes: 90 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Local reference runtime (ADR-0001 D11). Credentials below are LOCAL DEFAULTS for a machine-local
# stack with synthetic data only – override them in `.env`; real environments never use them.
name: requestflow

services:
postgres:
image: postgres:17.11-alpine
environment:
POSTGRES_DB: requestflow
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres-local-dev}
APP_OWNER_PASSWORD: ${APP_OWNER_PASSWORD:-owner-local-dev}
APP_RW_PASSWORD: ${APP_RW_PASSWORD:-rw-local-dev}
ports:
- "${POSTGRES_PORT:-54329}:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- ./docker/postgres/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U postgres -d requestflow"]
interval: 2s
timeout: 3s
retries: 30

storage:
image: chrislusf/seaweedfs:4.47
# The S3 identity is written from env at start, so no credential file lives in the repo.
entrypoint: ["/bin/sh", "-c"]
command:
- |
printf '{"identities":[{"name":"app","credentials":[{"accessKey":"%s","secretKey":"%s"}],"actions":["Admin","Read","Write","List","Tagging"]}]}' \
"$$S3_ACCESS_KEY_ID" "$$S3_SECRET_ACCESS_KEY" > /tmp/s3.json
exec weed server -dir=/data -s3 -s3.port=8333 -s3.config=/tmp/s3.json -master.volumeSizeLimitMB=64
environment:
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-local-access-key}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-local-secret-key}
ports:
- "${S3_PORT:-8333}:8333"
volumes:
- seaweed:/data

# One image, three commands (ADR-0001 D11). Connection data is built from the same env defaults.
setup:
build: .
image: requestflow-app:local
working_dir: /ops
command: ["node", "node_modules/tsx/dist/cli.mjs", "src/setup.ts"]
environment: &app-env
DATABASE_URL: postgres://app_rw:${APP_RW_PASSWORD:-rw-local-dev}@postgres:5432/requestflow
MIGRATION_DATABASE_URL: postgres://app_owner:${APP_OWNER_PASSWORD:-owner-local-dev}@postgres:5432/requestflow
S3_ENDPOINT: http://storage:8333
S3_REGION: eu-central-1
S3_BUCKET: requestflow-documents
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-local-access-key}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-local-secret-key}
S3_FORCE_PATH_STYLE: "true"
depends_on:
postgres:
condition: service_healthy
storage:
condition: service_started
restart: on-failure:5

web:
build: .
image: requestflow-app:local
environment:
<<: *app-env
MIGRATION_DATABASE_URL: ""
ports:
- "${WEB_PORT:-3000}:3000"
depends_on:
setup:
condition: service_completed_successfully

worker:
build: .
image: requestflow-app:local
working_dir: /ops
command: ["node", "node_modules/tsx/dist/cli.mjs", "src/worker.ts"]
environment:
<<: *app-env
MIGRATION_DATABASE_URL: ""
depends_on:
setup:
condition: service_completed_successfully

volumes:
pgdata:
seaweed:
18 changes: 18 additions & 0 deletions docker/postgres/init/01-roles.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Creates the two application roles on first start of an empty data directory (ADR-0001 D7).
# Passwords come from the environment – never from the repository or a migration.
# app_owner – owns schema `app`, runs migrations
# app_rw – runtime role: no superuser, no BYPASSRLS, so row-level security always applies
set -euo pipefail
: "${APP_OWNER_PASSWORD:?APP_OWNER_PASSWORD is required}"
: "${APP_RW_PASSWORD:?APP_RW_PASSWORD is required}"

# psql quotes :'var' as a literal and :"var" as an identifier – no string building in bash.
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \
-v owner_pw="$APP_OWNER_PASSWORD" -v rw_pw="$APP_RW_PASSWORD" -v db="$POSTGRES_DB" <<'SQL'
CREATE ROLE app_owner LOGIN PASSWORD :'owner_pw' NOSUPERUSER NOCREATEROLE NOBYPASSRLS;
CREATE ROLE app_rw LOGIN PASSWORD :'rw_pw' NOSUPERUSER NOCREATEROLE NOCREATEDB NOBYPASSRLS;
GRANT CONNECT ON DATABASE :"db" TO app_owner, app_rw;
GRANT CREATE ON DATABASE :"db" TO app_owner;
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
SQL
Loading
Loading