Merge remote-tracking branch 'origin/feat/procore'
This commit is contained in:
@@ -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.*
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 : ''}`;
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['test/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -21,6 +21,7 @@ packages:
|
||||
- 'packages/office'
|
||||
- 'packages/outlook'
|
||||
- 'packages/pdf'
|
||||
- 'packages/procore'
|
||||
- 'packages/raycast'
|
||||
- 'packages/salesforce'
|
||||
- 'packages/shopify'
|
||||
|
||||
Reference in New Issue
Block a user