feat(procore): Hanzo AI for Procore — RFI/submittal/doc AI over @hanzo/ai

Adds packages/procore (@hanzo/procore): an embedded-app panel + OAuth/API-proxy
service that brings Hanzo AI to Procore construction projects.

- OAuth2 Authorization-Code (login[-sandbox].procore.com) with server-side
  secret + rotating refresh; tokens never reach the browser.
- Pure Procore REST v1.0 wrappers (projects, RFIs, submittals, documents,
  observations, daily logs) with Procore-Company-Id scoping + pagination.
- Pure project-context assembly with honest truncation.
- AI actions over @hanzo/ai (imported, not reimplemented): summarize RFI,
  draft RFI response, extract action items/risks, project status, plus ask.
- Optional explicit RFI reply write-back through the same proxy.
- 102 vitest tests; tsc --noEmit clean; esbuild build green.
- Registers packages/procore in pnpm-workspace.yaml.
This commit is contained in:
Hanzo Dev
2026-07-04 01:30:19 -07:00
parent c62346e94c
commit f52eff1b68
27 changed files with 3360 additions and 0 deletions
+231
View File
@@ -0,0 +1,231 @@
# Hanzo AI for Procore
AI over your Procore construction project — **summarize an RFI**, **draft an RFI
response**, **extract action items and risks**, and **summarize project status**,
plus a freeform "ask about the project" box. An embedded-app **panel** you host at
`procore.hanzo.ai`, backed by a small **OAuth + API-proxy service** that keeps the
Procore client secret and access tokens server-side.
Built on the published Hanzo SDK:
- **[`@hanzo/ai`](https://www.npmjs.com/package/@hanzo/ai)** — the headless client.
`createAiClient({ baseUrl, token }).chat.completions.create({ model, messages })`
and `.models.list()`. All model calls go to `https://api.hanzo.ai` on `/v1/...`
(never an `/api/` prefix). We import it; we do not reimplement the transport.
- **[`@hanzo/iam`](https://www.npmjs.com/package/@hanzo/iam)** — Hanzo identity /
the `hk-…` API key the panel uses as its gateway bearer.
Procore is the construction management system of record: documents, RFIs,
submittals, observations, and daily logs. This app reads those records through
the Procore REST API and grounds every AI answer in them.
---
## Architecture
```
Browser panel (dist/index.html, app.js) Server service (dist/server.js)
───────────────────────────────────── ──────────────────────────────
reads Procore via ──► /proxy/* ──────────► injects OAuth Bearer + refresh,
same-origin proxy (cookie) forwards to api.procore.com with
(never sees a Procore token) the Procore-Company-Id header
└── calls api.hanzo.ai /v1 directly with the pasted hk-… Hanzo key
(@hanzo/ai headless client)
```
- The **panel** never holds a Procore token or the client secret. It reaches
Procore only through the same-origin `/proxy/*` endpoint (with an HttpOnly
session cookie). It talks to `api.hanzo.ai` directly with the user's pasted
Hanzo key.
- The **service** is the only place `PROCORE_CLIENT_SECRET` and the OAuth tokens
exist. It runs the install flow, holds the session, refreshes the (rotating)
refresh token transparently, and proxies REST calls.
### Source layout (all pure logic is unit-tested)
| File | Responsibility |
| --- | --- |
| `src/config.ts` | Hosts (production vs sandbox), REST version, gateway URL, server-secret config (`readServerConfig` fails fast). |
| `src/procore-oauth.ts` | OAuth2 Authorization-Code shaping: `authorizeUrl`, `tokenExchange`, `refreshExchange`, token parsing + expiry math. Pure. |
| `src/procore-api.ts` | REST v1.0 request wrappers (`listRFIs`, `getRFI`, `createReply`, `listSubmittals`, `listDocuments`, `listObservations`, `listDailyLogs`) with company/project scoping + pagination, and response parsers. Pure. |
| `src/project.ts` | RFI/submittal/doc/observation/daily-log JSON → windowed context text + honest truncation. Pure. |
| `src/hanzo.ts` | Thin over `@hanzo/ai`: prompt assembly + the single `ask` path + `listModels`. |
| `src/actions.ts` | The four AI actions (id → prompt) over `ask`. One code path to the model. |
| `src/panel.ts` | Browser proxy-client request shaping + launch-context parsing. Pure. |
| `src/app.ts` | The DOM glue for the panel (the one impure browser entry). |
| `src/server.ts` | Node http service: OAuth install/callback + `/proxy/*`. |
---
## 1. Create a Procore developer app
On the **Procore Developer Portal** (<https://developers.procore.com>):
1. Create a new **app** (a Data Connection app). This gives you a **Client ID**
and **Client Secret** (the *sandbox* credentials first; production credentials
are issued when you promote the app).
2. Add an **OAuth redirect URI** — exactly the URL your service serves the
callback at:
```
https://procore.hanzo.ai/oauth/callback
```
(For local development, add `http://localhost:8790/oauth/callback` too.)
3. Grant the app access to the **tools** it reads in your sandbox company:
Project, **RFIs**, **Submittals**, **Documents**, **Observations**, and
**Daily Log**. Procore permissions are granted per-company / per-tool in the
company's admin — the OAuth grant itself takes no `scope` parameter for a
standard data-connection app.
4. Note your **sandbox company id** — you supply it in the panel and it is sent
as the `Procore-Company-Id` header on every REST call.
### Sandbox vs production
Everything is host-selected by one env value, `PROCORE_ENV`:
| | Login (OAuth) | REST API |
| --- | --- | --- |
| `sandbox` (default) | `https://login-sandbox.procore.com` | `https://sandbox.procore.com` |
| `production` | `https://login.procore.com` | `https://api.procore.com` |
Develop against `sandbox`, flip to `production` only when your app is approved.
---
## 2. OAuth flow
Standard **Authorization Code** grant (server-side secret):
1. `GET /oauth/install` → 302 to
`https://login-sandbox.procore.com/oauth/authorize?response_type=code&client_id=…&redirect_uri=…&state=…`.
2. The user consents; Procore redirects back to `GET /oauth/callback?code=…&state=…`.
3. The service verifies `state`, then POSTs to `/oauth/token` with
`grant_type=authorization_code`, the `code`, the `client_id` + `client_secret`
(**in the body, server-side only**), and the same `redirect_uri`.
4. It opens a session, sets an HttpOnly cookie, and the panel is authenticated.
5. Access tokens are short-lived (~2h). The service refreshes with
`grant_type=refresh_token` **before** each proxied call when the token has
expired. Procore **rotates** the refresh token on every refresh, so the new
one is stored each time.
> In-memory sessions here are for a single instance. For a multi-instance
> deployment behind `hanzoai/ingress`, persist sessions + the OAuth `state` set
> to `hanzoai/kv` (Valkey), and read `PROCORE_CLIENT_SECRET` from KMS
> (`kms.hanzo.ai`) — never from a committed env file.
---
## 3. Procore REST API
All reads/writes go through `https://api.procore.com/rest/v1.0/...` (or the
sandbox host), and **every request carries the `Procore-Company-Id` header**
(company scope). Project-scoped resources take the project id in the path.
| Resource | Endpoint (relative to `/rest/v1.0`) |
| --- | --- |
| Projects | `GET /projects?company_id={id}` |
| RFIs (list) | `GET /projects/{project_id}/rfis` |
| RFI (detail) | `GET /projects/{project_id}/rfis/{id}` |
| **RFI reply** (write) | `POST /projects/{project_id}/rfis/{id}/replies` — body `{ "rfi_reply": { "body": "…" } }` |
| Submittals | `GET /projects/{project_id}/submittals` |
| Documents | `GET /folders_and_files?project_id={id}` |
| Observations | `GET /observations/items?project_id={id}` |
| Daily logs | `GET /projects/{project_id}/daily_logs/notes_logs?log_date=YYYY-MM-DD` |
List endpoints paginate with `page` (1-based) + `per_page` (max 100) and return
the total record count in the **`Total`** response header (surfaced by
`parseTotal`).
---
## 4. The RFI summarize / respond flow
1. Connect the app (`/oauth/install`) so the service holds a Procore token.
2. Open the panel, enter your **company id**, and **Load** a project. The panel
pulls the project's RFIs / submittals / documents / observations through the
proxy and assembles a windowed, truncation-honest context.
3. **Summarize RFI** — the model summarizes the question, replies so far, status,
ball-in-court, and what's outstanding, grounded only in the records.
4. **Draft RFI response** — the model drafts a reply to the open RFI, answering
from the records and stating exactly what's still needed rather than inventing
an answer. The result lands in the Result box.
5. Optional write-back: **Post as RFI reply** posts the Result back to Procore via
`POST /projects/{id}/rfis/{rfi}/replies` — behind an explicit button and a
confirm, targeting the first open RFI on the loaded project.
The other actions — **Action items & risks** (owners + severity-ordered risks
from RFIs/observations/daily logs) and **Project status** (open/overdue RFIs and
submittals, notable observations, schedule/safety notes) — run the same way.
---
## Build & run
```bash
pnpm install
pnpm --filter @hanzo/procore build # → dist/ (panel + server)
pnpm --filter @hanzo/procore test # vitest
pnpm --filter @hanzo/procore typecheck # tsc --noEmit
# run the service
PROCORE_CLIENT_ID=… \
PROCORE_CLIENT_SECRET=… \
PROCORE_REDIRECT_URI=https://procore.hanzo.ai/oauth/callback \
PROCORE_ENV=sandbox \
node packages/procore/dist/server.js
```
### Required environment (service)
| Var | Notes |
| --- | --- |
| `PROCORE_CLIENT_ID` | App client id (public). |
| `PROCORE_CLIENT_SECRET` | **Server only.** From KMS in production. |
| `PROCORE_REDIRECT_URI` | Must match the app's registered redirect. |
| `PROCORE_ENV` | `sandbox` (default) or `production`. |
| `PORT` | Listen port (default `8790`). |
`readServerConfig` throws on a missing secret — the service refuses to start
rather than pretend it can complete an OAuth exchange.
---
## Deploy over hanzoai/ingress
Host the static panel (`dist/index.html`, `app.js`, `styles.css`) with the
**hanzoai/static** plugin and run `dist/server.js` as a small service, both
behind **hanzoai/ingress** at `procore.hanzo.ai` (no nginx, no caddy):
- `/` and the static assets → the panel.
- `/oauth/*`, `/proxy/*`, `/healthz` → the service.
Secrets come from **KMS** as `KMSSecret`-synced env; sessions from **Valkey**
(`hanzoai/kv`) for multi-instance. Image published to
`ghcr.io/hanzoai/procore:<tag>` by CI/CD (platform.hanzo.ai / Tekton) — never
built locally.
---
## Procore Marketplace path
To list on the **Procore App Marketplace**:
1. Build and validate the app in your **sandbox** developer account (the flow
above).
2. Complete the app's Marketplace listing on the Developer Portal (description,
screenshots, the OAuth redirect, the tools/permissions it requests).
3. Submit for Procore's **app review**; on approval you receive **production**
OAuth credentials — set `PROCORE_ENV=production` and swap in the production
`PROCORE_CLIENT_ID` / `PROCORE_CLIENT_SECRET`.
4. Publish. Installing companies grant the app access to their Project / RFIs /
Submittals / Documents tools; the `Procore-Company-Id` header scopes every
request to the installing company.
---
*Routed through `api.hanzo.ai`. Answers are grounded in your Procore project
records — informational coordination support, not an engineering, legal, or
safety directive.*
+86
View File
@@ -0,0 +1,86 @@
// Build @hanzo/procore into dist/: bundle the web panel (app.ts) as ESM for the
// browser, copy index.html (with its entry script stamped) + styles.css, and
// bundle the OAuth + API-proxy server for Node. No framework — esbuild + Node
// stdlib only. @hanzo/ai is bundled into both outputs (it is the headless client
// the panel and server both call).
//
// node build.js → production build
// node build.js --watch → rebuild on change
// HANZO_PROCORE_BASE=https://localhost:8443 node build.js → dev base (informational)
//
// Output layout:
// dist/index.html dist/app.js dist/styles.css (panel)
// dist/server.js (service)
//
// The panel is HOSTED over hanzoai/static behind hanzoai/ingress at
// procore.hanzo.ai; the server runs as a small service (OAuth callback + API
// proxy). All model calls go to api.hanzo.ai regardless of the host base.
import esbuild from 'esbuild';
import { rmSync, mkdirSync, copyFileSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const BASE = (process.env.HANZO_PROCORE_BASE || 'https://procore.hanzo.ai').replace(/\/+$/, '');
const watch = process.argv.includes('--watch');
const src = join(__dirname, 'src');
const dist = join(__dirname, 'dist');
async function build() {
rmSync(dist, { recursive: true, force: true });
mkdirSync(dist, { recursive: true });
// Bundle the panel as ESM for the browser.
const panelCtx = await esbuild.context({
entryPoints: { app: join(src, 'app.ts') },
outdir: dist,
bundle: true,
format: 'esm',
platform: 'browser',
target: ['chrome90', 'edge90', 'firefox90', 'safari15'],
sourcemap: true,
minify: !watch,
logLevel: 'info',
});
await panelCtx.rebuild();
// Copy index.html (stamp __ENTRY__ + base) and styles.css.
const html = readFileSync(join(src, 'index.html'), 'utf8')
.split('__ENTRY__').join('app.js')
.replace('</head>', ` <meta name="hanzo:base" content="${BASE}" />\n</head>`);
writeFileSync(join(dist, 'index.html'), html);
copyFileSync(join(src, 'styles.css'), join(dist, 'styles.css'));
// Bundle the server as a Node ESM binary — our pure stdlib code + @hanzo/ai.
const serverCtx = await esbuild.context({
entryPoints: [join(src, 'server.ts')],
outfile: join(dist, 'server.js'),
bundle: true,
format: 'esm',
platform: 'node',
target: ['node18'],
sourcemap: true,
minify: !watch,
banner: { js: "import { createRequire as __cr } from 'module'; const require = __cr(import.meta.url);" },
logLevel: 'info',
});
await serverCtx.rebuild();
console.log(`Hanzo AI for Procore built -> dist/ (base ${BASE})`);
console.log(' Panel: dist/index.html · Service: dist/server.js');
if (watch) {
await panelCtx.watch();
await serverCtx.watch();
console.log('watching...');
} else {
await panelCtx.dispose();
await serverCtx.dispose();
}
}
build().catch((e) => {
console.error(e);
process.exit(1);
});
+43
View File
@@ -0,0 +1,43 @@
{
"name": "@hanzo/procore",
"version": "0.1.0",
"description": "Hanzo AI for Procore — AI over your construction project: summarize an RFI, draft an RFI response, extract action items and risks, and summarize project status. An embedded-app panel plus an OAuth + API-proxy service, built on @hanzo/ai and @hanzo/iam over the api.hanzo.ai gateway.",
"private": true,
"type": "module",
"scripts": {
"build": "node build.js",
"watch": "node build.js --watch",
"start": "node dist/server.js",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hanzo/ai": "^0.2.0",
"@hanzo/iam": "^0.13.2"
},
"devDependencies": {
"@types/node": "^20.14.0",
"esbuild": "^0.25.8",
"typescript": "^5.8.3",
"vitest": "^3.2.6"
},
"engines": {
"node": ">=18.0.0"
},
"keywords": [
"hanzo",
"procore",
"construction",
"rfi",
"submittal",
"ai",
"oauth"
],
"author": "Hanzo AI",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/hanzoai/extension.git",
"directory": "packages/procore"
}
}
+95
View File
@@ -0,0 +1,95 @@
// The AI actions over a Procore project — summarize an RFI, draft an RFI
// response, extract action items / risks, summarize project status. Each is a
// prompt template applied to the windowed project context via the single `ask`
// primitive in hanzo.ts. There is exactly ONE code path to the model: an action
// is (id → prompt), and the panel and any server-side caller resolve an id here
// and call `ask`. No action speaks to the gateway directly. (A freeform question
// is `ask` directly, not an action — it has no fixed prompt.)
import { ask, type AskOptions, type ProjectMeta } from './hanzo.js';
import type { ProjectContext } from './project.js';
// The action catalog. `id` is the stable key the panel's chips use; `label` is
// the button text; `prompt` is the instruction handed to the model as the task,
// over the fenced project records. Prompts are specific and output-shaped
// (bullets, structured lists) so results are consistent and parseable.
export const ACTIONS = {
summarizeRfi: {
label: 'Summarize RFI',
prompt:
'Summarize the RFI in these records for a busy project engineer. Cover: the ' +
'question being asked and why it matters, any answer or replies so far, the ' +
'current status and who the ball is in court with, and what still needs to ' +
'happen. Reference the RFI number. Use short bullets. No preamble. If more ' +
'than one RFI is shown, summarize each briefly.',
},
draftRfiResponse: {
label: 'Draft RFI response',
prompt:
'Draft a clear, professional response to the open RFI in these records, ' +
'written from the responding party to the asker. Answer the question ' +
'directly using only what the records support; where the records do not ' +
'contain the answer, state exactly what information or decision is still ' +
'needed rather than inventing one. Keep it concise and reference any ' +
'relevant drawing, spec section, or submittal named in the records. Return ' +
'only the response body, ready to paste as an RFI reply.',
},
extractActionItems: {
label: 'Action items & risks',
prompt:
'Read these project records (RFIs, observations, daily logs) and extract two ' +
'lists. First, "Action items": concrete follow-ups, each as a single line ' +
'with the owner (ball-in-court or author) when the records name one and a ' +
'due date when present. Second, "Risks": schedule, cost, safety, or quality ' +
'risks the records surface, ordered by severity (highest first), each with a ' +
'one-line explanation grounded in the record it comes from. If a list is ' +
'empty, say so. Do not invent items not in the records.',
},
projectStatus: {
label: 'Project status',
prompt:
'Write a short project-status summary for a superintendent or PM from these ' +
'records. Cover: open vs closed RFIs and any that are overdue or waiting on ' +
'the team; submittals in review or overdue; notable observations; and ' +
'anything from the daily logs that affects schedule or safety. Structure as ' +
'a one-line headline followed by grouped bullets. Base every statement on ' +
'the records; note plainly if the records look incomplete.',
},
} as const;
// An action id from the catalog.
export type ActionId = keyof typeof ACTIONS;
// isActionId narrows an arbitrary string to a known action id. Boundary guard —
// the panel and the server validate an inbound id here before running it.
export function isActionId(id: string): id is ActionId {
return Object.prototype.hasOwnProperty.call(ACTIONS, id);
}
// actionPrompt resolves an action id to its prompt template. Throws on an unknown
// id (a boundary error, surfaced to the caller) rather than silently running a
// default. Pure.
export function actionPrompt(id: string): string {
if (!isActionId(id)) throw new Error(`Unknown action: ${id}`);
return ACTIONS[id].prompt;
}
// actionList is the ordered catalog for building the UI (chips) — id + label,
// derived from ACTIONS so the panel and the catalog can never drift. Pure.
export function actionList(): Array<{ id: ActionId; label: string }> {
return (Object.keys(ACTIONS) as ActionId[]).map((id) => ({ id, label: ACTIONS[id].label }));
}
// runAction is the single entry point every surface calls: resolve the action's
// prompt and run it over the windowed project context via `ask`. This is the one
// code path from an action id to the model. Async so an unknown id surfaces as a
// rejected promise (not a synchronous throw), giving callers ONE way to handle
// failure: `await`/`.catch`. A gateway error rejects via `ask`.
export async function runAction(
id: string,
ctx: ProjectContext,
meta?: ProjectMeta,
opts: AskOptions = {},
): Promise<string> {
return ask(actionPrompt(id), ctx, meta, opts);
}
+298
View File
@@ -0,0 +1,298 @@
// The Procore panel glue: the embedded/linked-app page that opens with a company
// (and optionally a project) in context and drives the AI actions over the
// project's live records. All the logic-heavy work (action prompts, context
// windowing, chat shaping, proxy request shaping, auth) lives in its own tested
// modules; this file binds them to the DOM. It is the one impure, browser-only
// entry point.
//
// Data flow: the panel reads Procore ONLY through the same-origin server proxy
// (createProxyClient), which holds the OAuth token server-side. It pulls the
// project's RFIs / submittals / documents / observations, assembles a windowed
// context (buildProjectContext), and runs an action or a freeform question
// against api.hanzo.ai with the pasted Hanzo key. The optional RFI reply
// write-back posts back through the same proxy, behind an explicit button.
import { actionList, isActionId, runAction } from './actions.js';
import { ask as askHanzo, listModels, type ProjectMeta } from './hanzo.js';
import {
buildProjectContext,
contextNote,
rfiSection,
submittalSection,
documentSection,
observationSection,
type ProjectContext,
} from './project.js';
import { DEFAULT_MODEL } from './config.js';
import { getApiKey, setApiKey, hasApiKey, bearer, validateKey } from './auth.js';
import { createProxyClient, parseLaunchContext, type ProxyClient } from './panel.js';
import type { ProjectSummary, Rfi } from './procore-api.js';
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
let controller: AbortController | null = null;
window.addEventListener('DOMContentLoaded', () => {
const launch = parseLaunchContext(window.location.search);
const companyEl = $<HTMLInputElement>('company');
const projectEl = $<HTMLSelectElement>('project');
const loadBtn = $<HTMLButtonElement>('load');
const recordsEl = $('records');
const outputEl = $<HTMLTextAreaElement>('output');
const statusEl = $('status');
const modelEl = $<HTMLSelectElement>('model');
const chipRow = $('chips');
const runBtn = $<HTMLButtonElement>('run');
const stopBtn = $<HTMLButtonElement>('stop');
const promptEl = $<HTMLTextAreaElement>('prompt');
const apiKeyEl = $<HTMLInputElement>('apikey');
const saveKeyBtn = $<HTMLButtonElement>('savekey');
const authHint = $('authhint');
const replyBtn = $<HTMLButtonElement>('reply');
companyEl.value = launch.companyId;
apiKeyEl.value = getApiKey();
reflectAuth();
void populateModels();
// Loaded project state: the records that make up the current context, plus the
// project we're scoped to (for the reply write-back).
let ctx: ProjectContext | null = null;
let meta: ProjectMeta = {};
let currentProjectId = '';
let firstOpenRfi: Rfi | null = null;
// Action chips — derived from the catalog so UI and logic never drift.
for (const a of actionList()) {
const b = document.createElement('button');
b.className = 'chip';
b.textContent = a.label;
b.dataset.action = a.id;
b.onclick = () => void run(a.id);
chipRow.appendChild(b);
}
// client builds a proxy client scoped to the current company. Rebuilt on demand
// so a company change takes effect immediately.
function client(): ProxyClient {
return createProxyClient({ companyId: companyEl.value.trim() });
}
loadBtn.onclick = () => void loadProject();
projectEl.onchange = () => {
currentProjectId = projectEl.value;
};
companyEl.onchange = () => void populateProjects();
runBtn.onclick = () => {
const prompt = promptEl.value.trim();
if (prompt) void ask(prompt);
};
stopBtn.onclick = () => controller?.abort();
replyBtn.onclick = () => void postReply();
saveKeyBtn.onclick = async () => {
const key = apiKeyEl.value.trim();
setStatus('Checking key…');
try {
const models = await validateKey(key);
setApiKey(key);
fillModels(models);
reflectAuth();
setStatus('Key saved.', 'ok');
} catch (e: any) {
setStatus(e?.message || 'Key rejected.', 'error');
}
};
// If we launched with a company, list its projects; if we also launched with a
// project, load it straight away.
if (launch.companyId) {
void populateProjects().then(() => {
if (launch.projectId) {
projectEl.value = launch.projectId;
currentProjectId = launch.projectId;
void loadProject();
}
});
}
// populateProjects fills the project picker from the proxy for the current
// company. A company with no id (or an auth failure) leaves the picker empty
// with a status hint.
async function populateProjects(): Promise<void> {
const company = companyEl.value.trim();
if (!company) {
setStatus('Enter your Procore company id to list projects.', 'warn');
return;
}
setStatus('Loading projects…');
try {
const { items } = await client().listProjects({ perPage: 100 });
fillProjects(items);
setStatus(items.length ? `Loaded ${items.length} projects.` : 'No projects visible.', items.length ? 'ok' : 'warn');
} catch (e: any) {
setStatus(e?.message || 'Could not load projects — is the app connected? (Connect on procore.hanzo.ai)', 'error');
}
}
// loadProject pulls the scoped project's records and assembles the context the
// actions run over. RFIs and submittals and documents and observations are
// fetched in parallel; each failure is tolerated so a project missing one tool
// still yields a usable context.
async function loadProject(): Promise<void> {
const projectId = projectEl.value || currentProjectId;
if (!projectId) {
setStatus('Pick a project first.', 'warn');
return;
}
currentProjectId = projectId;
setStatus('Loading project records…');
const c = client();
const [rfis, submittals, documents, observations] = await Promise.all([
c.listRFIs(projectId, { perPage: 100 }).then((r) => r.items).catch(() => [] as Rfi[]),
c.listSubmittals(projectId, { perPage: 100 }).then((r) => r.items).catch(() => []),
c.listDocuments(projectId, { perPage: 100 }).then((r) => r.items).catch(() => []),
c.listObservations(projectId, { perPage: 100 }).then((r) => r.items).catch(() => []),
]);
firstOpenRfi = rfis.find((r) => r.status.toLowerCase() !== 'closed') ?? rfis[0] ?? null;
ctx = buildProjectContext([
rfiSection(rfis),
submittalSection(submittals),
documentSection(documents),
observationSection(observations),
]);
const chosen = projectSummary(projectId);
meta = { name: chosen?.displayName || undefined };
recordsEl.textContent =
`${rfis.length} RFIs · ${submittals.length} submittals · ${documents.length} documents · ${observations.length} observations`;
replyBtn.disabled = !firstOpenRfi;
replyBtn.title = firstOpenRfi
? `Post the result as a reply to RFI ${firstOpenRfi.number || firstOpenRfi.id}`
: 'No open RFI to reply to';
setStatus(contextNote(ctx));
}
// run executes one of the named actions over the loaded project context.
async function run(actionId: string): Promise<void> {
if (!isActionId(actionId)) return;
await execute((c, m, opts) => runAction(actionId, c, m, opts));
}
// ask executes a freeform question over the loaded project context.
async function ask(prompt: string): Promise<void> {
await execute((c, m, opts) => askHanzo(prompt, c, m, opts));
}
// execute is the shared runner: require a loaded context, call the model, show
// the result. Both the chips and freeform ask funnel through here (one path).
async function execute(
call: (
c: ProjectContext,
m: ProjectMeta,
opts: { token: string; model: string; signal: AbortSignal },
) => Promise<string>,
): Promise<void> {
if (!ctx || ctx.totalBlocks === 0) {
setStatus('Load a project first (pick one and press Load).', 'warn');
return;
}
controller?.abort();
controller = new AbortController();
setBusy(true);
setStatus(contextNote(ctx));
outputEl.value = '';
try {
const text = await call(ctx, meta, {
token: bearer(),
model: modelEl.value || DEFAULT_MODEL,
signal: controller.signal,
});
outputEl.value = text;
setStatus('Done.', 'ok');
} catch (e: any) {
if (e?.name === 'AbortError') setStatus('Stopped.');
else setStatus(e?.message || 'Request failed.', 'error');
} finally {
setBusy(false);
}
}
// postReply writes the current output back to Procore as a reply on the first
// open RFI — the explicit, documented write-back. Guarded: it never posts an
// empty body and always confirms which RFI it targeted.
async function postReply(): Promise<void> {
const body = outputEl.value.trim();
if (!firstOpenRfi) return setStatus('No open RFI to reply to.', 'warn');
if (!body) return setStatus('Nothing to post — run an action or write a reply first.', 'warn');
if (!confirm(`Post this as a reply to RFI ${firstOpenRfi.number || firstOpenRfi.id}?`)) return;
setBusy(true);
setStatus('Posting reply to Procore…');
try {
await client().createReply(currentProjectId, firstOpenRfi.id, body);
setStatus(`Reply posted to RFI ${firstOpenRfi.number || firstOpenRfi.id}.`, 'ok');
} catch (e: any) {
setStatus(e?.message || 'Reply failed.', 'error');
} finally {
setBusy(false);
}
}
// ---- small DOM helpers ----------------------------------------------------
let projects: ProjectSummary[] = [];
function projectSummary(id: string): ProjectSummary | undefined {
return projects.find((p) => String(p.id) === String(id));
}
function fillProjects(items: ProjectSummary[]): void {
projects = items;
projectEl.innerHTML = '';
for (const p of items) {
const opt = document.createElement('option');
opt.value = String(p.id);
opt.textContent = p.displayName || p.name;
projectEl.appendChild(opt);
}
}
async function populateModels(): Promise<void> {
try {
fillModels(await listModels({ token: bearer() }));
} catch {
fillModels([DEFAULT_MODEL]);
}
}
function fillModels(ids: string[]): void {
const list = ids.length ? ids : [DEFAULT_MODEL];
modelEl.innerHTML = '';
for (const id of list) {
const opt = document.createElement('option');
opt.value = id;
opt.textContent = id;
modelEl.appendChild(opt);
}
if (list.includes(DEFAULT_MODEL)) modelEl.value = DEFAULT_MODEL;
}
function reflectAuth(): void {
authHint.textContent = hasApiKey()
? 'Using your saved Hanzo key.'
: 'No key saved — using public models. Paste an hk-… key for your org models.';
}
function setBusy(b: boolean): void {
runBtn.disabled = b;
stopBtn.disabled = !b;
loadBtn.disabled = b;
for (const c of Array.from(chipRow.querySelectorAll('button'))) (c as HTMLButtonElement).disabled = b;
replyBtn.disabled = b || !firstOpenRfi;
}
function setStatus(msg: string, kind: '' | 'ok' | 'warn' | 'error' = ''): void {
statusEl.textContent = msg;
statusEl.className = `status${kind ? ' ' + kind : ''}`;
}
});
+56
View File
@@ -0,0 +1,56 @@
// Auth for the web panel: the zero-setup pasted-key path for the Hanzo gateway.
// The Procore panel is a static web page (iframe), so the Hanzo credential lives
// in localStorage and is validated by a real /v1/models call — a key that can't
// list models is rejected before it's saved, so the user learns at paste time,
// not at first action. Mirrors @hanzo/docusign exactly (one way to hold a key).
//
// The Procore access token (for reading projects/RFIs/documents) is a SEPARATE
// credential minted server-side by the OAuth flow and held by server.ts; the
// panel reaches Procore only through the server proxy (with its session cookie),
// so it never holds the Procore token or secret. This module is only the
// Hanzo-gateway bearer.
import { APIKEY_STORAGE_KEY, pickBearer } from './config.js';
import { listModels } from './hanzo.js';
export function getApiKey(): string {
try {
return localStorage.getItem(APIKEY_STORAGE_KEY) || '';
} catch {
return '';
}
}
export function setApiKey(key: string): void {
try {
const k = key.trim();
if (k) localStorage.setItem(APIKEY_STORAGE_KEY, k);
else localStorage.removeItem(APIKEY_STORAGE_KEY);
} catch {
/* private-mode / storage disabled — the in-memory key still works this session */
}
}
export function clearApiKey(): void {
setApiKey('');
}
export function hasApiKey(): boolean {
return !!getApiKey();
}
// bearer is the credential the chat call sends: the pasted key (or empty for
// anonymous public models). When Hanzo OAuth lands it slots in as the second
// argument to pickBearer with no change to callers.
export function bearer(): string {
return pickBearer(getApiKey(), '');
}
// validateKey confirms a pasted key actually works by listing models with it.
// Returns the models on success so the caller populates the picker in one
// round-trip; throws with the gateway's message on failure.
export async function validateKey(key: string): Promise<string[]> {
const k = key.trim();
if (!k) throw new Error('Enter a Hanzo API key (hk-…).');
return listModels({ token: k });
}
+155
View File
@@ -0,0 +1,155 @@
// Procore config — the two host sets (production vs sandbox), the Procore REST
// API version segment, the api.hanzo.ai model gateway, and the server-side
// secret set (Procore OAuth). Endpoints and the bearer choice mirror
// @hanzo/docusign and @hanzo/clio so the productivity suite stays DRY; the
// construction-specific pieces (project-context windowing, the AI actions) live
// in project.ts / hanzo.ts / actions.ts, not here.
// ---- Hanzo model gateway --------------------------------------------------
// Where the Hanzo model gateway lives. `@hanzo/ai` defaults here too. /v1 only,
// never an /api/ prefix (api.hanzo.ai IS the api host).
export const HANZO_API_BASE_URL = 'https://api.hanzo.ai';
// Default model. A Zen model (qwen3+). Overridable per-request via the picker;
// the gateway routes it.
export const DEFAULT_MODEL = 'zen5';
// Public IAM origin that mints Hanzo user tokens, and the OAuth client id an
// inbound Hanzo token is audienced to (owner-scoping validation via @hanzo/iam).
export const DEFAULT_IAM_SERVER_URL = 'https://hanzo.id';
export const DEFAULT_IAM_CLIENT_ID = 'hanzo-procore';
// localStorage key for the pasted Hanzo API key (`hk-…`) in the web panel. The
// panel is a static web page (iframe), not a host with roamingSettings, so the
// zero-setup credential lives in localStorage — same as @hanzo/docusign.
export const APIKEY_STORAGE_KEY = 'hanzo.procore.apiKey';
// Project text budget. A busy project has many RFIs, submittals, and daily-log
// entries; the whole corpus won't fit a model window and shouldn't be sent.
// This caps the characters of project text we attach to any one request — chosen
// so it fits comfortably inside a modern context window alongside the reply, and
// is honest rather than optimal (we truncate visibly, never drop silently).
export const PROJECT_CHAR_BUDGET = 60_000;
// pickBearer chooses the credential to send to the Hanzo gateway: a pasted API
// key wins over an OAuth token (an explicit key is a deliberate override), else
// the token, else empty (anonymous — the gateway still serves public models).
// Pure — unit-tested.
export function pickBearer(apiKey: string, oauthToken: string): string {
return (apiKey && apiKey.trim()) || (oauthToken && oauthToken.trim()) || '';
}
// chatCompletionsURL / modelsURL — the model gateway endpoints.
export function chatCompletionsURL(): string {
return `${HANZO_API_BASE_URL}/v1/chat/completions`;
}
export function modelsURL(): string {
return `${HANZO_API_BASE_URL}/v1/models`;
}
// ---- Procore OAuth + API hosts --------------------------------------------
//
// Procore has two environments, each a fixed triple of hosts: the OAuth login
// host (authorize + token), the REST API host, and the app host. We never
// hard-code a single host inline — every URL is built from the selected
// environment's host set, so switching sandbox↔production is one env value.
//
// production: login.procore.com api.procore.com app.procore.com
// sandbox: login-sandbox.procore.com sandbox.procore.com (per-company)
//
// Docs: developers.procore.com/documentation/oauth-introduction and
// developers.procore.com/reference/rest/docs/rest-api-overview.
export interface ProcoreHosts {
/** OAuth login host — /oauth/authorize and /oauth/token live here. */
login: string;
/** REST API host — /rest/v1.0/... lives here. */
api: string;
}
export const PROCORE_HOSTS = {
production: {
login: 'https://login.procore.com',
api: 'https://api.procore.com',
},
sandbox: {
login: 'https://login-sandbox.procore.com',
api: 'https://sandbox.procore.com',
},
} as const satisfies Record<string, ProcoreHosts>;
// Which environment is a Procore env value.
export type ProcoreEnv = keyof typeof PROCORE_HOSTS;
// hosts resolves the host set for an environment.
export function hosts(env: ProcoreEnv): ProcoreHosts {
return PROCORE_HOSTS[env];
}
// The REST API version segment. v1.0 is current for the resources we read
// (projects, RFIs, submittals, documents, observations, daily logs); we never
// bump to a speculative v1.1 — one version, forward only.
export const REST_API_VERSION = 'v1.0';
// apiBaseUrl turns an environment into the REST root: `${api}/rest/v1.0`. Every
// Procore API call is built on top of this. Pure — the request wrappers in
// procore-api.ts take this string.
export function apiBaseUrl(env: ProcoreEnv): string {
return `${hosts(env).api}/rest/${REST_API_VERSION}`;
}
// isProcoreEnv narrows an arbitrary string to a ProcoreEnv, defaulting unknown
// values to 'sandbox' (the safe, non-production environment). Boundary guard.
export function isProcoreEnv(v: string | undefined): v is ProcoreEnv {
return v === 'production' || v === 'sandbox';
}
// The OAuth scopes the app requests. Procore's Authorization Code grant does not
// take a scope parameter for standard data-connection apps (permissions are
// granted per-company/tool in the Procore admin), but the account is included
// for completeness and forward-compat with fine-grained scopes.
export const OAUTH_SCOPES = [] as const;
// ---- Server-side configuration (Procore OAuth) ----------------------------
//
// These are read from the environment by src/server.ts. They NEVER reach the
// browser bundle: the client id is public, but the client secret is server-only
// and is validated to be present before the server will start (readServerConfig
// throws on a missing secret). This is the ONLY place the secret exists.
export interface ServerConfig {
/** Procore OAuth client id (public). */
procoreClientId: string;
/** Procore OAuth client secret (SERVER ONLY — token exchange + refresh). */
procoreClientSecret: string;
/** OAuth redirect registered on the app (e.g. https://procore.hanzo.ai/oauth/callback). */
procoreRedirectUri: string;
/** Procore environment: 'sandbox' (login-sandbox) or 'production' (login). */
procoreEnv: ProcoreEnv;
/** Listen port. */
port: number;
}
// readServerConfig fails fast (throws) if a required Procore secret is missing —
// a server that cannot exchange OAuth codes must not pretend to start. Pure given
// an env map, so it is unit-tested without touching process.env.
export function readServerConfig(env: Record<string, string | undefined>): ServerConfig {
const procoreClientId = env.PROCORE_CLIENT_ID;
const procoreClientSecret = env.PROCORE_CLIENT_SECRET;
const procoreRedirectUri = env.PROCORE_REDIRECT_URI;
const missing: string[] = [];
if (!procoreClientId) missing.push('PROCORE_CLIENT_ID');
if (!procoreClientSecret) missing.push('PROCORE_CLIENT_SECRET');
if (!procoreRedirectUri) missing.push('PROCORE_REDIRECT_URI');
if (missing.length > 0) {
throw new Error(`Missing required environment: ${missing.join(', ')}`);
}
return {
procoreClientId: procoreClientId!,
procoreClientSecret: procoreClientSecret!,
procoreRedirectUri: procoreRedirectUri!,
procoreEnv: isProcoreEnv(env.PROCORE_ENV) ? env.PROCORE_ENV : 'sandbox',
port: Number(env.PORT) || 8790,
};
}
+147
View File
@@ -0,0 +1,147 @@
// The Hanzo call and its request/response shaping over a PROJECT — thin over the
// published `@hanzo/ai` headless client, host-agnostic, and fully unit-testable
// (no Procore SDK, no DOM). The panel and the server are the glue that read a
// project's records and hand them here. Speaks the OpenAI-compatible /v1 wire
// protocol the whole Hanzo suite uses (identical to @hanzo/docusign) so the
// gateway sees one shape from every surface.
//
// @hanzo/ai@^0.2.0 IS the headless client: createAiClient({ baseUrl, token })
// → .chat.completions.create({ model, messages }) + .models.list(). We import it
// and do NOT reimplement the transport. This module adds only the project-aware
// prompt assembly + the single `ask` primitive the actions layer on.
import {
createAiClient,
type AiClient,
type ChatCompletion,
type ChatCompletionMessage,
} from '@hanzo/ai';
import { DEFAULT_MODEL, HANZO_API_BASE_URL } from './config.js';
import { contextNote, type ProjectContext } from './project.js';
// SYSTEM_PROMPT grounds every answer in the project records. It forbids inventing
// facts nobody recorded (the failure mode that makes a construction assistant
// dangerous on-site), asks for the RFI/submittal number when the text carries it,
// and stays honest about truncated context. It states plainly that the output is
// informational, not a construction/engineering directive — a coordination tool
// that pretends otherwise is a liability.
export const SYSTEM_PROMPT =
'You are Hanzo AI, an assistant that helps construction teams work with their ' +
'Procore project — RFIs, submittals, documents, observations, and daily logs. ' +
'Work ONLY from the project records provided below — never invent RFIs, ' +
'submittals, dates, parties, or facts that are not supported by the text. Cite ' +
'the RFI or submittal number when the text carries it. Be concise, precise, and ' +
'neutral. If the records are marked truncated and a complete answer needs the ' +
'omitted part, say so plainly rather than guessing. You provide informational ' +
'coordination support, not engineering, legal, or safety directives.';
// ---- Prompt assembly ------------------------------------------------------
// Optional structured context about the project (name, number) the Procore API
// supplies. Attached as a short header above the records so answers can reference
// the project without the model guessing.
export interface ProjectMeta {
name?: string;
projectNumber?: string;
companyName?: string;
}
function projectHeader(meta: ProjectMeta | undefined): string {
if (!meta) return '';
const parts: string[] = [];
if (meta.name) parts.push(`Project: ${meta.name}`);
if (meta.projectNumber) parts.push(`Project #: ${meta.projectNumber}`);
if (meta.companyName) parts.push(`Company: ${meta.companyName}`);
return parts.length > 0 ? parts.join('\n') + '\n\n' : '';
}
// buildMessages turns a task (the user's question, or one of the action prompts)
// plus the windowed project context + optional metadata into the
// OpenAI-compatible message list. The records are fenced so the model treats them
// as data, not instructions, and the honest context note rides inside the user
// turn so it is never lost. Pure — unit-tested.
export function buildMessages(
task: string,
ctx: ProjectContext,
meta?: ProjectMeta,
): ChatCompletionMessage[] {
const system: ChatCompletionMessage = { role: 'system', content: SYSTEM_PROMPT };
const header = projectHeader(meta);
const user: ChatCompletionMessage = {
role: 'user',
content:
`${header}${contextNote(ctx)}\n\n` +
`---- project records ----\n${ctx.text}\n---- end project records ----\n\n` +
`---- task ----\n${task}`,
};
return [system, user];
}
// extractContent pulls the assistant text out of a @hanzo/ai ChatCompletion,
// tolerating the string-or-parts content shape the OpenAI schema allows. Throws
// on empty content so callers surface the real gateway state. Pure — unit-tested.
export function extractContent(res: ChatCompletion): string {
const msg = res?.choices?.[0]?.message;
const content = msg?.content;
let text = '';
if (typeof content === 'string') {
text = content;
} else if (Array.isArray(content)) {
text = content
.map((part) => (part?.type === 'text' ? part.text : ''))
.join('');
}
if (text.length === 0) throw new Error('Hanzo API returned no content');
return text;
}
// ---- The single call path -------------------------------------------------
export interface AskOptions {
model?: string;
temperature?: number;
token?: string;
baseURL?: string;
/** Injected client (tests). Defaults to a real createAiClient. */
client?: AiClient;
signal?: AbortSignal;
}
// client resolves the @hanzo/ai client for a call: an injected one (tests) or a
// fresh createAiClient pointed at the gateway with the caller's bearer.
function client(opts: AskOptions): AiClient {
return (
opts.client ??
createAiClient({ token: opts.token, baseUrl: opts.baseURL ?? HANZO_API_BASE_URL })
);
}
// ask runs ONE non-streaming completion over a project and returns the answer.
// This is the single path from every Procore surface to the model — the actions,
// a freeform question, and any server-side call all funnel here. token may be
// empty (the gateway serves anonymous/limited models).
export async function ask(
task: string,
ctx: ProjectContext,
meta: ProjectMeta | undefined,
opts: AskOptions = {},
): Promise<string> {
const res = await client(opts).chat.completions.create(
{
model: opts.model ?? DEFAULT_MODEL,
messages: buildMessages(task, ctx, meta),
stream: false,
...(opts.temperature !== undefined ? { temperature: opts.temperature } : {}),
},
{ signal: opts.signal },
);
return extractContent(res);
}
// listModels returns the model ids the caller may route to, from /v1/models via
// the headless client. The endpoint is org-scoped by the bearer; an empty token
// lists public models.
export async function listModels(opts: AskOptions = {}): Promise<string[]> {
const models = await client(opts).models.list({ signal: opts.signal });
return models.map((m) => m.id);
}
+54
View File
@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Hanzo AI for Procore</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header>
<span class="title">Hanzo AI</span>
<select id="model" aria-label="Model"></select>
<span class="host">Procore</span>
</header>
<div class="row scope">
<input id="company" placeholder="Company id" aria-label="Procore company id" />
<select id="project" aria-label="Project"></select>
<button id="load" class="secondary">Load</button>
</div>
<div class="records" id="records"></div>
<!-- One-click actions over the loaded project. Chips are built from the catalog. -->
<div class="chips" id="chips"></div>
<label for="prompt">Ask about this project</label>
<textarea id="prompt" placeholder="e.g. Which RFIs are overdue and waiting on us? · Summarize open submittals for the concrete package. · What did the daily logs flag this week?"></textarea>
<div class="row">
<button id="run">Ask Hanzo</button>
<button id="stop" class="secondary" disabled>Stop</button>
<span class="spacer"></span>
<button id="reply" class="secondary" disabled title="No open RFI to reply to">Post as RFI reply</button>
</div>
<details>
<summary>Hanzo API key</summary>
<div class="row">
<input id="apikey" type="password" placeholder="hk-…" autocomplete="off" />
<button id="savekey" class="secondary">Save</button>
</div>
<div id="authhint" class="hint"></div>
</details>
<div class="status" id="status"></div>
<label for="output">Result</label>
<textarea id="output" placeholder="Hanzo's analysis appears here."></textarea>
<footer>Routed through api.hanzo.ai · grounded in your Procore project records · informational coordination support, not an engineering directive.</footer>
<script type="module" src="__ENTRY__"></script>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
// Public surface of @hanzo/procore: the pure, host-agnostic modules. The browser
// panel (app.ts) and the Node service (server.ts) are entry points, not
// re-exported here. A consumer embedding the project-analysis pipeline in their
// own service imports from here.
export * from './config.js';
export * from './procore-oauth.js';
export * from './procore-api.js';
export * from './project.js';
export * from './hanzo.js';
export * from './actions.js';
+161
View File
@@ -0,0 +1,161 @@
// Browser-side panel helpers — PURE where it counts (launch-context parsing +
// proxy-request shaping), so the impure DOM wiring in app.ts stays thin and the
// logic is unit-tested. The panel reaches Procore ONLY through the server proxy
// (config: same-origin /proxy/*), which injects the OAuth Bearer + refreshes it;
// the browser never holds a Procore token.
import {
parseProjects,
parseRFIs,
parseRFI,
parseDocuments,
parseSubmittals,
parseObservations,
parseDailyLogs,
parseTotal,
pageParams,
type ProjectSummary,
type Rfi,
type DocumentEntry,
type Submittal,
type Observation,
type DailyLogNote,
type Page,
} from './procore-api.js';
// The Procore launch context: when Procore opens an embedded/linked app it passes
// the company and (for a project-tool app) the project in the URL query. The
// panel reads them so it opens already scoped. Both are optional — without them
// the user picks from the project list.
export interface LaunchContext {
companyId: string;
projectId: string;
}
// parseLaunchContext reads company_id / project_id from a location search string.
// Accepts Procore's snake_case keys. Pure — unit-tested with plain strings.
export function parseLaunchContext(search: string): LaunchContext {
const q = new URLSearchParams(search);
return {
companyId: q.get('company_id') ?? q.get('companyId') ?? '',
projectId: q.get('project_id') ?? q.get('projectId') ?? '',
};
}
// The panel's Procore client: reads through the same-origin server proxy. Every
// call carries the company id in the `Procore-Company-Id` header (the proxy
// forwards it to Procore) and rides the session cookie (credentials: 'include').
// Pure over an injected fetch + base, so request shaping is unit-testable.
export interface ProxyClient {
listProjects(page?: Page): Promise<{ items: ProjectSummary[]; total: number }>;
listRFIs(projectId: number | string, page?: Page): Promise<{ items: Rfi[]; total: number }>;
getRFI(projectId: number | string, rfiId: number | string): Promise<Rfi>;
listSubmittals(projectId: number | string, page?: Page): Promise<{ items: Submittal[]; total: number }>;
listDocuments(projectId: number | string, page?: Page): Promise<{ items: DocumentEntry[]; total: number }>;
listObservations(projectId: number | string, page?: Page): Promise<{ items: Observation[]; total: number }>;
listDailyLogs(projectId: number | string, logDate: string, page?: Page): Promise<{ items: DailyLogNote[]; total: number }>;
createReply(projectId: number | string, rfiId: number | string, body: string): Promise<void>;
}
export interface ProxyClientOptions {
/** Company scope — sent as the Procore-Company-Id header on every request. */
companyId: string;
/** Proxy base (defaults to same-origin ''). The REST path is appended after /proxy. */
base?: string;
/** Injected fetch (tests). Defaults to global fetch. */
fetch?: typeof fetch;
}
// proxyUrl builds a same-origin proxy URL for a REST path + query. Normalizes the
// base (drops a trailing slash) so this is the ONE place base+path joining
// happens. Kept small and pure so tests assert the exact shape
// (…/proxy/projects?company_id=…&page=…).
export function proxyUrl(base: string, restPath: string, query: Record<string, string> = {}): string {
const q = new URLSearchParams();
for (const [k, v] of Object.entries(query)) if (v !== undefined && v !== '') q.set(k, v);
const qs = q.toString();
return `${base.replace(/\/+$/, '')}/proxy${restPath}${qs ? `?${qs}` : ''}`;
}
// createProxyClient returns a ProxyClient bound to a company scope. The company
// header and cookie credentials are attached once here so every method stays a
// one-liner and the scoping is impossible to forget.
export function createProxyClient(opts: ProxyClientOptions): ProxyClient {
const base = opts.base ?? ''; // proxyUrl normalizes the trailing slash
const doFetch = opts.fetch ?? fetch;
const companyId = opts.companyId;
const commonHeaders = { 'Procore-Company-Id': companyId };
async function getJson(restPath: string, query: Record<string, string>): Promise<{ data: any; total: number }> {
const resp = await doFetch(proxyUrl(base, restPath, query), {
headers: commonHeaders,
credentials: 'include',
});
if (!resp.ok) throw new Error(await proxyError(resp));
return { data: await resp.json(), total: parseTotal(resp.headers) };
}
return {
async listProjects(page?: Page) {
const { data, total } = await getJson('/projects', { company_id: companyId, ...pageParams(page) });
return { items: parseProjects(data), total };
},
async listRFIs(projectId, page?) {
const { data, total } = await getJson(`/projects/${enc(projectId)}/rfis`, pageParams(page));
return { items: parseRFIs(data), total };
},
async getRFI(projectId, rfiId) {
const { data } = await getJson(`/projects/${enc(projectId)}/rfis/${enc(rfiId)}`, {});
return parseRFI(data);
},
async listSubmittals(projectId, page?) {
const { data, total } = await getJson(`/projects/${enc(projectId)}/submittals`, pageParams(page));
return { items: parseSubmittals(data), total };
},
async listDocuments(projectId, page?) {
const { data, total } = await getJson('/folders_and_files', {
project_id: String(projectId),
...pageParams(page),
});
return { items: parseDocuments(data), total };
},
async listObservations(projectId, page?) {
const { data, total } = await getJson('/observations/items', {
project_id: String(projectId),
...pageParams(page),
});
return { items: parseObservations(data), total };
},
async listDailyLogs(projectId, logDate, page?) {
const { data, total } = await getJson(`/projects/${enc(projectId)}/daily_logs/notes_logs`, {
log_date: logDate,
...pageParams(page),
});
return { items: parseDailyLogs(data), total };
},
async createReply(projectId, rfiId, body) {
const resp = await doFetch(proxyUrl(base, `/projects/${enc(projectId)}/rfis/${enc(rfiId)}/replies`), {
method: 'POST',
headers: { ...commonHeaders, 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ rfi_reply: { body } }),
});
if (!resp.ok) throw new Error(await proxyError(resp));
},
};
}
// proxyError extracts a readable message from a non-2xx proxy response.
async function proxyError(resp: Response): Promise<string> {
const text = await resp.text().catch(() => '');
try {
const j = JSON.parse(text);
return `Procore proxy ${resp.status}: ${j?.error || j?.message || text.slice(0, 200)}`;
} catch {
return `Procore proxy ${resp.status}: ${text.slice(0, 200) || 'request failed'}`;
}
}
function enc(segment: number | string): string {
return encodeURIComponent(String(segment));
}
+418
View File
@@ -0,0 +1,418 @@
// Procore REST API v1.0 — pure request shaping + response parsing. Every function
// returns a PreparedRequest (url + headers [+ body]) or parses a response body;
// none opens a socket, so the whole API surface is unit-testable without a
// network. server.ts is the thin glue that fetches these shapes with the OAuth
// Bearer access token.
//
// The REST root is `${api}/rest/v1.0` (see config.apiBaseUrl). All Procore reads
// and writes are scoped two ways:
// • Company scope — the `Procore-Company-Id` header, REQUIRED on every request.
// • Project scope — the project id in the path (…/projects/{id}/…) or a
// `project_id` query param, depending on the resource.
// Docs: developers.procore.com/reference/rest/rfis and .../submittals,
// .../project-documents, .../observations, .../daily-logs.
// A prepared GET: url + headers a single fetch needs. Bearer + company scope.
export interface PreparedGet {
url: string;
headers: Record<string, string>;
}
// A prepared write (POST/PATCH): adds the JSON body and Content-Type.
export interface PreparedWrite extends PreparedGet {
method: 'POST' | 'PATCH';
body: string;
}
// The company-scope header name — REQUIRED by Procore on every REST v1.0 call.
export const COMPANY_HEADER = 'Procore-Company-Id';
// headers builds the auth + company-scope header set common to every request.
// The company id is a number in Procore; we stringify it at this one boundary.
function headers(accessToken: string, companyId: number | string): Record<string, string> {
return {
Authorization: `Bearer ${accessToken}`,
[COMPANY_HEADER]: String(companyId),
};
}
// jsonHeaders adds Content-Type for a write body on top of the auth/company set.
function jsonHeaders(accessToken: string, companyId: number | string): Record<string, string> {
return { ...headers(accessToken, companyId), 'Content-Type': 'application/json' };
}
// The common scope every request carries: which company, and (for project-scoped
// resources) which project. Grouping them keeps every wrapper's signature small
// and makes the two-level scoping impossible to forget.
export interface Scope {
apiBase: string;
accessToken: string;
companyId: number | string;
}
// Pagination controls. Procore paginates with page (1-based) + per_page and
// returns the total count in the `Total` response header (parsed by the caller
// with parseTotal). We attach these as query params on list endpoints.
export interface Page {
page?: number;
perPage?: number;
}
// DEFAULT_PER_PAGE is Procore's practical page size; 100 is the documented max
// for most list endpoints, so we default to it to minimise round-trips.
export const DEFAULT_PER_PAGE = 100;
// pageParams renders pagination into query pairs, clamping per_page to the
// documented maximum. Only emits params that are set, so an unpaginated call
// stays clean. Pure — unit-tested.
export function pageParams(page: Page | undefined): Record<string, string> {
const out: Record<string, string> = {};
if (!page) return out;
if (typeof page.page === 'number' && page.page > 0) out.page = String(Math.floor(page.page));
if (typeof page.perPage === 'number' && page.perPage > 0) {
out.per_page = String(Math.min(Math.floor(page.perPage), DEFAULT_PER_PAGE));
}
return out;
}
// buildUrl joins the REST root + path and appends a query string, dropping
// undefined/empty values. One place builds every URL so scoping/pagination
// params are applied consistently. Pure.
function buildUrl(apiBase: string, path: string, query: Record<string, string> = {}): string {
const base = `${apiBase.replace(/\/+$/, '')}${path}`;
const q = new URLSearchParams();
for (const [k, v] of Object.entries(query)) {
if (v !== undefined && v !== '') q.set(k, v);
}
const qs = q.toString();
return qs ? `${base}?${qs}` : base;
}
// enc encodes a single path segment (ids are integers, but we encode defensively
// so a stray value can never break — or traverse — the path).
function enc(segment: number | string): string {
return encodeURIComponent(String(segment));
}
// ---- Projects -------------------------------------------------------------
// listProjects — the projects the caller can see within the company. Company
// scope only (no project in the path). The panel's picker uses this to choose
// which project to run against.
export function listProjects(scope: Scope, page?: Page): PreparedGet {
return {
url: buildUrl(scope.apiBase, '/projects', {
company_id: String(scope.companyId),
...pageParams(page),
}),
headers: headers(scope.accessToken, scope.companyId),
};
}
// ---- RFIs -----------------------------------------------------------------
// listRFIs — the RFIs on a project. Project-scoped path. The core construction
// question artefact: a request for information, its answer, and its status.
export function listRFIs(scope: Scope, projectId: number | string, page?: Page): PreparedGet {
return {
url: buildUrl(scope.apiBase, `/projects/${enc(projectId)}/rfis`, pageParams(page)),
headers: headers(scope.accessToken, scope.companyId),
};
}
// getRFI — one RFI in full (question, all replies, references, status, ball-in-
// court). This is what the summarize/respond actions run over.
export function getRFI(scope: Scope, projectId: number | string, rfiId: number | string): PreparedGet {
return {
url: buildUrl(scope.apiBase, `/projects/${enc(projectId)}/rfis/${enc(rfiId)}`),
headers: headers(scope.accessToken, scope.companyId),
};
}
// createReply — post a reply to an RFI. This is the ONE write path, behind an
// explicit action. Procore nests the reply under an `rfi_reply` key with a
// `body` (rich text). Returns a PreparedWrite (POST + JSON body).
export function createReply(
scope: Scope,
projectId: number | string,
rfiId: number | string,
body: string,
): PreparedWrite {
return {
method: 'POST',
url: buildUrl(scope.apiBase, `/projects/${enc(projectId)}/rfis/${enc(rfiId)}/replies`),
headers: jsonHeaders(scope.accessToken, scope.companyId),
body: JSON.stringify({ rfi_reply: { body } }),
};
}
// ---- Submittals -----------------------------------------------------------
// listSubmittals — the submittals on a project (shop drawings, product data,
// samples awaiting review). Project-scoped path.
export function listSubmittals(scope: Scope, projectId: number | string, page?: Page): PreparedGet {
return {
url: buildUrl(scope.apiBase, `/projects/${enc(projectId)}/submittals`, pageParams(page)),
headers: headers(scope.accessToken, scope.companyId),
};
}
// ---- Documents (project files) --------------------------------------------
// listDocuments — the files/folders in a project's Documents tool. Procore's
// project-documents endpoint is company-scoped in the path with the project as a
// required query param. Returns folder + file entries.
export function listDocuments(scope: Scope, projectId: number | string, page?: Page): PreparedGet {
return {
url: buildUrl(scope.apiBase, '/folders_and_files', {
project_id: String(projectId),
...pageParams(page),
}),
headers: headers(scope.accessToken, scope.companyId),
};
}
// ---- Observations ---------------------------------------------------------
// listObservations — quality/safety observation items on a project.
export function listObservations(scope: Scope, projectId: number | string, page?: Page): PreparedGet {
return {
url: buildUrl(scope.apiBase, `/observations/items`, {
project_id: String(projectId),
...pageParams(page),
}),
headers: headers(scope.accessToken, scope.companyId),
};
}
// ---- Daily logs -----------------------------------------------------------
// listDailyLogs — the daily-log notes for a project on a given date (Procore's
// notes_logs endpoint; `log_date` in YYYY-MM-DD). Daily logs are the field
// record the action-item/risk extraction reads.
export function listDailyLogs(
scope: Scope,
projectId: number | string,
logDate: string,
page?: Page,
): PreparedGet {
return {
url: buildUrl(scope.apiBase, `/projects/${enc(projectId)}/daily_logs/notes_logs`, {
log_date: logDate,
...pageParams(page),
}),
headers: headers(scope.accessToken, scope.companyId),
};
}
// ---- Pagination header ----------------------------------------------------
// parseTotal reads the `Total` response header Procore returns on list endpoints
// into the total record count (for computing "page N of M"). Returns 0 for a
// missing/unparseable header. Accepts a Headers instance or a plain header map
// so it works against fetch Responses and test fixtures alike. Pure.
export function parseTotal(h: Headers | Record<string, string | string[] | undefined>): number {
const raw =
typeof (h as Headers)?.get === 'function'
? (h as Headers).get('Total') ?? (h as Headers).get('total')
: (h as Record<string, string | string[] | undefined>).Total ??
(h as Record<string, string | string[] | undefined>).total;
const val = Array.isArray(raw) ? raw[0] : raw;
const n = Number.parseInt(String(val ?? ''), 10);
return Number.isFinite(n) && n >= 0 ? n : 0;
}
// ---- Response parsing -----------------------------------------------------
//
// Procore list endpoints return a bare JSON array of records. We normalize the
// snake_case wire fields we surface into small typed shapes at this boundary so
// nothing downstream touches the wire.
// One project as it appears in listProjects.
export interface ProjectSummary {
id: number;
name: string;
displayName: string;
active: boolean;
}
export function parseProjects(data: any): ProjectSummary[] {
const rows = Array.isArray(data) ? data : Array.isArray(data?.projects) ? data.projects : [];
return rows
.map((p: any) => ({
id: Number(p?.id ?? 0),
name: String(p?.name ?? ''),
displayName: String(p?.display_name ?? p?.name ?? ''),
active: p?.active === true,
}))
.filter((p: ProjectSummary) => p.id > 0);
}
// One reply within an RFI's question thread.
export interface RfiReply {
id: number;
body: string;
createdBy: string;
createdAt: string;
}
// The question sub-object of an RFI (its body + the reply thread).
export interface RfiQuestion {
body: string;
replies: RfiReply[];
}
// One RFI in full. Procore nests the question + replies under `question`.
export interface Rfi {
id: number;
number: string;
subject: string;
status: string;
question: RfiQuestion;
ballInCourt: string;
dueDate: string;
createdAt: string;
}
function parseReply(r: any): RfiReply {
return {
id: Number(r?.id ?? 0),
body: String(r?.body ?? r?.plain_text_body ?? ''),
createdBy: personName(r?.created_by),
createdAt: String(r?.created_at ?? ''),
};
}
// personName renders a Procore person sub-object (or bare string) to a display
// name. Procore returns { id, name, login } for people; we take name.
function personName(p: any): string {
if (!p) return '';
if (typeof p === 'string') return p;
return String(p?.name ?? p?.login ?? '');
}
// parseRFI reads a getRFI body into a typed RFI. Missing fields become '' / []
// so the UI/prompt renders predictably. The question body lives at
// `question.body`; replies at `question.rfi_replies` (Procore's key). Pure.
export function parseRFI(data: any): Rfi {
const q = data?.question ?? {};
const rawReplies = Array.isArray(q?.rfi_replies)
? q.rfi_replies
: Array.isArray(data?.rfi_replies)
? data.rfi_replies
: [];
return {
id: Number(data?.id ?? 0),
number: String(data?.number ?? ''),
subject: String(data?.subject ?? ''),
status: String(data?.status ?? ''),
question: {
body: String(q?.body ?? q?.plain_text_body ?? ''),
replies: rawReplies.map(parseReply),
},
ballInCourt: personName(data?.ball_in_court),
dueDate: String(data?.due_date ?? ''),
createdAt: String(data?.created_at ?? ''),
};
}
// parseRFIs reads a listRFIs body into typed RFIs. The list endpoint returns the
// same shape as the detail endpoint (with the question thread), so we reuse
// parseRFI per row. Pure.
export function parseRFIs(data: any): Rfi[] {
const rows = Array.isArray(data) ? data : Array.isArray(data?.rfis) ? data.rfis : [];
return rows.map(parseRFI).filter((r: Rfi) => r.id > 0);
}
// One submittal as surfaced from listSubmittals.
export interface Submittal {
id: number;
number: string;
title: string;
status: string;
type: string;
dueDate: string;
}
export function parseSubmittals(data: any): Submittal[] {
const rows = Array.isArray(data) ? data : Array.isArray(data?.submittals) ? data.submittals : [];
return rows
.map((s: any) => ({
id: Number(s?.id ?? 0),
number: String(s?.number ?? s?.formatted_number ?? ''),
title: String(s?.title ?? ''),
status: String(s?.status?.name ?? s?.status ?? ''),
type: String(s?.type?.name ?? s?.type ?? ''),
dueDate: String(s?.due_date ?? ''),
}))
.filter((s: Submittal) => s.id > 0);
}
// One document/folder entry from listDocuments (folders_and_files). Procore
// returns folders and files in one payload; we flatten both into a common shape
// with `isFolder` so the context assembly can list them uniformly.
export interface DocumentEntry {
id: number;
name: string;
isFolder: boolean;
updatedAt: string;
}
export function parseDocuments(data: any): DocumentEntry[] {
const folders = Array.isArray(data?.folders) ? data.folders : [];
const files = Array.isArray(data?.files) ? data.files : [];
const bare = Array.isArray(data) ? data : [];
const map =
(isFolder: boolean) =>
(d: any): DocumentEntry => ({
id: Number(d?.id ?? 0),
name: String(d?.name ?? ''),
isFolder,
updatedAt: String(d?.updated_at ?? ''),
});
return [...folders.map(map(true)), ...files.map(map(false)), ...bare.map(map(false))].filter(
(d) => d.id > 0 && d.name.length > 0,
);
}
// One observation item from listObservations.
export interface Observation {
id: number;
name: string;
status: string;
priority: string;
description: string;
}
export function parseObservations(data: any): Observation[] {
const rows = Array.isArray(data) ? data : Array.isArray(data?.observations) ? data.observations : [];
return rows
.map((o: any) => ({
id: Number(o?.id ?? 0),
name: String(o?.name ?? ''),
status: String(o?.status ?? ''),
priority: String(o?.priority ?? ''),
description: String(o?.description ?? ''),
}))
.filter((o: Observation) => o.id > 0);
}
// One daily-log note from listDailyLogs.
export interface DailyLogNote {
id: number;
date: string;
comment: string;
createdBy: string;
}
export function parseDailyLogs(data: any): DailyLogNote[] {
const rows = Array.isArray(data) ? data : Array.isArray(data?.notes_logs) ? data.notes_logs : [];
return rows
.map((n: any) => ({
id: Number(n?.id ?? 0),
date: String(n?.date ?? n?.log_date ?? ''),
comment: String(n?.comment ?? ''),
createdBy: personName(n?.created_by),
}))
.filter((n: DailyLogNote) => n.id > 0);
}
+140
View File
@@ -0,0 +1,140 @@
// Procore OAuth 2.0 (Authorization Code Grant) — pure request shaping. The client
// secret is held by the server (server.ts) and never reaches the browser; these
// functions build the exact URL/body of each OAuth call so the wire shape is
// unit-testable without a network round-trip. The token exchange and refresh are
// each one fetch in the server.
//
// Procore uses form-encoded bodies with client_id + client_secret IN THE BODY
// (not HTTP Basic) and requires the redirect_uri on the code exchange — the
// standard OAuth2 web-server shape, documented at
// developers.procore.com/documentation/oauth-auth-grant-flow. The login host is
// login.procore.com (production) / login-sandbox.procore.com (sandbox).
import { hosts, type ProcoreEnv } from './config.js';
// authorizeUrl is where the install flow sends the user's browser to grant the
// app. `state` is the CSRF token the server generates and re-checks on callback.
// Procore's standard data-connection grant takes response_type=code + client_id
// + redirect_uri; scope is optional (permissions are per-company/tool), so it is
// only appended when non-empty.
export function authorizeUrl(args: {
env: ProcoreEnv;
clientId: string;
redirectUri: string;
scopes?: readonly string[];
state: string;
}): string {
const params: Record<string, string> = {
response_type: 'code',
client_id: args.clientId,
redirect_uri: args.redirectUri,
state: args.state,
};
if (args.scopes && args.scopes.length > 0) params.scope = args.scopes.join(' ');
const q = new URLSearchParams(params);
return `${hosts(args.env).login}/oauth/authorize?${q.toString()}`;
}
// A prepared HTTP request: the pieces a single fetch needs. Pure output so a
// test asserts the form body without opening a socket. Procore's token endpoint
// takes application/x-www-form-urlencoded.
export interface PreparedRequest {
url: string;
headers: Record<string, string>;
body: string;
}
// tokenUrl is the OAuth token endpoint for an environment.
export function tokenUrl(env: ProcoreEnv): string {
return `${hosts(env).login}/oauth/token`;
}
// tokenExchange builds the code→token request against the login host's
// /oauth/token. grant_type=authorization_code with the code, the app's
// credentials, and the SAME redirect_uri that was used on /oauth/authorize
// (Procore requires it to match). The secret rides only in this server-side body.
export function tokenExchange(args: {
env: ProcoreEnv;
clientId: string;
clientSecret: string;
code: string;
redirectUri: string;
}): PreparedRequest {
return {
url: tokenUrl(args.env),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: args.code,
client_id: args.clientId,
client_secret: args.clientSecret,
redirect_uri: args.redirectUri,
}).toString(),
};
}
// refreshExchange builds the refresh-token→token request. Procore access tokens
// are short-lived (~2h) and each refresh returns a NEW refresh token (rotation),
// so the server must store the returned refresh_token after every refresh.
export function refreshExchange(args: {
env: ProcoreEnv;
clientId: string;
clientSecret: string;
refreshToken: string;
}): PreparedRequest {
return {
url: tokenUrl(args.env),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: args.refreshToken,
client_id: args.clientId,
client_secret: args.clientSecret,
}).toString(),
};
}
// The token response we care about. Procore returns access_token (+
// refresh_token, expires_in, created_at, token_type). parseTokenResponse
// validates the one field we must have and surfaces Procore's own error otherwise.
export interface ProcoreTokenSet {
access_token: string;
refresh_token?: string;
expires_in?: number;
created_at?: number;
token_type?: string;
}
export function parseTokenResponse(data: any): ProcoreTokenSet {
if (!data || typeof data !== 'object') throw new Error('empty token response');
if (data.error) {
const reason = data.error_description || data.message || data.error;
throw new Error(`Procore OAuth error: ${reason}`);
}
if (typeof data.access_token !== 'string' || data.access_token.length === 0) {
throw new Error('Procore OAuth response missing access_token');
}
return {
access_token: data.access_token,
refresh_token: typeof data.refresh_token === 'string' ? data.refresh_token : undefined,
expires_in: typeof data.expires_in === 'number' ? data.expires_in : undefined,
created_at: typeof data.created_at === 'number' ? data.created_at : undefined,
token_type: typeof data.token_type === 'string' ? data.token_type : undefined,
};
}
// tokenExpiresAt computes the absolute epoch-seconds expiry from a token set,
// applying a safety skew so a request is never sent with a token about to expire
// mid-flight. created_at + expires_in is Procore's own clock; when created_at is
// absent we fall back to `now`. Pure — the caller passes `now` for testability.
export function tokenExpiresAt(token: ProcoreTokenSet, now: number, skewSeconds = 60): number {
const base = typeof token.created_at === 'number' ? token.created_at : now;
const ttl = typeof token.expires_in === 'number' ? token.expires_in : 0;
return base + ttl - skewSeconds;
}
// isExpired reports whether a token set is at/past its (skew-adjusted) expiry as
// of `now`. The server checks this before each API call and refreshes when true.
export function isExpired(token: ProcoreTokenSet, now: number, skewSeconds = 60): boolean {
return now >= tokenExpiresAt(token, now, skewSeconds);
}
+184
View File
@@ -0,0 +1,184 @@
// Project-context assembly — PURE, host-agnostic, fully unit-testable. Turns the
// typed Procore records (RFIs, submittals, documents, observations, daily logs)
// into the plain text an AI action reads, windowed to a character budget so a
// busy project never overflows the model or gets sent silently truncated.
//
// There is ONE windowing contract, identical in spirit to @hanzo/docusign's page
// windowing: render ordered blocks, walk them in order, stop at the budget,
// always include at least the first block, and report `truncated` honestly. The
// only construction-specific part is how a record renders to a block.
import type {
DailyLogNote,
DocumentEntry,
Observation,
Rfi,
Submittal,
} from './procore-api.js';
import { PROJECT_CHAR_BUDGET } from './config.js';
// ---- Record → text block --------------------------------------------------
// renderRFI turns one RFI (question + reply thread) into a labelled block. The
// reply order carries meaning (the conversation), so we never reorder it. Empty
// fields are omitted rather than rendered as blank lines. Pure.
export function renderRFI(rfi: Rfi): string {
const lines: string[] = [];
const head = `RFI ${rfi.number || rfi.id}: ${rfi.subject}`.trim();
lines.push(head);
const meta: string[] = [];
if (rfi.status) meta.push(`Status: ${rfi.status}`);
if (rfi.ballInCourt) meta.push(`Ball in court: ${rfi.ballInCourt}`);
if (rfi.dueDate) meta.push(`Due: ${rfi.dueDate}`);
if (meta.length) lines.push(meta.join(' · '));
if (rfi.question.body) lines.push(`Question: ${rfi.question.body.trim()}`);
for (const r of rfi.question.replies) {
const who = r.createdBy ? ` (${r.createdBy})` : '';
if (r.body) lines.push(`Reply${who}: ${r.body.trim()}`);
}
return lines.join('\n');
}
// renderSubmittal turns one submittal into a compact one/two-line block.
export function renderSubmittal(s: Submittal): string {
const head = `Submittal ${s.number || s.id}: ${s.title}`.trim();
const meta: string[] = [];
if (s.type) meta.push(`Type: ${s.type}`);
if (s.status) meta.push(`Status: ${s.status}`);
if (s.dueDate) meta.push(`Due: ${s.dueDate}`);
return meta.length ? `${head}\n${meta.join(' · ')}` : head;
}
// renderDocument turns one document/folder entry into a single labelled line.
export function renderDocument(d: DocumentEntry): string {
const kind = d.isFolder ? 'Folder' : 'File';
const when = d.updatedAt ? ` (updated ${d.updatedAt})` : '';
return `${kind}: ${d.name}${when}`;
}
// renderObservation turns one observation into a labelled block.
export function renderObservation(o: Observation): string {
const head = `Observation: ${o.name}`.trim();
const meta: string[] = [];
if (o.status) meta.push(`Status: ${o.status}`);
if (o.priority) meta.push(`Priority: ${o.priority}`);
const lines = [meta.length ? `${head}\n${meta.join(' · ')}` : head];
if (o.description) lines.push(o.description.trim());
return lines.join('\n');
}
// renderDailyLog turns one daily-log note into a labelled block.
export function renderDailyLog(n: DailyLogNote): string {
const who = n.createdBy ? `${n.createdBy}` : '';
const date = n.date ? `[${n.date}]` : '[daily log]';
return `${date}${who}: ${n.comment.trim()}`;
}
// ---- Windowing to a budget ------------------------------------------------
// A named group of rendered blocks — a section of the project context (e.g.
// "Open RFIs", "Daily log 2026-06-20"). The assembler concatenates sections in
// the order given and windows the whole thing to the budget.
export interface Section {
title: string;
blocks: string[];
}
// The windowed project context: the rendered text, how many blocks were
// available vs included, and whether anything was dropped (so the prompt and UI
// can say so honestly).
export interface ProjectContext {
text: string;
totalBlocks: number;
includedBlocks: number;
truncated: boolean;
}
// renderSection turns a section into its header + blocks string. Used both to
// measure the budget and to emit — the measured text IS the sent text.
function renderSection(title: string, blocks: string[]): string {
return `## ${title}\n${blocks.join('\n\n')}`;
}
// buildProjectContext concatenates sections IN ORDER and caps the rendered text
// at `budget` characters. It walks blocks in order (across sections) and stops
// when adding the next block would exceed the budget — always including at least
// the first block (hard-cut to the budget if that one block alone is over).
// `truncated` is true whenever not every available block made it in. Pure and
// total: deterministic, no I/O. This is the ONE windowing algorithm for the
// package — the same "fit ordered text to a budget" contract as @hanzo/docusign.
export function buildProjectContext(
sections: Section[],
budget: number = PROJECT_CHAR_BUDGET,
): ProjectContext {
const totalBlocks = sections.reduce((n, s) => n + s.blocks.length, 0);
if (totalBlocks === 0) {
return { text: '', totalBlocks: 0, includedBlocks: 0, truncated: false };
}
const parts: string[] = [];
let used = 0;
let included = 0;
let hardCut = false;
outer: for (const section of sections) {
if (section.blocks.length === 0) continue;
// The section header is charged to the first block that fits under it.
let headerPending = `## ${section.title}\n`;
for (const block of section.blocks) {
const prefix = parts.length === 0 ? '' : '\n\n';
const addition = prefix + headerPending + block;
if (used + addition.length > budget) {
if (parts.length === 0) {
// First block alone overflows — hard-cut it to the budget so we always
// send something rather than an empty context.
const solo = (headerPending + block).slice(0, budget);
parts.push(solo);
used = solo.length;
included = 1;
hardCut = true;
}
break outer;
}
parts.push(addition);
used += addition.length;
included += 1;
headerPending = ''; // header only precedes the first block of the section
}
}
const truncated = included < totalBlocks || hardCut;
return { text: parts.join(''), totalBlocks, includedBlocks: included, truncated };
}
// contextNote is the one honest sentence prepended to the project text so the
// model (and, echoed in the panel, the user) knows the scope of what it sees.
// Never claim the whole project was sent when it wasn't. Pure.
export function contextNote(ctx: ProjectContext): string {
if (ctx.totalBlocks === 0) return 'No project records were available.';
return ctx.truncated
? `Project records: ${ctx.includedBlocks} of ${ctx.totalBlocks} items (truncated to fit — answer only from what is shown and say so if the omitted part is needed).`
: `Project records: all ${ctx.totalBlocks} item${ctx.totalBlocks === 1 ? '' : 's'}.`;
}
// ---- Convenience section builders -----------------------------------------
//
// These turn a typed record list into a Section, so a caller (server or panel)
// assembles a project context in a couple of lines. Kept here (pure) so the same
// section shapes feed both surfaces and the tests.
export function rfiSection(rfis: Rfi[], title = 'RFIs'): Section {
return { title, blocks: rfis.map(renderRFI) };
}
export function submittalSection(submittals: Submittal[], title = 'Submittals'): Section {
return { title, blocks: submittals.map(renderSubmittal) };
}
export function documentSection(docs: DocumentEntry[], title = 'Documents'): Section {
return { title, blocks: docs.map(renderDocument) };
}
export function observationSection(items: Observation[], title = 'Observations'): Section {
return { title, blocks: items.map(renderObservation) };
}
export function dailyLogSection(notes: DailyLogNote[], title = 'Daily log'): Section {
return { title, blocks: notes.map(renderDailyLog) };
}
+248
View File
@@ -0,0 +1,248 @@
// The Procore install + API-proxy service. This is the ONLY place the Procore
// client secret and the OAuth tokens exist — the secret is read from the
// environment (never bundled, never sent to the browser); tokens are minted by
// the OAuth flow and kept server-side, refreshed transparently on expiry. It:
//
// GET /oauth/install → redirect the user to Procore's OAuth consent
// GET /oauth/callback → exchange the code for a token, open a session, and
// hand the browser a session cookie
// ALL /proxy/* → forward a browser request to the Procore REST API
// with the session's Bearer token + company header,
// refreshing the token first if it has expired. The
// browser never sees the token or the secret.
// GET /healthz → readiness
//
// It is a dependency-free Node http handler built on the pure modules (config,
// procore-oauth, procore-api) so it is deployable behind hanzoai/ingress as a
// small service at procore.hanzo.ai. The pure logic it wraps is what the tests
// cover; the http server is an integration concern.
//
// node dist/server.js (after build.js bundles it)
//
// Required environment (see config.readServerConfig):
// PROCORE_CLIENT_ID, PROCORE_CLIENT_SECRET, PROCORE_REDIRECT_URI
// PROCORE_ENV (sandbox | production, default sandbox)
// PORT (default 8790)
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { randomUUID } from 'node:crypto';
import { apiBaseUrl, readServerConfig, OAUTH_SCOPES, type ServerConfig } from './config.js';
import {
authorizeUrl,
tokenExchange,
refreshExchange,
parseTokenResponse,
isExpired,
type ProcoreTokenSet,
} from './procore-oauth.js';
// A server-side session: the tokens for one authorized user + a bookkeeping
// timestamp so we know when the token was minted (Procore returns created_at,
// but we also track wall-clock at mint as a fallback). In-memory here keyed by an
// opaque session id carried in an HttpOnly cookie; a production deployment
// persists this to hanzoai/kv (Valkey) so sessions survive a restart.
interface Session {
token: ProcoreTokenSet;
/** Wall-clock epoch seconds when the token was last minted/refreshed. */
mintedAt: number;
}
const sessions = new Map<string, Session>();
const SESSION_COOKIE = 'procore_sid';
// A pending OAuth `state` (CSRF token) minted at /oauth/install and consumed at
// /oauth/callback. A production deployment persists these (Valkey, short TTL); a
// Set is enough for a single instance.
const pendingStates = new Set<string>();
function nowSeconds(): number {
return Math.floor(Date.now() / 1000);
}
function json(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
}
function log(fields: Record<string, unknown>): void {
console.log(JSON.stringify(fields));
}
// parseCookies reads the session id from the Cookie header. Minimal parser — we
// only need our one cookie.
function sessionIdFromCookie(req: IncomingMessage): string | undefined {
const raw = req.headers.cookie;
if (!raw) return undefined;
for (const part of raw.split(';')) {
const [k, ...v] = part.trim().split('=');
if (k === SESSION_COOKIE) return decodeURIComponent(v.join('='));
}
return undefined;
}
// GET /oauth/install → 302 to Procore's consent screen. `state` is a fresh CSRF
// token re-checked on callback.
function handleInstall(cfg: ServerConfig, res: ServerResponse): void {
const state = randomUUID();
pendingStates.add(state);
const url = authorizeUrl({
env: cfg.procoreEnv,
clientId: cfg.procoreClientId,
redirectUri: cfg.procoreRedirectUri,
scopes: OAUTH_SCOPES,
state,
});
res.writeHead(302, { Location: url });
res.end();
}
// GET /oauth/callback?code=…&state=… → verify state, exchange the code for a
// token (secret in the server-side body), open a session, and set the cookie.
async function handleOAuthCallback(cfg: ServerConfig, url: URL, res: ServerResponse): Promise<void> {
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
if (!code) return json(res, 400, { error: 'missing code' });
if (!state || !pendingStates.delete(state)) return json(res, 400, { error: 'invalid state' });
const exReq = tokenExchange({
env: cfg.procoreEnv,
clientId: cfg.procoreClientId,
clientSecret: cfg.procoreClientSecret,
code,
redirectUri: cfg.procoreRedirectUri,
});
let token: ProcoreTokenSet;
try {
const resp = await fetch(exReq.url, { method: 'POST', headers: exReq.headers, body: exReq.body });
token = parseTokenResponse(await resp.json().catch(() => ({})));
} catch (e: any) {
return json(res, 400, { error: e?.message || 'token exchange failed' });
}
const sid = randomUUID();
sessions.set(sid, { token, mintedAt: nowSeconds() });
log({ msg: 'oauth: session opened', sid });
res.writeHead(200, {
'Content-Type': 'application/json',
'Set-Cookie': `${SESSION_COOKIE}=${sid}; HttpOnly; Secure; SameSite=Lax; Path=/`,
});
res.end(JSON.stringify({ ok: true, installed: true }));
}
// freshToken returns a session's access token, refreshing first if it has
// expired. The refresh rotates Procore's refresh token, so we store the new set.
// Throws if the refresh fails (the caller returns 401 and the user re-installs).
async function freshToken(cfg: ServerConfig, sid: string, session: Session): Promise<string> {
if (!isExpired(session.token, nowSeconds())) return session.token.access_token;
const refreshToken = session.token.refresh_token;
if (!refreshToken) throw new Error('session expired and no refresh token');
const rReq = refreshExchange({
env: cfg.procoreEnv,
clientId: cfg.procoreClientId,
clientSecret: cfg.procoreClientSecret,
refreshToken,
});
const resp = await fetch(rReq.url, { method: 'POST', headers: rReq.headers, body: rReq.body });
const next = parseTokenResponse(await resp.json().catch(() => ({})));
sessions.set(sid, { token: next, mintedAt: nowSeconds() });
log({ msg: 'oauth: token refreshed', sid });
return next.access_token;
}
// ALL /proxy/* → forward to the Procore REST API. The browser sends the REST path
// (after /proxy) plus the company id in the `Procore-Company-Id` header; the
// server injects the Bearer token (never exposed to the browser) and forwards the
// method, query, and body. This is the API proxy that keeps the secret + tokens
// server-side while letting the panel read Projects/RFIs/Documents and post a
// reply.
async function handleProxy(
cfg: ServerConfig,
req: IncomingMessage,
res: ServerResponse,
url: URL,
): Promise<void> {
const sid = sessionIdFromCookie(req);
const session = sid ? sessions.get(sid) : undefined;
if (!sid || !session) return json(res, 401, { error: 'not authenticated' });
const companyId = req.headers['procore-company-id'];
if (!companyId || Array.isArray(companyId)) {
return json(res, 400, { error: 'missing Procore-Company-Id header' });
}
let accessToken: string;
try {
accessToken = await freshToken(cfg, sid, session);
} catch (e: any) {
return json(res, 401, { error: e?.message || 'token refresh failed' });
}
// The path after /proxy is the REST path relative to the version root, e.g.
// /proxy/projects → ${apiBase}/projects. We forbid absolute/scheme-bearing
// targets so the proxy can only ever reach the Procore REST host.
const restPath = url.pathname.replace(/^\/proxy/, '') || '/';
const target = `${apiBaseUrl(cfg.procoreEnv)}${restPath}${url.search}`;
const method = req.method || 'GET';
const body = method === 'GET' || method === 'HEAD' ? undefined : await readRawBody(req);
try {
const upstream = await fetch(target, {
method,
headers: {
Authorization: `Bearer ${accessToken}`,
'Procore-Company-Id': companyId,
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body,
});
// Pass Procore's Total header through so the panel can paginate.
const total = upstream.headers.get('Total');
const text = await upstream.text();
const outHeaders: Record<string, string> = { 'Content-Type': 'application/json' };
if (total) outHeaders.Total = total;
res.writeHead(upstream.status, outHeaders);
res.end(text);
} catch (e: any) {
log({ msg: 'proxy: upstream error', error: e?.message, target });
return json(res, 502, { error: 'upstream request failed' });
}
}
// readRawBody collects the raw request bytes as a utf8 string.
async function readRawBody(req: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const c of req) chunks.push(c as Buffer);
return Buffer.concat(chunks).toString('utf8');
}
// createHandler is the request router. Everything is a small handler over the
// pure modules. Exported so a test can drive it with a mock req/res if desired.
export function createHandler(cfg: ServerConfig) {
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
const url = new URL(req.url || '/', `http://localhost:${cfg.port}`);
try {
if (req.method === 'GET' && url.pathname === '/oauth/install') return handleInstall(cfg, res);
if (req.method === 'GET' && url.pathname === '/oauth/callback') return await handleOAuthCallback(cfg, url, res);
if (url.pathname === '/proxy' || url.pathname.startsWith('/proxy/')) return await handleProxy(cfg, req, res, url);
if (req.method === 'GET' && url.pathname === '/healthz') return json(res, 200, { ok: true });
return json(res, 404, { error: 'not found' });
} catch (e: any) {
log({ msg: 'unhandled error', error: e?.message });
return json(res, 500, { error: 'internal error' });
}
};
}
// main boots the server when run directly. Import-safe: only the direct entry
// listens, so tests import the handlers without opening a port.
export function main(): void {
const cfg = readServerConfig(process.env);
const server = createServer(createHandler(cfg));
server.listen(cfg.port, () => {
log({ msg: 'hanzo procore service up', port: cfg.port, env: cfg.procoreEnv });
});
}
if (process.argv[1] && process.argv[1].endsWith('server.js')) {
main();
}
+50
View File
@@ -0,0 +1,50 @@
:root {
color-scheme: light dark;
--fg: #1a1a1a; --muted: #666; --bg: #fff; --line: #e3e3e3; --accent: #111;
--ok: #1a7f37; --warn: #9a6700; --error: #cf222e;
}
@media (prefers-color-scheme: dark) {
:root {
--fg: #eaeaea; --muted: #9a9a9a; --bg: #1e1e1e; --line: #333; --accent: #fff;
--ok: #3fb950; --warn: #d29922; --error: #f85149;
}
}
* { box-sizing: border-box; }
body {
font: 14px/1.45 -apple-system, "Segoe UI", Roboto, sans-serif;
color: var(--fg); background: var(--bg); margin: 0 auto; padding: 14px;
max-width: 720px;
}
header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
header .title { font-weight: 600; font-size: 15px; }
header .host { color: var(--muted); font-size: 12px; margin-left: auto; }
label { display: block; font-size: 12px; color: var(--muted); margin: 10px 0 4px; }
textarea, select, input {
width: 100%; font: inherit; color: var(--fg); background: var(--bg);
border: 1px solid var(--line); border-radius: 6px; padding: 8px;
}
textarea#prompt { min-height: 56px; resize: vertical; }
textarea#output { min-height: 220px; resize: vertical; }
.row { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
.row.scope { margin-top: 4px; }
.row.scope input#company { max-width: 130px; }
button {
font: inherit; border: 1px solid var(--line); background: var(--accent);
color: var(--bg); border-radius: 6px; padding: 8px 14px; cursor: pointer;
}
button.secondary { background: transparent; color: var(--fg); }
button:disabled { opacity: .5; cursor: default; }
.spacer { flex: 1; }
.records { font-size: 11px; color: var(--muted); margin-top: 6px; min-height: 14px; }
.status { min-height: 18px; font-size: 12px; margin-top: 10px; color: var(--muted); }
.status.ok { color: var(--ok); } .status.warn { color: var(--warn); } .status.error { color: var(--error); }
.hint { font-size: 11px; color: var(--muted); margin-top: 4px; }
footer { margin-top: 12px; font-size: 11px; color: var(--muted); }
select#model { width: auto; max-width: 150px; padding: 4px 8px; font-size: 12px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.chip {
padding: 5px 10px; border-radius: 999px; font-size: 13px;
background: transparent; color: var(--fg); border: 1px solid var(--line);
}
.chip:hover:not(:disabled) { border-color: var(--accent); }
details summary { cursor: pointer; font-size: 12px; color: var(--muted); margin-top: 10px; }
+77
View File
@@ -0,0 +1,77 @@
import { describe, it, expect, vi } from 'vitest';
import type { AiClient, ChatCompletion, ChatCompletionCreateParams } from '@hanzo/ai';
import { ACTIONS, actionList, actionPrompt, isActionId, runAction } from '../src/actions.js';
import { buildProjectContext } from '../src/project.js';
const ctx = buildProjectContext([{ title: 'RFIs', blocks: ['RFI 042: base plate conflict'] }]);
function mockClient(text: string) {
const reply: ChatCompletion = {
id: 'c', object: 'chat.completion', created: 0, model: 'zen5',
choices: [{ index: 0, message: { role: 'assistant', content: text }, finish_reason: 'stop' }],
};
const create = vi.fn(async (_p: ChatCompletionCreateParams) => reply);
return { client: { chat: { completions: { create } }, models: { list: vi.fn() } } as unknown as AiClient, create };
}
describe('actions: catalog', () => {
it('exposes exactly the four documented actions', () => {
expect(Object.keys(ACTIONS).sort()).toEqual(
['draftRfiResponse', 'extractActionItems', 'projectStatus', 'summarizeRfi'].sort(),
);
});
it('every action has a non-empty label and prompt', () => {
for (const [id, a] of Object.entries(ACTIONS)) {
expect(a.label.length, id).toBeGreaterThan(0);
expect(a.prompt.length, id).toBeGreaterThan(20);
}
});
it('actionList mirrors the catalog order and shape', () => {
const list = actionList();
expect(list.map((a) => a.id)).toEqual(Object.keys(ACTIONS));
expect(list[0]).toEqual({ id: 'summarizeRfi', label: ACTIONS.summarizeRfi.label });
});
});
describe('actions: prompt intent', () => {
it('draftRfiResponse tells the model not to invent an answer it lacks', () => {
expect(actionPrompt('draftRfiResponse')).toMatch(/still needed|inventing/i);
expect(actionPrompt('draftRfiResponse')).toMatch(/RFI reply/i);
});
it('extractActionItems asks for owners and severity-ordered risks', () => {
const p = actionPrompt('extractActionItems');
expect(p).toMatch(/action items/i);
expect(p).toMatch(/risks/i);
expect(p).toMatch(/severity/i);
});
it('projectStatus asks for overdue RFIs/submittals', () => {
expect(actionPrompt('projectStatus')).toMatch(/overdue/i);
});
});
describe('actions: isActionId / actionPrompt guards', () => {
it('narrows known ids and rejects unknown', () => {
expect(isActionId('summarizeRfi')).toBe(true);
expect(isActionId('nope')).toBe(false);
});
it('actionPrompt throws on an unknown id', () => {
expect(() => actionPrompt('nope')).toThrow(/Unknown action/);
});
});
describe('actions: runAction', () => {
it('routes the resolved prompt through ask (one code path to the model)', async () => {
const { client, create } = mockClient('summary');
const out = await runAction('summarizeRfi', ctx, { name: 'Tower A' }, { client });
expect(out).toBe('summary');
const user = String(create.mock.calls[0][0].messages[1].content);
expect(user).toContain(ACTIONS.summarizeRfi.prompt);
expect(user).toContain('RFI 042');
});
it('rejects (not synchronously throws) on an unknown id', async () => {
await expect(runAction('nope', ctx)).rejects.toThrow(/Unknown action/);
});
});
+106
View File
@@ -0,0 +1,106 @@
import { describe, it, expect } from 'vitest';
import {
HANZO_API_BASE_URL,
DEFAULT_MODEL,
APIKEY_STORAGE_KEY,
PROJECT_CHAR_BUDGET,
pickBearer,
chatCompletionsURL,
modelsURL,
hosts,
apiBaseUrl,
isProcoreEnv,
readServerConfig,
PROCORE_HOSTS,
} from '../src/config.js';
describe('config: gateway constants', () => {
it('points at api.hanzo.ai with /v1 paths and never /api/', () => {
expect(HANZO_API_BASE_URL).toBe('https://api.hanzo.ai');
expect(chatCompletionsURL()).toBe('https://api.hanzo.ai/v1/chat/completions');
expect(modelsURL()).toBe('https://api.hanzo.ai/v1/models');
expect(chatCompletionsURL()).not.toContain('/api/');
});
it('defaults to a zen model and a sane budget', () => {
expect(DEFAULT_MODEL).toBe('zen5');
expect(PROJECT_CHAR_BUDGET).toBeGreaterThan(10_000);
expect(APIKEY_STORAGE_KEY).toContain('procore');
});
});
describe('config: pickBearer', () => {
it('prefers a pasted key over an oauth token', () => {
expect(pickBearer('hk-abc', 'oauth-xyz')).toBe('hk-abc');
});
it('falls back to the oauth token, trimming whitespace', () => {
expect(pickBearer(' ', ' tok ')).toBe('tok');
});
it('returns empty when both are blank (anonymous)', () => {
expect(pickBearer('', '')).toBe('');
expect(pickBearer(' ', '')).toBe('');
});
});
describe('config: hosts + apiBaseUrl', () => {
it('exposes the exact production hosts', () => {
expect(hosts('production').login).toBe('https://login.procore.com');
expect(hosts('production').api).toBe('https://api.procore.com');
});
it('exposes the exact sandbox hosts', () => {
expect(hosts('sandbox').login).toBe('https://login-sandbox.procore.com');
expect(hosts('sandbox').api).toBe('https://sandbox.procore.com');
});
it('builds the rest/v1.0 root per environment', () => {
expect(apiBaseUrl('production')).toBe('https://api.procore.com/rest/v1.0');
expect(apiBaseUrl('sandbox')).toBe('https://sandbox.procore.com/rest/v1.0');
});
it('only defines the two known environments', () => {
expect(Object.keys(PROCORE_HOSTS).sort()).toEqual(['production', 'sandbox']);
});
});
describe('config: isProcoreEnv', () => {
it('accepts the two environments', () => {
expect(isProcoreEnv('production')).toBe(true);
expect(isProcoreEnv('sandbox')).toBe(true);
});
it('rejects anything else', () => {
expect(isProcoreEnv('demo')).toBe(false);
expect(isProcoreEnv(undefined)).toBe(false);
});
});
describe('config: readServerConfig', () => {
const base = {
PROCORE_CLIENT_ID: 'cid',
PROCORE_CLIENT_SECRET: 'secret',
PROCORE_REDIRECT_URI: 'https://procore.hanzo.ai/oauth/callback',
};
it('reads a full config, defaulting env to sandbox and port to 8790', () => {
const cfg = readServerConfig(base);
expect(cfg.procoreClientId).toBe('cid');
expect(cfg.procoreClientSecret).toBe('secret');
expect(cfg.procoreRedirectUri).toBe('https://procore.hanzo.ai/oauth/callback');
expect(cfg.procoreEnv).toBe('sandbox');
expect(cfg.port).toBe(8790);
});
it('honours PROCORE_ENV and PORT', () => {
const cfg = readServerConfig({ ...base, PROCORE_ENV: 'production', PORT: '9001' });
expect(cfg.procoreEnv).toBe('production');
expect(cfg.port).toBe(9001);
});
it('coerces an unknown PROCORE_ENV to sandbox', () => {
expect(readServerConfig({ ...base, PROCORE_ENV: 'staging' }).procoreEnv).toBe('sandbox');
});
it('throws listing every missing required secret', () => {
expect(() => readServerConfig({})).toThrow(/PROCORE_CLIENT_ID/);
expect(() => readServerConfig({})).toThrow(/PROCORE_CLIENT_SECRET/);
expect(() => readServerConfig({})).toThrow(/PROCORE_REDIRECT_URI/);
expect(() => readServerConfig({ PROCORE_CLIENT_ID: 'x' })).toThrow(/PROCORE_CLIENT_SECRET, PROCORE_REDIRECT_URI/);
});
});
+125
View File
@@ -0,0 +1,125 @@
import { describe, it, expect, vi } from 'vitest';
import type { AiClient, ChatCompletion, ChatCompletionCreateParams, Model } from '@hanzo/ai';
import {
SYSTEM_PROMPT,
buildMessages,
extractContent,
ask,
listModels,
type ProjectMeta,
} from '../src/hanzo.js';
import { buildProjectContext, type ProjectContext } from '../src/project.js';
const ctx: ProjectContext = buildProjectContext([{ title: 'RFIs', blocks: ['RFI 042: base plate conflict'] }]);
const meta: ProjectMeta = { name: 'Tower A', projectNumber: 'P-100', companyName: 'BuildCo' };
// completion builds a minimal @hanzo/ai ChatCompletion with the given content.
function completion(content: ChatCompletion['choices'][number]['message']['content']): ChatCompletion {
return {
id: 'c1',
object: 'chat.completion',
created: 0,
model: 'zen5',
choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }],
};
}
// mockClient captures the params the code sends and returns a canned completion.
// The create mock takes both the params and the options arg the source passes.
function mockClient(reply: ChatCompletion, models: Model[] = []) {
const create = vi.fn(async (_p: ChatCompletionCreateParams, _o?: { signal?: AbortSignal }) => reply);
const list = vi.fn(async (_o?: { signal?: AbortSignal }) => models);
const client = { chat: { completions: { create } }, models: { list } } as unknown as AiClient;
return { client, create, list };
}
describe('hanzo: buildMessages', () => {
it('produces a grounded system prompt + a fenced user turn with meta + context note', () => {
const msgs = buildMessages('What is overdue?', ctx, meta);
expect(msgs).toHaveLength(2);
expect(msgs[0].role).toBe('system');
expect(msgs[0].content).toBe(SYSTEM_PROMPT);
const user = String(msgs[1].content);
expect(user).toContain('Project: Tower A');
expect(user).toContain('Project #: P-100');
expect(user).toContain('Company: BuildCo');
expect(user).toContain('---- project records ----');
expect(user).toContain('RFI 042: base plate conflict');
expect(user).toContain('---- task ----');
expect(user).toContain('What is overdue?');
});
it('omits the header block entirely when meta is undefined', () => {
const user = String(buildMessages('q', ctx).at(1)!.content);
expect(user).not.toContain('Project:');
expect(user).toContain('---- task ----');
});
it('SYSTEM_PROMPT forbids inventing records and disclaims directives', () => {
expect(SYSTEM_PROMPT).toMatch(/never invent/i);
expect(SYSTEM_PROMPT).toMatch(/not.*directives/i);
});
});
describe('hanzo: extractContent', () => {
it('reads a plain string content', () => {
expect(extractContent(completion('hello'))).toBe('hello');
});
it('concatenates text parts of an array content', () => {
const parts = [
{ type: 'text', text: 'a' },
{ type: 'image_url', image_url: { url: 'x' } },
{ type: 'text', text: 'b' },
] as any;
expect(extractContent(completion(parts))).toBe('ab');
});
it('throws on empty content', () => {
expect(() => extractContent(completion(''))).toThrow(/no content/);
expect(() => extractContent(completion(undefined))).toThrow(/no content/);
});
});
describe('hanzo: ask', () => {
it('sends model + messages non-streaming through the injected client and returns the text', async () => {
const { client, create } = mockClient(completion('overdue: RFI 042'));
const out = await ask('What is overdue?', ctx, meta, { client, model: 'zen5', temperature: 0.2 });
expect(out).toBe('overdue: RFI 042');
expect(create).toHaveBeenCalledOnce();
const [params, options] = create.mock.calls[0];
expect(params.model).toBe('zen5');
expect(params.stream).toBe(false);
expect(params.temperature).toBe(0.2);
expect(params.messages[0].role).toBe('system');
expect(options).toBeDefined();
});
it('omits temperature from the wire when not provided', async () => {
const { client, create } = mockClient(completion('ok'));
await ask('q', ctx, undefined, { client });
expect('temperature' in create.mock.calls[0][0]).toBe(false);
});
it('defaults the model to zen5', async () => {
const { client, create } = mockClient(completion('ok'));
await ask('q', ctx, undefined, { client });
expect(create.mock.calls[0][0].model).toBe('zen5');
});
it('propagates an abort signal to the client', async () => {
const { client, create } = mockClient(completion('ok'));
const controller = new AbortController();
await ask('q', ctx, undefined, { client, signal: controller.signal });
expect(create.mock.calls[0][1]?.signal).toBe(controller.signal);
});
});
describe('hanzo: listModels', () => {
it('maps the model catalog to ids', async () => {
const models: Model[] = [
{ id: 'zen5', object: 'model' },
{ id: 'zen5-pro', object: 'model' },
];
const { client } = mockClient(completion('x'), models);
expect(await listModels({ client })).toEqual(['zen5', 'zen5-pro']);
});
});
+141
View File
@@ -0,0 +1,141 @@
import { describe, it, expect } from 'vitest';
import {
authorizeUrl,
tokenUrl,
tokenExchange,
refreshExchange,
parseTokenResponse,
tokenExpiresAt,
isExpired,
} from '../src/procore-oauth.js';
describe('oauth: authorizeUrl', () => {
it('builds the sandbox consent URL with the standard params', () => {
const url = new URL(
authorizeUrl({
env: 'sandbox',
clientId: 'cid',
redirectUri: 'https://procore.hanzo.ai/oauth/callback',
state: 'st-1',
}),
);
expect(url.origin).toBe('https://login-sandbox.procore.com');
expect(url.pathname).toBe('/oauth/authorize');
expect(url.searchParams.get('response_type')).toBe('code');
expect(url.searchParams.get('client_id')).toBe('cid');
expect(url.searchParams.get('redirect_uri')).toBe('https://procore.hanzo.ai/oauth/callback');
expect(url.searchParams.get('state')).toBe('st-1');
});
it('uses the production login host and omits scope when empty', () => {
const url = new URL(
authorizeUrl({ env: 'production', clientId: 'c', redirectUri: 'https://x/cb', state: 's', scopes: [] }),
);
expect(url.origin).toBe('https://login.procore.com');
expect(url.searchParams.has('scope')).toBe(false);
});
it('space-joins scopes when present', () => {
const url = new URL(
authorizeUrl({ env: 'sandbox', clientId: 'c', redirectUri: 'https://x/cb', state: 's', scopes: ['a', 'b'] }),
);
expect(url.searchParams.get('scope')).toBe('a b');
});
});
describe('oauth: token endpoints', () => {
it('resolves the token URL per environment', () => {
expect(tokenUrl('sandbox')).toBe('https://login-sandbox.procore.com/oauth/token');
expect(tokenUrl('production')).toBe('https://login.procore.com/oauth/token');
});
});
describe('oauth: tokenExchange', () => {
it('form-encodes the code grant with client credentials + redirect_uri in the body', () => {
const req = tokenExchange({
env: 'sandbox',
clientId: 'cid',
clientSecret: 'sec',
code: 'the-code',
redirectUri: 'https://procore.hanzo.ai/oauth/callback',
});
expect(req.url).toBe('https://login-sandbox.procore.com/oauth/token');
expect(req.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
const body = new URLSearchParams(req.body);
expect(body.get('grant_type')).toBe('authorization_code');
expect(body.get('code')).toBe('the-code');
expect(body.get('client_id')).toBe('cid');
expect(body.get('client_secret')).toBe('sec');
expect(body.get('redirect_uri')).toBe('https://procore.hanzo.ai/oauth/callback');
});
it('never puts the secret in the URL', () => {
const req = tokenExchange({ env: 'production', clientId: 'c', clientSecret: 'SUPER', code: 'x', redirectUri: 'https://x/cb' });
expect(req.url).not.toContain('SUPER');
});
});
describe('oauth: refreshExchange', () => {
it('form-encodes the refresh grant with client credentials', () => {
const req = refreshExchange({ env: 'sandbox', clientId: 'cid', clientSecret: 'sec', refreshToken: 'r-1' });
expect(req.url).toBe('https://login-sandbox.procore.com/oauth/token');
const body = new URLSearchParams(req.body);
expect(body.get('grant_type')).toBe('refresh_token');
expect(body.get('refresh_token')).toBe('r-1');
expect(body.get('client_id')).toBe('cid');
expect(body.get('client_secret')).toBe('sec');
expect(body.has('code')).toBe(false);
});
});
describe('oauth: parseTokenResponse', () => {
it('parses a full token set', () => {
const t = parseTokenResponse({
access_token: 'at',
refresh_token: 'rt',
expires_in: 7200,
created_at: 1000,
token_type: 'Bearer',
});
expect(t.access_token).toBe('at');
expect(t.refresh_token).toBe('rt');
expect(t.expires_in).toBe(7200);
expect(t.created_at).toBe(1000);
});
it('throws on a Procore error payload with the description', () => {
expect(() => parseTokenResponse({ error: 'invalid_grant', error_description: 'code used' })).toThrow(/code used/);
});
it('throws when access_token is missing or empty', () => {
expect(() => parseTokenResponse({})).toThrow(/missing access_token/);
expect(() => parseTokenResponse({ access_token: '' })).toThrow(/missing access_token/);
expect(() => parseTokenResponse(null)).toThrow(/empty token response/);
});
it('drops non-string/non-number optional fields defensively', () => {
const t = parseTokenResponse({ access_token: 'at', refresh_token: 123, expires_in: 'soon' });
expect(t.refresh_token).toBeUndefined();
expect(t.expires_in).toBeUndefined();
});
});
describe('oauth: expiry math', () => {
it('computes expiry from created_at + expires_in minus skew', () => {
const t = { access_token: 'a', created_at: 1000, expires_in: 7200 };
expect(tokenExpiresAt(t, 1000, 60)).toBe(1000 + 7200 - 60);
});
it('falls back to now when created_at is absent', () => {
const t = { access_token: 'a', expires_in: 100 };
expect(tokenExpiresAt(t, 5000, 0)).toBe(5000 + 100);
});
it('reports not-expired before, expired at/after the skewed boundary', () => {
const t = { access_token: 'a', created_at: 1000, expires_in: 7200 };
const boundary = 1000 + 7200 - 60;
expect(isExpired(t, boundary - 1, 60)).toBe(false);
expect(isExpired(t, boundary, 60)).toBe(true);
expect(isExpired(t, boundary + 100, 60)).toBe(true);
});
});
+92
View File
@@ -0,0 +1,92 @@
import { describe, it, expect, vi } from 'vitest';
import { parseLaunchContext, proxyUrl, createProxyClient } from '../src/panel.js';
describe('panel: parseLaunchContext', () => {
it('reads Procore snake_case company/project ids', () => {
expect(parseLaunchContext('?company_id=42&project_id=100')).toEqual({ companyId: '42', projectId: '100' });
});
it('accepts camelCase fallbacks and defaults to empty', () => {
expect(parseLaunchContext('?companyId=7')).toEqual({ companyId: '7', projectId: '' });
expect(parseLaunchContext('')).toEqual({ companyId: '', projectId: '' });
});
});
describe('panel: proxyUrl', () => {
it('prefixes /proxy and appends a query, dropping empties', () => {
expect(proxyUrl('', '/projects', { company_id: '42', page: '', per_page: '100' })).toBe(
'/proxy/projects?company_id=42&per_page=100',
);
});
it('strips a trailing slash from the base', () => {
expect(proxyUrl('https://procore.hanzo.ai/', '/x')).toBe('https://procore.hanzo.ai/proxy/x');
});
});
// jsonResponse builds a fetch Response-like with a Total header.
function jsonResponse(body: unknown, total?: string): Response {
const headers = new Headers({ 'Content-Type': 'application/json' });
if (total) headers.set('Total', total);
return new Response(JSON.stringify(body), { status: 200, headers });
}
// fetchMockOf wraps a responder in a fetch-typed mock so calls[i] carries the
// (url, init) arg tuple TypeScript expects.
function fetchMockOf(responder: () => Response) {
return vi.fn((_url: RequestInfo | URL, _init?: RequestInit) => Promise.resolve(responder()));
}
describe('panel: createProxyClient — scoping + shaping', () => {
it('sends the company header + credentials and returns parsed items with the Total', async () => {
const fetchMock = fetchMockOf(() => jsonResponse([{ id: 1, name: 'Tower A' }], '137'));
const client = createProxyClient({ companyId: '42', fetch: fetchMock as any });
const { items, total } = await client.listProjects({ perPage: 100 });
expect(items).toEqual([{ id: 1, name: 'Tower A', displayName: 'Tower A', active: false }]);
expect(total).toBe(137);
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toBe('/proxy/projects?company_id=42&per_page=100');
expect((init as RequestInit).headers).toMatchObject({ 'Procore-Company-Id': '42' });
expect((init as RequestInit).credentials).toBe('include');
});
it('lists RFIs on the project path with pagination', async () => {
const fetchMock = fetchMockOf(() => jsonResponse([], '0'));
const client = createProxyClient({ companyId: '42', fetch: fetchMock as any });
await client.listRFIs(100, { page: 2, perPage: 50 });
expect(String(fetchMock.mock.calls[0][0])).toBe('/proxy/projects/100/rfis?page=2&per_page=50');
});
it('gets one RFI and parses it', async () => {
const fetchMock = fetchMockOf(() =>
jsonResponse({ id: 9, number: '042', subject: 'Conflict', status: 'open', question: { body: 'Q?' } }),
);
const client = createProxyClient({ companyId: '42', fetch: fetchMock as any });
const rfi = await client.getRFI(100, 9);
expect(rfi.number).toBe('042');
expect(rfi.question.body).toBe('Q?');
expect(String(fetchMock.mock.calls[0][0])).toBe('/proxy/projects/100/rfis/9');
});
it('routes documents through folders_and_files with project_id', async () => {
const fetchMock = fetchMockOf(() => jsonResponse({ folders: [], files: [] }));
const client = createProxyClient({ companyId: '42', fetch: fetchMock as any });
await client.listDocuments(100);
expect(String(fetchMock.mock.calls[0][0])).toBe('/proxy/folders_and_files?project_id=100');
});
it('createReply POSTs the rfi_reply JSON body', async () => {
const fetchMock = fetchMockOf(() => new Response('{}', { status: 201 }));
const client = createProxyClient({ companyId: '42', fetch: fetchMock as any });
await client.createReply(100, 9, 'S-201 governs.');
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toBe('/proxy/projects/100/rfis/9/replies');
expect((init as RequestInit).method).toBe('POST');
expect(JSON.parse(String((init as RequestInit).body))).toEqual({ rfi_reply: { body: 'S-201 governs.' } });
});
it('surfaces a proxy error with status + message', async () => {
const fetchMock = fetchMockOf(() => new Response(JSON.stringify({ error: 'not authenticated' }), { status: 401 }));
const client = createProxyClient({ companyId: '42', fetch: fetchMock as any });
await expect(client.listProjects()).rejects.toThrow(/401.*not authenticated/);
});
});
+134
View File
@@ -0,0 +1,134 @@
import { describe, it, expect } from 'vitest';
import {
parseProjects,
parseRFI,
parseRFIs,
parseSubmittals,
parseDocuments,
parseObservations,
parseDailyLogs,
} from '../src/procore-api.js';
// A realistic Procore RFI detail payload (trimmed to fields we surface).
const rfiFixture = {
id: 555,
number: '042',
subject: 'Column base plate detail conflict',
status: 'open',
due_date: '2026-06-25',
created_at: '2026-06-18T14:00:00Z',
ball_in_court: { id: 9, name: 'Dana Structural', login: 'dana@eng.co' },
question: {
body: 'Detail 5/A-501 conflicts with S-201. Which governs at gridline C?',
rfi_replies: [
{ id: 1, body: 'Structural S-201 governs.', created_by: { name: 'Dana Structural' }, created_at: '2026-06-19T09:00:00Z' },
{ id: 2, plain_text_body: 'Understood, proceeding.', created_by: 'Field Team', created_at: '2026-06-19T10:00:00Z' },
],
},
};
describe('parse: projects', () => {
it('normalizes a bare array and drops rows without an id', () => {
const rows = parseProjects([
{ id: 1, name: 'Tower A', display_name: 'Tower A — Core', active: true },
{ id: 0, name: 'bad' },
{ name: 'no id' },
]);
expect(rows).toHaveLength(1);
expect(rows[0]).toEqual({ id: 1, name: 'Tower A', displayName: 'Tower A — Core', active: true });
});
it('falls back display_name to name and reads a wrapped shape', () => {
const rows = parseProjects({ projects: [{ id: 2, name: 'Annex' }] });
expect(rows[0].displayName).toBe('Annex');
expect(rows[0].active).toBe(false);
});
});
describe('parse: RFI detail', () => {
it('reads the question, replies (in order), status, and ball-in-court', () => {
const rfi = parseRFI(rfiFixture);
expect(rfi.id).toBe(555);
expect(rfi.number).toBe('042');
expect(rfi.subject).toBe('Column base plate detail conflict');
expect(rfi.status).toBe('open');
expect(rfi.ballInCourt).toBe('Dana Structural');
expect(rfi.dueDate).toBe('2026-06-25');
expect(rfi.question.body).toContain('Which governs at gridline C');
expect(rfi.question.replies).toHaveLength(2);
expect(rfi.question.replies[0].body).toBe('Structural S-201 governs.');
expect(rfi.question.replies[0].createdBy).toBe('Dana Structural');
// Reply 2 uses plain_text_body and a bare-string author.
expect(rfi.question.replies[1].body).toBe('Understood, proceeding.');
expect(rfi.question.replies[1].createdBy).toBe('Field Team');
});
it('tolerates a missing question / replies', () => {
const rfi = parseRFI({ id: 1, subject: 'x' });
expect(rfi.question.body).toBe('');
expect(rfi.question.replies).toEqual([]);
});
});
describe('parse: RFI list', () => {
it('parses an array and drops idless rows', () => {
const rfis = parseRFIs([rfiFixture, { subject: 'no id' }]);
expect(rfis).toHaveLength(1);
expect(rfis[0].number).toBe('042');
});
it('reads a wrapped { rfis } shape', () => {
expect(parseRFIs({ rfis: [rfiFixture] })).toHaveLength(1);
});
});
describe('parse: submittals', () => {
it('reads nested status/type name objects', () => {
const subs = parseSubmittals([
{ id: 10, number: 'SUB-01', title: 'Rebar shop drawings', status: { name: 'Open' }, type: { name: 'Shop Drawing' }, due_date: '2026-07-01' },
{ id: 11, formatted_number: '03-100', title: 'Concrete mix', status: 'Approved', type: 'Product Data' },
]);
expect(subs[0]).toEqual({ id: 10, number: 'SUB-01', title: 'Rebar shop drawings', status: 'Open', type: 'Shop Drawing', dueDate: '2026-07-01' });
expect(subs[1].number).toBe('03-100');
expect(subs[1].status).toBe('Approved');
expect(subs[1].type).toBe('Product Data');
});
});
describe('parse: documents (folders_and_files)', () => {
it('flattens folders + files with an isFolder flag', () => {
const docs = parseDocuments({
folders: [{ id: 1, name: 'Drawings', updated_at: '2026-06-01T00:00:00Z' }],
files: [{ id: 2, name: 'A-501.pdf', updated_at: '2026-06-10T00:00:00Z' }],
});
expect(docs).toHaveLength(2);
expect(docs[0]).toEqual({ id: 1, name: 'Drawings', isFolder: true, updatedAt: '2026-06-01T00:00:00Z' });
expect(docs[1].isFolder).toBe(false);
});
it('handles a bare array of files and drops nameless/idless rows', () => {
const docs = parseDocuments([{ id: 3, name: 'S-201.pdf' }, { id: 0, name: 'bad' }, { id: 4, name: '' }]);
expect(docs).toHaveLength(1);
expect(docs[0].name).toBe('S-201.pdf');
});
});
describe('parse: observations', () => {
it('reads name/status/priority/description', () => {
const obs = parseObservations([
{ id: 20, name: 'Missing guardrail on level 3', status: 'Initiated', priority: 'High', description: 'East edge open.' },
]);
expect(obs[0]).toEqual({ id: 20, name: 'Missing guardrail on level 3', status: 'Initiated', priority: 'High', description: 'East edge open.' });
});
});
describe('parse: daily logs', () => {
it('reads comment + author + date', () => {
const notes = parseDailyLogs([
{ id: 30, date: '2026-06-20', comment: 'Rain delay, 2 hours lost.', created_by: { name: 'Super' } },
]);
expect(notes[0]).toEqual({ id: 30, date: '2026-06-20', comment: 'Rain delay, 2 hours lost.', createdBy: 'Super' });
});
it('reads a wrapped { notes_logs } shape and log_date fallback', () => {
const notes = parseDailyLogs({ notes_logs: [{ id: 31, log_date: '2026-06-21', comment: 'Pour complete.' }] });
expect(notes[0].date).toBe('2026-06-21');
expect(notes[0].createdBy).toBe('');
});
});
+145
View File
@@ -0,0 +1,145 @@
import { describe, it, expect } from 'vitest';
import {
COMPANY_HEADER,
DEFAULT_PER_PAGE,
pageParams,
parseTotal,
listProjects,
listRFIs,
getRFI,
createReply,
listSubmittals,
listDocuments,
listObservations,
listDailyLogs,
type Scope,
} from '../src/procore-api.js';
import { apiBaseUrl } from '../src/config.js';
const scope: Scope = {
apiBase: apiBaseUrl('production'),
accessToken: 'at-123',
companyId: 42,
};
function q(url: string): URLSearchParams {
return new URL(url).searchParams;
}
describe('api: pageParams', () => {
it('emits nothing for no page', () => {
expect(pageParams(undefined)).toEqual({});
});
it('emits page + per_page when set', () => {
expect(pageParams({ page: 2, perPage: 50 })).toEqual({ page: '2', per_page: '50' });
});
it('clamps per_page to the documented max', () => {
expect(pageParams({ perPage: 500 }).per_page).toBe(String(DEFAULT_PER_PAGE));
});
it('ignores non-positive values', () => {
expect(pageParams({ page: 0, perPage: -5 })).toEqual({});
});
});
describe('api: company + auth scoping', () => {
it('every read carries the Bearer token and the Procore-Company-Id header', () => {
for (const req of [
listProjects(scope),
listRFIs(scope, 7),
getRFI(scope, 7, 9),
listSubmittals(scope, 7),
listDocuments(scope, 7),
listObservations(scope, 7),
listDailyLogs(scope, 7, '2026-06-20'),
]) {
expect(req.headers.Authorization).toBe('Bearer at-123');
expect(req.headers[COMPANY_HEADER]).toBe('42');
}
});
it('COMPANY_HEADER is exactly Procore-Company-Id', () => {
expect(COMPANY_HEADER).toBe('Procore-Company-Id');
});
});
describe('api: listProjects', () => {
it('is company-scoped (query) with no project in the path', () => {
const req = listProjects(scope, { page: 3, perPage: 25 });
const url = new URL(req.url);
expect(url.pathname).toBe('/rest/v1.0/projects');
expect(q(req.url).get('company_id')).toBe('42');
expect(q(req.url).get('page')).toBe('3');
expect(q(req.url).get('per_page')).toBe('25');
});
});
describe('api: RFIs', () => {
it('lists RFIs on a project path with pagination', () => {
const req = listRFIs(scope, 100, { page: 2 });
expect(new URL(req.url).pathname).toBe('/rest/v1.0/projects/100/rfis');
expect(q(req.url).get('page')).toBe('2');
});
it('gets one RFI by id', () => {
const req = getRFI(scope, 100, 555);
expect(new URL(req.url).pathname).toBe('/rest/v1.0/projects/100/rfis/555');
});
});
describe('api: createReply (the one write path)', () => {
it('POSTs an rfi_reply body under the project/rfi path', () => {
const req = createReply(scope, 100, 555, 'Use detail 5/A-501.');
expect(req.method).toBe('POST');
expect(new URL(req.url).pathname).toBe('/rest/v1.0/projects/100/rfis/555/replies');
expect(req.headers['Content-Type']).toBe('application/json');
expect(req.headers[COMPANY_HEADER]).toBe('42');
expect(JSON.parse(req.body)).toEqual({ rfi_reply: { body: 'Use detail 5/A-501.' } });
});
});
describe('api: submittals / documents / observations / daily logs', () => {
it('submittals are project-path scoped', () => {
expect(new URL(listSubmittals(scope, 100).url).pathname).toBe('/rest/v1.0/projects/100/submittals');
});
it('documents use folders_and_files with project_id query', () => {
const req = listDocuments(scope, 100);
expect(new URL(req.url).pathname).toBe('/rest/v1.0/folders_and_files');
expect(q(req.url).get('project_id')).toBe('100');
});
it('observations use observations/items with project_id query', () => {
const req = listObservations(scope, 100);
expect(new URL(req.url).pathname).toBe('/rest/v1.0/observations/items');
expect(q(req.url).get('project_id')).toBe('100');
});
it('daily logs are project-path scoped with a log_date', () => {
const req = listDailyLogs(scope, 100, '2026-06-20', { perPage: 10 });
expect(new URL(req.url).pathname).toBe('/rest/v1.0/projects/100/daily_logs/notes_logs');
expect(q(req.url).get('log_date')).toBe('2026-06-20');
expect(q(req.url).get('per_page')).toBe('10');
});
});
describe('api: path segment encoding', () => {
it('encodes ids defensively so a stray value cannot traverse the path', () => {
const req = getRFI(scope, 'a/b', 'c d');
expect(req.url).toContain('/projects/a%2Fb/rfis/c%20d');
});
});
describe('api: parseTotal', () => {
it('reads a numeric Total from a Headers instance', () => {
const h = new Headers({ Total: '137' });
expect(parseTotal(h)).toBe(137);
});
it('reads Total from a plain header map (either case)', () => {
expect(parseTotal({ Total: '5' })).toBe(5);
expect(parseTotal({ total: '9' })).toBe(9);
});
it('handles an array-valued header', () => {
expect(parseTotal({ Total: ['12', '13'] })).toBe(12);
});
it('returns 0 for a missing or unparseable header', () => {
expect(parseTotal({})).toBe(0);
expect(parseTotal(new Headers())).toBe(0);
expect(parseTotal({ Total: 'many' })).toBe(0);
});
});
+139
View File
@@ -0,0 +1,139 @@
import { describe, it, expect } from 'vitest';
import {
renderRFI,
renderSubmittal,
renderDocument,
renderObservation,
renderDailyLog,
buildProjectContext,
contextNote,
rfiSection,
submittalSection,
documentSection,
observationSection,
dailyLogSection,
} from '../src/project.js';
import type { Rfi, Submittal, DocumentEntry, Observation, DailyLogNote } from '../src/procore-api.js';
const rfi: Rfi = {
id: 555,
number: '042',
subject: 'Base plate conflict',
status: 'open',
ballInCourt: 'Dana Structural',
dueDate: '2026-06-25',
createdAt: '2026-06-18',
question: {
body: 'Detail 5/A-501 conflicts with S-201. Which governs?',
replies: [{ id: 1, body: 'S-201 governs.', createdBy: 'Dana', createdAt: '2026-06-19' }],
},
};
describe('project: record rendering', () => {
it('renders an RFI with number, meta, question, and replies in order', () => {
const text = renderRFI(rfi);
expect(text).toContain('RFI 042: Base plate conflict');
expect(text).toContain('Status: open');
expect(text).toContain('Ball in court: Dana Structural');
expect(text).toContain('Question: Detail 5/A-501 conflicts');
expect(text).toContain('Reply (Dana): S-201 governs.');
});
it('renders a submittal, document, observation, and daily log compactly', () => {
const sub: Submittal = { id: 10, number: 'SUB-01', title: 'Rebar', status: 'Open', type: 'Shop Drawing', dueDate: '2026-07-01' };
expect(renderSubmittal(sub)).toContain('Submittal SUB-01: Rebar');
expect(renderSubmittal(sub)).toContain('Type: Shop Drawing · Status: Open · Due: 2026-07-01');
const doc: DocumentEntry = { id: 2, name: 'A-501.pdf', isFolder: false, updatedAt: '2026-06-10' };
expect(renderDocument(doc)).toBe('File: A-501.pdf (updated 2026-06-10)');
const obs: Observation = { id: 20, name: 'Missing guardrail', status: 'Initiated', priority: 'High', description: 'East edge open.' };
expect(renderObservation(obs)).toContain('Observation: Missing guardrail');
expect(renderObservation(obs)).toContain('East edge open.');
const note: DailyLogNote = { id: 30, date: '2026-06-20', comment: 'Rain delay.', createdBy: 'Super' };
expect(renderDailyLog(note)).toBe('[2026-06-20] — Super: Rain delay.');
});
});
describe('project: buildProjectContext', () => {
it('returns empty for no blocks', () => {
const ctx = buildProjectContext([{ title: 'RFIs', blocks: [] }]);
expect(ctx).toEqual({ text: '', totalBlocks: 0, includedBlocks: 0, truncated: false });
});
it('includes all blocks under section headers when within budget', () => {
const ctx = buildProjectContext([
{ title: 'RFIs', blocks: ['a', 'b'] },
{ title: 'Docs', blocks: ['c'] },
]);
expect(ctx.totalBlocks).toBe(3);
expect(ctx.includedBlocks).toBe(3);
expect(ctx.truncated).toBe(false);
expect(ctx.text).toContain('## RFIs');
expect(ctx.text).toContain('## Docs');
expect(ctx.text.indexOf('## RFIs')).toBeLessThan(ctx.text.indexOf('## Docs'));
});
it('windows in order and reports truncation when the budget is exceeded', () => {
const blocks = ['x'.repeat(30), 'y'.repeat(30), 'z'.repeat(30)];
const ctx = buildProjectContext([{ title: 'RFIs', blocks }], 60);
expect(ctx.totalBlocks).toBe(3);
expect(ctx.includedBlocks).toBeLessThan(3);
expect(ctx.truncated).toBe(true);
expect(ctx.text).toContain('xxx');
expect(ctx.text).not.toContain('zzz');
expect(ctx.text.length).toBeLessThanOrEqual(60);
});
it('always includes at least the first block, hard-cutting it if it alone overflows', () => {
const huge = 'q'.repeat(500);
const ctx = buildProjectContext([{ title: 'RFIs', blocks: [huge, 'next'] }], 50);
expect(ctx.includedBlocks).toBe(1);
expect(ctx.truncated).toBe(true);
expect(ctx.text.length).toBe(50);
expect(ctx.text.startsWith('## RFIs')).toBe(true);
});
it('skips empty sections without charging a header', () => {
const ctx = buildProjectContext([
{ title: 'Empty', blocks: [] },
{ title: 'RFIs', blocks: ['a'] },
]);
expect(ctx.text).not.toContain('## Empty');
expect(ctx.text).toContain('## RFIs');
expect(ctx.includedBlocks).toBe(1);
});
});
describe('project: contextNote', () => {
it('is honest about no records', () => {
expect(contextNote({ text: '', totalBlocks: 0, includedBlocks: 0, truncated: false })).toMatch(/No project records/);
});
it('reports full coverage', () => {
expect(contextNote({ text: 'x', totalBlocks: 3, includedBlocks: 3, truncated: false })).toMatch(/all 3 items/);
});
it('reports truncation with the counts', () => {
expect(contextNote({ text: 'x', totalBlocks: 10, includedBlocks: 4, truncated: true })).toMatch(/4 of 10 items/);
});
});
describe('project: section builders assemble a real context', () => {
it('turns typed records into a windowed context end to end', () => {
const ctx = buildProjectContext([
rfiSection([rfi]),
submittalSection([{ id: 10, number: 'SUB-01', title: 'Rebar', status: 'Open', type: 'Shop', dueDate: '' }]),
documentSection([{ id: 2, name: 'A-501.pdf', isFolder: false, updatedAt: '' }]),
observationSection([{ id: 20, name: 'Guardrail', status: 'Initiated', priority: 'High', description: '' }]),
dailyLogSection([{ id: 30, date: '2026-06-20', comment: 'Rain delay.', createdBy: 'Super' }]),
]);
expect(ctx.totalBlocks).toBe(5);
expect(ctx.truncated).toBe(false);
expect(ctx.text).toContain('## RFIs');
expect(ctx.text).toContain('RFI 042');
expect(ctx.text).toContain('## Submittals');
expect(ctx.text).toContain('## Documents');
expect(ctx.text).toContain('## Observations');
expect(ctx.text).toContain('## Daily log');
});
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["node"],
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
environment: 'node',
},
});
+1
View File
@@ -21,6 +21,7 @@ packages:
- 'packages/office'
- 'packages/outlook'
- 'packages/pdf'
- 'packages/procore'
- 'packages/raycast'
- 'packages/salesforce'
- 'packages/shopify'