Procurement orchestration with durable workflows. Intake → policy evaluation → configurable approvals → purchase order → append-only audit, built as a production-lean MVP.
ProcureFlow is a focused Zip-style procurement platform: employees submit purchase requests, a deterministic policy engine decides who must approve, the approval chain is held durably by the workflow engine, purchase orders are generated after final approval, and every action is attributed in an audit trail.
- TypeScript end to end (Next.js App Router, durable workflow SDK, Drizzle).
- PostgreSQL locally (Docker/portable) and on Neon in production.
- Durable workflows with signals, queries, timers, SLA escalation and retries.
- Config-only switching between local, stock and managed workflow endpoints.
- Real adapter boundaries for email, Slack, ERP, storage and AI risk summaries.
- Framer Motion for entrance and interaction choreography, with Lucide icons.
- Runnable on Render + Neon with an external workflow engine endpoint.
ProcureFlow is the control layer between a purchase request and a purchase order. It is for finance, IT, operations and admin teams that need every purchase to follow the same policy, reach the right approver and leave an audit trail — without adopting a full ERP.
The job it does
- Gives employees one structured place to request hardware, software, services or supplies.
- Applies a deterministic policy to choose the approval chain, SLA and PO path.
- Holds approvals durably, so decisions survive restarts, deployments and long waits.
- Issues a purchase order with an ERP reference after final approval.
- Records every submission, decision and policy evaluation in an append-only audit trail.
Who it is for
- Requesters who need a clear status instead of chasing email threads.
- Managers and finance who need consistent approvals and spend controls.
- IT and operations who buy across many vendors and categories.
- Admins who own policies, vendors and the audit record.
What it is not
ProcureFlow is not a vendor marketplace, an ERP general ledger, an expense reimbursement tool or a contract lifecycle suite. It is the focused workflow and control layer for the procurement decision itself.
| Login | Dashboard |
|---|---|
![]() |
![]() |
| Mobile landing | Mobile security |
|---|---|
![]() |
![]() |
flowchart LR
subgraph Browser
UI["Next.js App Router UI<br/>requester / approver / finance / admin"]
end
subgraph Web["apps/web (Next.js)"]
API["Route handlers + server components<br/>Zod validation · RBAC · rate limits"]
TC["engine client"]
DBREAD["Read models<br/>Drizzle"]
end
subgraph Engine["Workflow engine"]
WF["purchaseRequestWorkflow<br/>vendorOnboardingWorkflow"]
TQ["Task queue: procureflow"]
end
subgraph Worker["apps/worker"]
W["Engine worker<br/>interceptors + integrations plugin"]
ACT["Activities<br/>all side effects"]
end
subgraph Data
PG[("PostgreSQL / Neon")]
end
subgraph Adapters
EMAIL["Email: console / Resend"]
SLACK["Slack webhook / no-op"]
ERP["ERP: CSV / no-op"]
STORE["Storage: local / S3 stub"]
AI["AI risk summary: deterministic"]
end
UI --> API
API --> DBREAD --> PG
API --> TC --> TQ
TQ --> W
W --> WF
WF --> ACT
ACT --> PG
ACT --> EMAIL
ACT --> SLACK
ACT --> ERP
ACT --> STORE
ACT --> AI
TC -. query / signals .-> WF
sequenceDiagram
actor Requester
actor Approver
actor Finance
participant Web as Next.js API
participant DB as PostgreSQL
participant T as workflow engine
participant W as Worker
participant PO as ERP adapter
Requester->>Web: POST /api/requests (draft)
Web->>DB: insert request + items + audit
Requester->>Web: POST /api/requests/:id/submit
Web->>T: start purchase-request-<id>
T->>W: run workflow
W->>DB: validate, evaluate policy, create tasks
W->>Approver: notification (outbox + adapter)
Approver->>Web: POST /api/approvals/:taskId (approve)
Web->>T: signal approve(taskId, actorId, comment)
T->>W: record decision (audit + advance)
W->>Finance: notification
Finance->>Web: POST /api/approvals/:taskId (approve)
Web->>T: signal approve
T->>W: final approval
W->>DB: PO number + PO + status PO_ISSUED
W->>PO: ERP CSV sync
W->>Requester: PO notification
The API never mutates approval state directly: submit starts a workflow and approval actions only send workflow engine signals. The worker's activities are the only writers of approval/PO state and audit events.
apps/
web/ Next.js App Router UI, API routes, Auth.js
worker/ Engine worker process
packages/
shared/ Zod schemas, domain types, deterministic policy engine
db/ Drizzle schema, migrations, seed, client
workflows/ Workflows, activities, interceptors, activity contracts
integrations/ Adapter interfaces + implementations + Worker Plugin
third_party/temporal/ Vendored engine source (upstream + small rework)
scripts/ Engine build/run, dev orchestration, smoke, CI guard
.github/workflows/ci.yml Quality, stock-engine E2E, engine compile
docker-compose.yml Local Postgres
render.yaml Render web + worker blueprint
VENDOR_BASE.md Pinned upstream provenance
VENDOR_MODIFICATIONS.md Every local engine change
| Tool | Version | Notes |
|---|---|---|
| Node.js | 20+ (tested on 24) | Next.js 14, workflow SDK |
| pnpm | 10+ | corepack enable |
| Docker | optional if Postgres is already running | docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d postgres |
| Go | 1.27+ | Only for the reworked engine build |
| engine CLI | auto-downloaded | Used by pnpm dev:stock-engine |
cp .env.example .env
# Edit AUTH_SECRET and INTERNAL_API_TOKEN for anything beyond local demo.pnpm install
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d postgrespnpm db:migrate
pnpm db:seedpnpm devpnpm dev starts, in order:
- Postgres if it is not already listening on
DATABASE_URL(via docker compose), - the reworked engine from
third_party/temporal(builds it if missing), - database migrations + idempotent seed,
- the workflow engine worker,
- the Next.js web app at http://localhost:3000.
The reworked server persists workflow state in
third_party/temporal/.build/engine.db and auto-provisions the default
namespace plus ProcureFlow search attributes.
The reworked engine does not include an inspector UI. The stock engine (
pnpm dev:stock-engine) serves its UI on http://localhost:8233;WORKFLOW_ENGINE_UI_URLis available for operators who expose one separately.
pnpm dev:stock-engineThis downloads the pinned engine CLI (v1.9.1), starts its dev server with the
ProcureFlow search attributes, then runs migrations/seed, worker and web. It is the
flow used by CI and does not require Go.
You can also run pieces separately:
pnpm engine:dev:stock # stock engine only
pnpm engine:build # build reworked engine + bootstrap (Go 1.27+)
pnpm engine:dev # reworked engine only
pnpm dev # full reworked stack
pnpm smoke # E2E against a running stackAll demo users use password Demo1234! (local/demo only — never use in
production).
| Role | Can do | |
|---|---|---|
| Requester | requester@procureflow.dev |
Create/submit requests, track status, resubmit changes |
| Approver | approver@procureflow.dev |
Manager approval step |
| Finance | finance@procureflow.dev |
Finance approvals, vendor review, audit, POs |
| Admin | admin@procureflow.dev |
Policies, vendor review, audit, escalations |
- Sign in as requester → New request.
- Pick Northwind Office Supply, add 2 × $1,450 laptops, justify, submit.
- The policy engine routes to Approver → Finance (total $2,900).
- Sign in as approver → Approvals → approve with a comment.
- Sign in as finance → Approvals → approve.
- Open the request: PO is issued and synced, vendor risk summary and full audit
trail are visible. The workflow engine workflow card shows a live
getStatequery. - Optional: submit a $12,000 request (adds admin/compliance), or use the pending Apex Consulting Group high-risk vendor (adds a compliance step).
- Optional: sign in as admin → Policies to change amounts/approvers and see the next request pick up the new chain. Vendor onboarding is also workflow-driven.
users, vendors, purchase_requests, purchase_request_items,
approval_tasks, purchase_orders, audit_events, notification_outbox,
policies (Drizzle schema in packages/db/src/schema.ts).
Statuses match the brief: requests move through DRAFT → SUBMITTED → IN_APPROVAL → APPROVED → PO_ISSUED, with CHANGES_REQUESTED, REJECTED, CANCELLED branches.
Approval tasks support PENDING, APPROVED, REJECTED, CHANGES_REQUESTED,
ESCALATED, CANCELLED.
Policies are DB rows (conditions + approvers JSONB):
- match amount range, departments, categories, vendor risk, vendor status, urgency;
- all populated conditions must match (AND);
- every matching enabled policy contributes approvers;
- approvers de-duplicate by role and are ordered manager → finance → admin;
- the longest SLA wins when several rules add the same role;
- an empty approver list means auto-approve (request goes straight to PO);
- no match falls back to one admin/compliance review.
Seeded rules: auto-approve ≤ $1,000; manager + finance $1,000.01–$10,000; admin added
above $10,000; high-risk vendor adds admin; vendor not yet approved adds admin;
HIGH urgency gives finance a 12-hour SLA. Amounts are stored as integer cents and
evaluation is pure/deterministic (packages/shared/src/policy.ts), unit-tested at
boundaries and for rule merging.
Admins can create/edit/disable policies at /policies. Changes apply to new
evaluations; in-flight workflows keep the chain they were created with.
vendor-onboarding-<vendorId> workflow:
beginVendorReviewactivity transitions DRAFT → PENDING_REVIEW, audits, notifies admins;- waits up to 7 days for a
decidesignal from finance/admin; completeVendorReviewactivity sets APPROVED/BLOCKED, updates risk level and a deterministic AI risk summary, audits, notifies the creator;- if the SLA expires the vendor remains pending and an expiry audit event is written.
| Workflow | ID |
|---|---|
| Purchase request | purchase-request-<requestId> (revision 1); ...-r<revision> only if a previous run closed after the 30-day resubmit window |
| Vendor onboarding | vendor-onboarding-<vendorId> |
| Outbox drain | notification-outbox-<timestamp>-<random> |
The request stores its workflowId, so the API always signals/queries the right run.
- Validates and persists submission via
validateAndSubmitRequestactivity. - Evaluates policy via
evaluateRequestPolicyactivity (reads policies from DB). - Creates approval tasks with due dates via
createApprovalPlan. - Sends outbox-backed email/Slack notifications via activities.
- Waits on signals
approve,reject,requestChanges(payload: taskId, actorId, comment), plusresubmitfor the changes-requested loop. - Uses
workflow.conditionwith per-step timers:- on SLA expiry, escalates the task to ADMIN with a new 24h deadline;
- if the admin SLA also expires, rejects the request with an audit reason.
- Final approval → PO number + PurchaseOrder +
PO_ISSUED; notifies requester and finance; syncs the PO to the ERP adapter (CSV locally) with heartbeats. - Rejection closes the request and notifies the requester.
- Changes requested transitions to
CHANGES_REQUESTED, notifies the requester and waits for aresubmitsignal (30 days). Resubmission increments the revision, re-evaluates policy and reuses the same workflow ID. - Exposes query
getStatereturning DB status, approval chain, pending task, policy explanation, last decision and an in-workflow audit summary. - All activities have retry policies; validation/authorization failures are
non-retryable
ApplicationFailures. Side-effecting writes use unique dedupe keys (workflowId + step + action), so activity retries do not duplicate audit events, tasks, POs or notifications. - The workflow never performs DB/network I/O.
audit_events.dedupe_keyunique (nullable for user-driven CRUD).notification_outbox.dedupe_keyunique; failed rows are retried, sent rows are skipped.approval_tasks (request_id, step_order)unique and upserted on each plan.purchase_orders.request_idunique; PO number collisions retry.- Request workflow ID is deterministic; duplicate starts are treated as already running and signal-based resubmission resumes the original run.
packages/workflows/src/interceptors.ts— workflow inbound/outbound interceptor that attaches actorId/actorRole/requestId to workflow logs and records every signal. Registered via Workerinterceptors.workflowModules.packages/workflows/src/activity-interceptors.ts— activity inbound/outbound interceptor using AsyncLocalStorage so structured activity logs include actor and request context. Registered via Workerinterceptors.activity.- Both are optional:
PROCUREFLOW_AUDIT_INTERCEPTOR_ENABLED=falseskips them. packages/integrations/src/worker-plugin.ts—IntegrationsWorkerPlugininstantiates the configured adapters before the Worker starts and exposes them through a registry that activities resolve at execution time. Swap providers with env config (or implement the interface); workflows never change. Disable withPROCUREFLOW_WORKER_PLUGIN_ENABLED=false.
The engine server source is vendored at third_party/temporal/ (upstream
main @ 96a50b1, 2026-11-02). Exact provenance and the local tag are in
VENDOR_BASE.md; every local change is in
VENDOR_MODIFICATIONS.md.
The upstream server code is unmodified. The rework consists of:
cmd/tools/procureflow-bootstrap/main.go— uses the public Go SDK operator API to wait for the server, create the namespace and idempotently register the threeProcureFlow*Keyword search attributes.config/procureflow-dev.yaml— durable SQLite dev topology (.build/engine.db) so approval workflows survive worker/server restarts.
Why not patch server internals? The required behavior is achievable with SDK APIs,
interceptors, a Worker Plugin and operator APIs; keeping server code pristine means
the same app works on managed and stock engines. See the rationale and the
preferred-extension-point checklist in VENDOR_MODIFICATIONS.md.
Build and run:
pnpm engine:build # requires Go 1.27+
pnpm engine:dev # SQLite durable server + bootstrapSearch attribute behavior is gated by WORKFLOW_ENGINE_SEARCH_ATTRIBUTES:
true→ workflows upsertProcureFlowDepartment,ProcureFlowRisk,ProcureFlowStatus; the reworked bootstrap ensures they exist. Verified via the engine inspector and a visibility query.false(default for the stock engine or a managed service) → the app skips those upserts and uses the PostgreSQL read model for everything. No code changes.
CI runs all functional tests against stock engine CLI, plus a compile-only job for the reworked server, so the rework is never load-bearing for basic functionality.
| Environment | Engine | Notes |
|---|---|---|
| Local dev | Reworked engine (pnpm dev) |
Durable SQLite, bootstrap, custom search attributes |
| Local fallback / CI | Stock engine (pnpm dev:stock-engine) |
No Go required; proves portability |
| Self-hosted | Any stock engine cluster | Point WORKFLOW_ENGINE_ADDRESS; keep rework flag false |
| Render + Neon | managed service or external engine | Reworked server is not deployed to Render |
All inputs are validated with Zod; RBAC is enforced server-side; cookie-authenticated mutations reject cross-origin requests; login/approval/public mutations are rate limited per process.
| Method | Route | Purpose |
|---|---|---|
* |
/api/auth/[...nextauth] |
Auth.js credentials login/session |
GET |
/api/health |
DB + workflow engine readiness (200/503) |
GET/POST |
/api/requests |
List my/all requests; create draft |
GET/PATCH |
/api/requests/:id |
Detail + live workflow state; edit draft/changes |
POST |
/api/requests/:id/submit |
Start/resume purchaseRequestWorkflow |
POST |
/api/requests/:id/cancel |
Cancel a draft |
GET |
/api/approvals |
Active approval inbox for the actor’s role |
POST |
/api/approvals/:taskId |
Signal approve / reject / request changes |
GET/POST |
/api/vendors |
Vendor directory / create DRAFT vendor |
GET/PATCH |
/api/vendors/:id |
Vendor detail / edit |
POST |
/api/vendors/:id/review |
Start vendor onboarding workflow |
POST |
/api/vendors/:id/decision |
Signal compliance decision |
GET/POST |
/api/policies |
List/create policies (admin) |
PATCH |
/api/policies/:id |
Edit policy (admin) |
GET |
/api/audit |
Filter/paginate audit log (admin/finance) |
POST |
/api/assistant/conversations |
Start a requester-scoped assistant intake |
POST |
/api/assistant/conversations/:id/turns |
Send one conversation turn |
GET |
/api/assistant/reviews |
Role-scoped assistant review queue |
POST |
/api/assistant/reviews/:id |
Approve/reject/edit an escalation |
POST |
/api/internal/outbox |
Retry outbox notifications (Bearer INTERNAL_API_TOKEN or admin) |
Pages: / marketing landing, /login, /dashboard, /requests/new wizard,
/requests/:id timeline, /approvals inbox, /vendors directory, /policies
admin, /audit log, /assistant guided intake, /assistant/reviews HITL queue.
The assistant is a separate deterministic Python orchestrator with a local model as its language interface. The model does not choose the next slot, readiness, routing, submission, retries, or permissions. See services/assistant/README.md.
Run it locally:
ollama serve
ollama pull phi4-mini:3.8b
pnpm db:migrate
pnpm assistant:dev
# in another terminal
pnpm devThe assistant service is also part of docker-compose.yml; Ollama stays on the host and
is reached at host.docker.internal:11434. If Docker is running inside WSL without
Docker Desktop, set ASSISTANT_HOST_IP to the WSL host gateway from ip route
(for example 172.28.144.1) and bind Ollama with OLLAMA_HOST=0.0.0.0:11434.
No conversation or model data leaves the machine. The default confidence thresholds
are conservative calibration seeds:
ASSISTANT_MODEL="phi4-mini:3.8b"
ASSISTANT_MODEL_QUANTIZATION="Q4_K_M"
ASSISTANT_AUTO_ACCEPT_LIMIT_CENTS="250000"
ASSISTANT_HIGH_MIN_LOGPROB="-0.35"
ASSISTANT_MID_MIN_LOGPROB="-1.0"Every turn exposes only the tools allowed at that step; filled and dependency-blocked slots are never sent to the model. Low confidence, malformed tool calls, failed guardrails, and consequential routing go to a role-scoped review queue. The assistant can create a draft only — it never submits or approves.
Interfaces live in packages/integrations/src/types.ts; local implementations work
with no configuration:
| Adapter | Local | Production path |
|---|---|---|
| Console structured log | EMAIL_ADAPTER=resend + RESEND_API_KEY (implemented) |
|
| Slack | No-op unless SLACK_WEBHOOK_URL is set |
Incoming webhook (implemented) |
| ERP | CSV per PO in ERP_CSV_DIR |
Implement ErpAdapter for NetSuite/SAP/Ariba |
| Storage | Local filesystem | S3StorageAdapter stub (metadata-only attachments in MVP) |
| AI risk | Deterministic, offline summary | Replace AiRiskSummaryAdapter with an LLM agent |
All outbound notifications are also written to notification_outbox with status and
error, so failures are visible and retryable. POST /api/internal/outbox starts the
notificationOutboxWorkflow, which runs the drain activity through the same adapters.
pnpm test:unit # policy engine + Zod schemas
pnpm test:workflows # workflow engine workflow integration tests (5 scenarios)
pnpm assistant:test # 20 deterministic assistant replay tests
pnpm typecheck
pnpm lint
pnpm build
pnpm engine:check # workflow engine modification log freshness
pnpm smoke # full HTTP + workflow engine + DB E2E against a running stackWorkflow tests use the durable workflow test harness and cover: manager+finance approval → PO, rejection, changes requested + resubmission, SLA escalation to admin, and auto-approval. They download/cache a workflow engine test server on first run.
CI (.github/workflows/ci.yml):
- quality — install, workflow engine diff guard, lint, typecheck, unit tests, production build.
- stock-engine — Postgres service, stock engine CLI dev server, workflow tests, worker + web, full smoke test.
- engine-build — compiles the reworked server and bootstrap with Go.
The default docker-compose.yml is a deployable application stack, not a
database-only file. It contains four services:
| Service | Type | Purpose |
|---|---|---|
web |
Application (built from Dockerfile) |
Next.js UI + API; assign the public domain and port 3000 |
worker |
Application (built from Dockerfile) |
Durable workflow worker; no public domain |
engine |
Internal service | Workflow engine (stock auto-setup image) on engine:7233 |
postgres |
Database | PostgreSQL 16 with a persistent volume |
This is why the old Coolify error appeared: the previous compose file exposed only the Postgres service, so Coolify had no non-database service to attach a domain to.
- Create a Docker Compose resource from this repository.
- Use
/docker-compose.ymlas the compose file and branchmain. - Set these environment variables in Coolify:
APP_URL— the final public URL, e.g.https://procureflow.example.comPOSTGRES_PASSWORD— recommended; use a long URL-safe passwordAUTH_SECRET— optional but recommended for explicit controlINTERNAL_API_TOKEN— optional but recommended for explicit controlSEED_DEMO_DATA—trueon the first deployment if you want the demo users (requester@procureflow.dev/Demo1234!), thenfalseand change the users. It isfalseby default for a real deployment.
- Deploy. The web container runs
pnpm db:migratebeforenext start. - Assign the domain to the
webservice and set the internal port to 3000. In Coolify's domain field, usehttps://your-domain:3000, or set Port to3000explicitly. The:3000is the internal container port; normal visitors still use HTTPS on port 443. Do not assign domains topostgres,engine, orworker. Do not use127.0.0.1as the domain; that points the browser at its own machine and cannot receive a TLS certificate. - Set
APP_URLto the assigned public domain without:3000(for examplehttps://procureflow.example.com) and redeploy if it was not known up front.
The web service publishes container port 3000 for Coolify's proxy/health
checks as well as direct testing. A temporary http://SERVER_PUBLIC_IP:3000
check will show “not secure” because it bypasses HTTPS; use a real DNS name for
TLS. A 502 Bad Gateway means the proxy cannot reach the web container — verify
the domain is pointing at the Coolify server and the web service is healthy.
If the browser opens a TP-Link admin page instead of ProcureFlow, the hostname is
resolving to the router, not to the Coolify server. 127.0.0.1 is never a valid
public deployment domain.
-
On the Coolify server, find its public IPv4 address:
curl -4 https://ifconfig.me
-
At your DNS provider, create an A record for the app hostname pointing to that public IP. Do not include
https://, a port,127.0.0.1, or a private192.168.x.xaddress in the DNS record. -
If the server is behind a TP-Link router, forward inbound TCP 80 and 443 to the server's LAN IP. Do not forward them to the router's own admin interface. A router page on the app hostname usually means the A record points at the router or the request is hitting router management.
-
Verify from an external network / phone hotspot:
nslookup app.example.com 8.8.8.8
It must return the public IP from step 1. If it returns
127.0.0.1,192.168.x.x, or the router address, fix the A record and wait for TTL. -
In Coolify use
https://app.example.com:3000(or set Port3000), and setAPP_URL=https://app.example.com. Redeploy after the DNS record is correct.
Hairpin NAT can make the domain fail from inside the same LAN even when it works externally; test from a phone on cellular data before changing the app.
AUTH_SECRET and INTERNAL_API_TOKEN are not required during docker compose build.
If omitted at runtime, the web entrypoint generates strong values on first boot and
persists them in the procureflow_runtime volume, so sessions survive restarts.
Set them explicitly if you run multiple web replicas. The Coolify health check only
verifies that the web server is serving /login; full DB and engine readiness is
reported separately at /api/health.
Local production-mode test:
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
docker compose -f docker-compose.yml -f docker-compose.dev.yml ps
# open http://localhost:3000For local Postgres-only development, keep using the dev override:
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d postgrescoolify.env.example lists the required values. Never commit a filled-in copy.
The Postgres, engine, and runtime data volumes persist across container restarts.
- Create a Neon project and database.
- Copy the pooled connection string into
DATABASE_URL(add?sslmode=requireif Neon does not include it). - Copy the direct connection string into
DIRECT_URL(used by migrations). - Set
DATABASE_SSL=true,DATABASE_POOLED=true. The app uses postgres.js withprepare: falsefor pooled endpoints,DATABASE_URLat runtime andDIRECT_URLforpnpm db:migrateand the seed.
- Push the repo to GitHub.
- Render → New → Blueprint → select the repo (
render.yaml). - Fill the
sync: falseenv vars: Neon URLs,WORKFLOW_ENGINE_ADDRESS,WORKFLOW_ENGINE_NAMESPACE,WORKFLOW_ENGINE_API_KEY(managed engine),WORKFLOW_ENGINE_UI_URL,AUTH_URL(your Render web URL), optional Resend/Slack keys. - Deploy. The web service runs
pnpm db:migrateas a pre-deploy command and exposes/api/health; the worker runspnpm --filter @procureflow/worker start. - Deploy the web service before/with the worker so migrations run first. If you
prefer a separate migration step, run
pnpm db:migratefrom a Render shell or CI withDIRECT_URL.
RENDER_EXTERNAL_URL is used automatically for Auth.js when AUTH_URL is unset.
managed engine: create a namespace, copy the gRPC endpoint into
WORKFLOW_ENGINE_ADDRESS, set WORKFLOW_ENGINE_NAMESPACE, WORKFLOW_ENGINE_TLS=true,
WORKFLOW_ENGINE_API_KEY, and keep WORKFLOW_ENGINE_SEARCH_ATTRIBUTES=false. The app uses official
SDK packages only and requires no code changes.
Do not attempt to run the reworked engine server on Render in this MVP. It is a local/self-hosted development artifact; use managed engine or an external self-hosted endpoint for managed deployment.
See .env.example for the complete annotated list. Core values:
| Variable | Purpose |
|---|---|
DATABASE_URL |
Runtime Postgres URL (Neon pooled) |
DIRECT_URL |
Migration/seed Postgres URL (Neon direct) |
DATABASE_SSL, DATABASE_POOLED |
SSL and PgBouncer-compatible prepared statements |
AUTH_SECRET |
Auth.js signing secret (32+ random bytes) |
AUTH_URL / NEXTAUTH_URL |
Public app URL for callbacks and email links |
WORKFLOW_ENGINE_ADDRESS |
gRPC endpoint (local, stock, managed) |
WORKFLOW_ENGINE_NAMESPACE |
Namespace |
WORKFLOW_ENGINE_TASK_QUEUE |
Default procureflow |
WORKFLOW_ENGINE_TLS, WORKFLOW_ENGINE_API_KEY |
managed service / mTLS endpoints |
WORKFLOW_ENGINE_SEARCH_ATTRIBUTES |
Enable ProcureFlow search attributes (rework only) |
WORKFLOW_ENGINE_UI_URL |
Optional engine inspector URL for operators |
PROCUREFLOW_AUDIT_INTERCEPTOR_ENABLED |
Enable custom interceptors |
PROCUREFLOW_WORKER_PLUGIN_ENABLED |
Enable adapter Worker Plugin |
EMAIL_*, SLACK_WEBHOOK_URL, ERP_*, STORAGE_*, AI_RISK_SUMMARY_ENABLED |
Integration configuration |
INTERNAL_API_TOKEN |
Bearer token for /api/internal/outbox |
Never commit .env or real secrets. .env.example contains local-only placeholders.
- Single-tenant, role-based approvals. Any active user with the task’s role can act; admins can act on any active step. Requesters cannot approve their own requests. There is no per-department manager mapping in the MVP; policies route by role.
- Amounts are integer cents in the database; the UI accepts dollars and converts without float drift. Currency is display-only (no FX conversion).
- Policy precedence: matching rules merge, roles are de-duplicated and ordered manager → finance → admin. “No approvers” on a matching rule means auto-approve.
- Vendor status matters: a request may target a DRAFT/PENDING vendor (it adds a compliance step), but a BLOCKED vendor is rejected at submission.
- Approval SLAs: one escalation to ADMIN with a fresh 24h deadline; a second expiry rejects the request. Timers are workflow engine timers.
- Changes requested: the workflow waits 30 days for a resubmission signal. If it completes, a later resubmission starts a revision-suffixed deterministic workflow.
- Notifications are best-effort at the workflow boundary: failures are recorded in the outbox and retried by activities; a notification failure does not roll back a PO. The outbox endpoint/workflow can retry failed rows.
- Attachments are metadata-only in the MVP; storage adapter is ready for S3.
- Rate limiting is per-process (in-memory). Use Redis/Upstash for multi-instance production clusters.
- Auth.js v4 credentials + JWT sessions are used for simplicity; session revocation is by JWT expiry (8h) rather than server-side session tables.
- Search attributes are optional and only used when the reworked engine flag is on. The PostgreSQL read model is the source of truth for lists/dashboards.
- CSV ERP sync writes one file per PO; a failed sync marks the PO FAILED and leaves it issued, retryable by finance/ops.
Error: connect ECONNREFUSED 127.0.0.1:5432 — start Postgres
(docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d postgres)
or fix DATABASE_URL.
Cannot find module '@procureflow/...' — run pnpm install at the repo root
(workspace packages are linked through pnpm).
Reworked engine build fails — install Go 1.27+ (go version) or let
GOTOOLCHAIN=auto download it. pnpm engine:build prints the exact command.
Windows users can use the Node-based build/run scripts (pnpm engine:build,
pnpm engine:dev) or WSL/Git Bash for the .sh equivalents.
Port 7233 already in use — another workflow engine dev server is running. Stop it, or
change WORKFLOW_ENGINE_ADDRESS/the dev ports consistently. The reworked config uses
7233/7243 and the stock CLI uses 7233/8233.
Search attribute upsert fails (invalid search attribute) — you are running a
stock server with WORKFLOW_ENGINE_SEARCH_ATTRIBUTES=true but without the ProcureFlow
attributes. Set the flag to false, or run pnpm engine:dev (bootstrap creates
them), or register them through the engine operator tooling.
Workflow query returns null / “state unavailable” — the worker or workflow engine is not
reachable; the DB read model still renders. Check /api/health and the worker log.
Login works but pages redirect to /login — AUTH_SECRET changed between
processes, or the browser clock/cookie domain differs. Restart web/worker with the
same .env.
A different app answers on localhost:3000 — a stale local/WSL Next server can
hold loopback port 3000 while Docker publishes on another interface. The page may
look like ProcureFlow but use a different database, so login fails and the Docker
logs show no auth request.
Check the listener and WSL:
netstat -ano | findstr :3000
wsl --list --runningStop the old dev server, or run the Docker stack on another host port:
WEB_PORT=3001 docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --force-recreate webThen open http://localhost:3001. The dev override automatically points
APP_URL, AUTH_URL, and NEXTAUTH_URL at WEB_PORT.
Demo accounts cannot sign in — the login page exists, but the seed has not run
or the browser is using a different hostname than AUTH_URL. Demo users are created
only by pnpm db:seed.
For Docker:
docker compose exec web pnpm db:seed
docker compose exec postgres psql -U procureflow -d procureflow \
-c 'select email, role, active from users;'For the local dev override, SEED_DEMO_DATA is already enabled:
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --force-recreate webUse http://localhost:3000 consistently. If APP_URL is a public HTTPS domain,
Auth.js may issue secure cookies that the browser will not send to localhost.
Set APP_URL=http://localhost:3000, AUTH_URL=http://localhost:3000, and
NEXTAUTH_URL=http://localhost:3000 for local testing.
Stock engine workflow tests download slowly the first time — the test harness caches the test server under the OS temp directory. Subsequent runs are fast.
Published at https://github.com/olenny-coder/procureflow (public).
git remote -v
git push origin main
git push --tags # publish vendor-base-96a50b1 for the CI provenance guardAdd repository secrets for Render, Neon and your engine endpoint; never commit them. CI runs on push and pull requests.
MIT — see LICENSE. The vendored workflow engine tree retains its MIT license
at third_party/temporal/LICENSE.





