docs(all): comprehensive documentation overhaul (#57)
Restructure docs/ into architecture/, modules/, and development/ directories. Add thorough documentation for Compass Core platform and HPS Compass modules. Rewrite CLAUDE.md as a lean quick-reference that points to the full docs. Rename files to lowercase, consolidate old docs, add gotchas section. Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
This commit is contained in:
parent
a25c8a26bc
commit
a7494397f2
@ -27,3 +27,4 @@ NETSUITE_CONCURRENCY_LIMIT=15
|
|||||||
GITHUB_TOKEN=your_github_repo_token_here
|
GITHUB_TOKEN=your_github_repo_token_here
|
||||||
GITHUB_REPO=High-Performance-Structures/compass
|
GITHUB_REPO=High-Performance-Structures/compass
|
||||||
|
|
||||||
|
GOOGLE_SERVICE_ACCOUNT_ENCRYPTION_KEY=run openssl rand --hex 32
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -14,6 +14,7 @@ out/
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
credentials/
|
||||||
|
|
||||||
# build
|
# build
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
263
AGENTS.md
Executable file
263
AGENTS.md
Executable file
@ -0,0 +1,263 @@
|
|||||||
|
---
|
||||||
|
Repo: github.com/High-Performance-Structures/compass (public, invite-only)
|
||||||
|
GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n".
|
||||||
|
Branching: `<username>/<feature>` off main
|
||||||
|
Conventional commits: `type(scope): subject`
|
||||||
|
PRs: squash-merged to main
|
||||||
|
Deployment: manual `bun deploy` or automatic through cloudflare/github integration
|
||||||
|
Last Updated: 2026/02/07
|
||||||
|
This file: AGENTS.md -> Symlinked to CLAUDE.md and GEMINI.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
|
||||||
|
This file provides guidance to AI assistants when working on code
|
||||||
|
in this repository. This file is version controlled, and written
|
||||||
|
by both human developers and AI assistants. All changes to this
|
||||||
|
document--and the codebase, for that matter, are expected to be
|
||||||
|
thoughtful, intentional and helpful. This document and/or it's
|
||||||
|
symbolic links should never be overwritten or destroyed without care.
|
||||||
|
and running `/init` is **strongly** discouraged when working with
|
||||||
|
less.. *corrigible* agents.
|
||||||
|
|
||||||
|
Documentation
|
||||||
|
---
|
||||||
|
|
||||||
|
- full documentation lives in `docs/` -- see [docs/README.md](docs/README.md).
|
||||||
|
- Docs content must be generic: no personal device
|
||||||
|
names/hostnames/paths
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docs/
|
||||||
|
├── architecture/ # ai-agent, auth-system, data-layer, native-mobile, overview, server-actions
|
||||||
|
├── auth/ # rate-limits
|
||||||
|
├── development/ # conventions, getting-started, plugins, theming
|
||||||
|
├── google-drive/ # google-drive-integration
|
||||||
|
├── modules/ # financials, google-drive, mobile, netsuite, overview, scheduling
|
||||||
|
├── openclaw-principles/ # discord-integration, memory-system, openclaw-architecture, system-prompt-architecture, system-prompt
|
||||||
|
└── wip/ # guides/, plans/, specs/, spec.json
|
||||||
|
```
|
||||||
|
|
||||||
|
This monorepo is split into modular layers:
|
||||||
|
---
|
||||||
|
|
||||||
|
*this is the goal that we are working towards*
|
||||||
|
|
||||||
|
1. Core: the agentic harness and ui layer. It sustains a secure,
|
||||||
|
enterprise-ready architecture fit for bootstrapping AI powered project
|
||||||
|
management and collaborative dashboards *for any industry*.
|
||||||
|
So other industries can drop in their own modules without touching it.
|
||||||
|
this keeps the architecture flexible and the developer environment clean.
|
||||||
|
|
||||||
|
2. Industries: prebuilt packages, ready-to-launch/deploy, built
|
||||||
|
alongside domain experts from their respective sectors.
|
||||||
|
- **HPS Compass** is construction project management package built on
|
||||||
|
compass core. the architecture is designed so other industries could
|
||||||
|
swap in their own module packages without touching core. And this
|
||||||
|
architecture helps to foster a flexible developer environment.
|
||||||
|
- Other packages are being planned, and contributions are always
|
||||||
|
welcome.
|
||||||
|
|
||||||
|
Deployment:
|
||||||
|
---
|
||||||
|
|
||||||
|
As of writing, (February 2026) For the sake of easy deployment and
|
||||||
|
stress testing, this project is configured to be deployed
|
||||||
|
to Cloudflare Workers via OpenNext. However, in the future we plan
|
||||||
|
to configure this for local deployments.
|
||||||
|
|
||||||
|
|
||||||
|
Build, test, and development commands:
|
||||||
|
---
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun dev # turbopack dev server (check if already running before running yourself. Restart after builds.)
|
||||||
|
bun run build # production build
|
||||||
|
bun preview # test build on cloudflare runtime
|
||||||
|
bun deploy # build and deploy to cloudflare workers
|
||||||
|
bun lint # run eslint
|
||||||
|
```
|
||||||
|
|
||||||
|
database:
|
||||||
|
```bash
|
||||||
|
bun run db:generate # generate drizzle migrations from schema
|
||||||
|
bun run db:migrate:local # apply migrations locally
|
||||||
|
bun run db:migrate:prod # apply migrations to production D1
|
||||||
|
```
|
||||||
|
|
||||||
|
mobile (capacitor):
|
||||||
|
```bash
|
||||||
|
bun cap:sync # sync web assets + plugins to native projects
|
||||||
|
bun cap:ios # open xcode project
|
||||||
|
bun cap:android # open android studio project
|
||||||
|
```
|
||||||
|
|
||||||
|
Style & Conventions
|
||||||
|
---
|
||||||
|
|
||||||
|
- Add brief code comments for tricky or non-obvious logic.
|
||||||
|
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail).
|
||||||
|
Split/refactor when it improves clarity or testability.
|
||||||
|
- **server actions** for all data mutations (`src/app/actions/`). return `{ success: true }` or `{ success: false; error: string }`. revalidate paths after writes. access D1 via `getCloudflareContext()`. see [docs/architecture/server-actions.md](docs/architecture/server-actions.md).
|
||||||
|
- **database**: drizzle ORM with D1 (SQLite). text IDs (UUIDs), text dates (ISO 8601). schema split across 8 files in `src/db/`. add new migrations, never modify old ones. see [docs/architecture/data-layer.md](docs/architecture/data-layer.md).
|
||||||
|
- **auth**: WorkOS for SSO/email/password. middleware in `src/middleware.ts` redirects unauthenticated users. `getCurrentUser()` from `lib/auth.ts` for user info. RBAC via `lib/permissions.ts`. see [docs/architecture/auth-system.md](docs/architecture/auth-system.md).
|
||||||
|
- **ai agent**: OpenRouter provider, tool-first design (queryData, navigateTo, renderComponent, theme tools, plugin tools). unified chat architecture -- one `ChatView` component with `variant="page"` or `variant="panel"`. `ChatProvider` at layout level owns state. see [docs/architecture/ai-agent.md](docs/architecture/ai-agent.md).
|
||||||
|
|
||||||
|
typescript discipline
|
||||||
|
---
|
||||||
|
|
||||||
|
these rules are enforced by convention, not tooling. see [docs/development/conventions.md](docs/development/conventions.md) for the reasoning behind each one.
|
||||||
|
> TODO: Add testing suite
|
||||||
|
|
||||||
|
- no `any` -- use `unknown` with narrowing
|
||||||
|
- no `as` -- fix the types instead of asserting
|
||||||
|
- no `!` -- check for null explicitly
|
||||||
|
- discriminated unions over optional properties
|
||||||
|
- `readonly` everywhere mutation isn't intended
|
||||||
|
- no `enum` -- use `as const` + union types
|
||||||
|
- explicit return types on exported functions
|
||||||
|
- result types over exceptions
|
||||||
|
- effect-free module scope
|
||||||
|
|
||||||
|
tech stack
|
||||||
|
---
|
||||||
|
|
||||||
|
| layer | tech |
|
||||||
|
|-------|------|
|
||||||
|
| framework | Next.js 15 App Router, React 19 |
|
||||||
|
| language | TypeScript 5.x (strict) |
|
||||||
|
| ui | shadcn/ui (new-york style), Tailwind CSS v4 |
|
||||||
|
| database | Cloudflare D1 (SQLite) via Drizzle ORM |
|
||||||
|
| auth | WorkOS (SSO, directory sync) |
|
||||||
|
| ai agent | AI SDK v6 + OpenRouter |
|
||||||
|
| integrations | NetSuite REST API, Google Drive API |
|
||||||
|
| mobile | Capacitor (iOS + Android webview) |
|
||||||
|
| themes | 10 presets + AI-generated custom themes (oklch) |
|
||||||
|
| deployment | Cloudflare Workers via OpenNext |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### HPS modules
|
||||||
|
|
||||||
|
each module contributes schema tables, server actions, components, and optionally agent tools. see [docs/modules/overview.md](docs/modules/overview.md) for the full module system design.
|
||||||
|
|
||||||
|
- **netsuite**: bidirectional ERP sync in `src/lib/netsuite/`. oauth, rate limiting (15 concurrent max), delta sync with conflict resolution. see [docs/modules/netsuite.md](docs/modules/netsuite.md).
|
||||||
|
- **google drive**: domain-wide delegation via service account in `src/lib/google/`. two-layer permissions (compass RBAC + workspace). see [docs/modules/google-drive.md](docs/modules/google-drive.md).
|
||||||
|
- **scheduling**: gantt charts, critical path, baselines in `src/lib/schedule/`. see [docs/modules/scheduling.md](docs/modules/scheduling.md).
|
||||||
|
- **financials**: invoices, vendor bills, payments, credit memos. tied to netsuite sync. see [docs/modules/financials.md](docs/modules/financials.md).
|
||||||
|
- **mobile**: capacitor webview wrapper. the web app must never break because of native code -- all capacitor imports are dynamic, gated behind `isNative()`. see [docs/modules/mobile.md](docs/modules/mobile.md) and [docs/architecture/native-mobile.md](docs/architecture/native-mobile.md).
|
||||||
|
- **themes**: per-user oklch color system, 10 presets, AI-generated custom themes. see [docs/development/theming.md](docs/development/theming.md).
|
||||||
|
- **plugins/skills**: github-hosted SKILL.md files inject into agent system prompt. full plugins provide tools, components, actions. see [docs/development/plugins.md](docs/development/plugins.md).
|
||||||
|
|
||||||
|
|
||||||
|
project structure
|
||||||
|
---
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/
|
||||||
|
│ ├── (auth)/ # auth pages (login, signup, etc)
|
||||||
|
│ ├── api/ # api routes (agent, push, netsuite, auth)
|
||||||
|
│ ├── dashboard/ # protected dashboard routes
|
||||||
|
│ ├── actions/ # server actions (25 files, all mutations)
|
||||||
|
│ ├── globals.css # tailwind + theme variables
|
||||||
|
│ └── layout.tsx # root layout (ChatProvider lives here)
|
||||||
|
├── components/
|
||||||
|
│ ├── ui/ # shadcn/ui primitives
|
||||||
|
│ ├── agent/ # ai chat (ChatView, ChatProvider, ChatPanelShell)
|
||||||
|
│ ├── native/ # capacitor mobile components
|
||||||
|
│ ├── netsuite/ # netsuite connection ui
|
||||||
|
│ ├── files/ # google drive file browser
|
||||||
|
│ ├── financials/ # invoice/bill components
|
||||||
|
│ ├── schedule/ # gantt and scheduling
|
||||||
|
│ └── people/ # user management
|
||||||
|
├── db/
|
||||||
|
│ ├── schema.ts # core tables
|
||||||
|
│ ├── schema-netsuite.ts
|
||||||
|
│ ├── schema-plugins.ts
|
||||||
|
│ ├── schema-theme.ts
|
||||||
|
│ ├── schema-dashboards.ts
|
||||||
|
│ ├── schema-agent.ts
|
||||||
|
│ ├── schema-ai-config.ts
|
||||||
|
│ └── schema-google.ts
|
||||||
|
├── hooks/ # react hooks (chat, native, audio)
|
||||||
|
├── lib/
|
||||||
|
│ ├── agent/ # ai agent harness + plugins/
|
||||||
|
│ ├── google/ # google drive integration
|
||||||
|
│ ├── native/ # capacitor platform detection + photo queue
|
||||||
|
│ ├── netsuite/ # netsuite integration
|
||||||
|
│ ├── push/ # push notification sender
|
||||||
|
│ ├── schedule/ # scheduling computation
|
||||||
|
│ ├── theme/ # theme presets, apply, fonts
|
||||||
|
│ ├── auth.ts # workos integration
|
||||||
|
│ ├── permissions.ts # rbac checks
|
||||||
|
│ └── validations/ # zod schemas
|
||||||
|
└── types/ # global typescript types
|
||||||
|
|
||||||
|
docs/ # full documentation (start with docs/README.md)
|
||||||
|
drizzle/ # database migrations (auto-generated)
|
||||||
|
ios/ # xcode project (capacitor)
|
||||||
|
android/ # android studio project (capacitor)
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
component conventions
|
||||||
|
---
|
||||||
|
|
||||||
|
- shadcn/ui new-york style. add components: `bunx shadcn@latest add <component-name>`
|
||||||
|
- aliases: `@/components`, `@/components/ui`, `@/lib`, `@/hooks`
|
||||||
|
- `cn()` from `@/lib/utils.ts` for conditional class merging
|
||||||
|
- form validation: react-hook-form + zod
|
||||||
|
- icons: lucide-react or @tabler/icons-react
|
||||||
|
- data tables: tanstack/react-table
|
||||||
|
- charts: recharts
|
||||||
|
|
||||||
|
environment variables
|
||||||
|
---
|
||||||
|
|
||||||
|
dev: `.env.local` or `.dev.vars` (both gitignored, identical content in each.) prod: cloudflare dashboard secrets.
|
||||||
|
|
||||||
|
required:
|
||||||
|
- `WORKOS_API_KEY`, `WORKOS_CLIENT_ID` -- authentication
|
||||||
|
- `OPENROUTER_API_KEY` -- ai agent
|
||||||
|
|
||||||
|
> Todo: create friendlier deployment configuration for developers without access to cloudflare,
|
||||||
|
environment variables, and openrouter.
|
||||||
|
|
||||||
|
optional (enable specific modules):
|
||||||
|
- `NETSUITE_CLIENT_ID`, `NETSUITE_CLIENT_SECRET`, `NETSUITE_ACCOUNT_ID`, `NETSUITE_REDIRECT_URI`, `NETSUITE_TOKEN_ENCRYPTION_KEY` -- netsuite sync
|
||||||
|
- `NETSUITE_CONCURRENCY_LIMIT` -- defaults to 15
|
||||||
|
- `GOOGLE_SERVICE_ACCOUNT_ENCRYPTION_KEY` -- google drive
|
||||||
|
- `FCM_SERVER_KEY` -- push notifications
|
||||||
|
|
||||||
|
see [docs/development/getting-started.md](docs/development/getting-started.md) for full setup.
|
||||||
|
|
||||||
|
|
||||||
|
gotchas
|
||||||
|
---
|
||||||
|
|
||||||
|
sharp edges that will cost you debugging time if you don't know about them upfront.
|
||||||
|
|
||||||
|
### ai sdk v6
|
||||||
|
|
||||||
|
- `inputSchema` not `parameters` for `tool()` definitions
|
||||||
|
- `UIMessage` uses `parts` array -- there is no `.content` field
|
||||||
|
- `useChat`: `sendMessage({ text })` not `append({ role, content })`
|
||||||
|
- `useChat`: `status` is `"streaming" | "submitted" | "ready" | "error"`, not `isGenerating`
|
||||||
|
- `useChat`: needs `transport: new DefaultChatTransport({ api })` not `api` prop
|
||||||
|
- zod schemas must use `import { z } from "zod/v4"` to match AI SDK internals
|
||||||
|
- `ToolUIPart`: properties may be flat or nested under `toolInvocation`
|
||||||
|
|
||||||
|
### netsuite
|
||||||
|
|
||||||
|
- 401 errors can mean request timeout, not just auth failure
|
||||||
|
- "field doesn't exist" often means permission denied, not a missing field
|
||||||
|
- 15 concurrent request limit is shared across ALL integrations on the account
|
||||||
|
- no batch create/update via REST -- single record per request
|
||||||
|
- sandbox URLs use different separators (`123456-sb1` in URLs vs `123456_SB1` in account ID)
|
||||||
|
- omitting the `line` param on line items adds a new line instead of updating
|
||||||
|
|
||||||
|
see [docs/modules/netsuite.md](docs/modules/netsuite.md) for full context.
|
||||||
|
|
||||||
|
|
||||||
312
CLAUDE.md
312
CLAUDE.md
@ -1,312 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
compass
|
|
||||||
===
|
|
||||||
|
|
||||||
a construction project management system built with Next.js 15 + React 19, designed to replace BuilderTrend with a lean, self-hosted, open-source alternative. deployed to Cloudflare Workers via OpenNext.
|
|
||||||
|
|
||||||
quick start
|
|
||||||
---
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun dev # turbopack dev server on :3000
|
|
||||||
bun run build # production build
|
|
||||||
bun preview # test build on cloudflare runtime
|
|
||||||
bun deploy # build and deploy to cloudflare workers
|
|
||||||
bun lint # run eslint
|
|
||||||
```
|
|
||||||
|
|
||||||
database commands:
|
|
||||||
```bash
|
|
||||||
bun run db:generate # generate drizzle migrations from schema
|
|
||||||
bun run db:migrate:local # apply migrations locally
|
|
||||||
bun run db:migrate:prod # apply migrations to production D1
|
|
||||||
```
|
|
||||||
|
|
||||||
mobile (capacitor):
|
|
||||||
```bash
|
|
||||||
bun cap:sync # sync web assets + plugins to native projects
|
|
||||||
bun cap:ios # open xcode project
|
|
||||||
bun cap:android # open android studio project
|
|
||||||
```
|
|
||||||
|
|
||||||
tech stack
|
|
||||||
---
|
|
||||||
|
|
||||||
| layer | tech |
|
|
||||||
|-------|------|
|
|
||||||
| framework | Next.js 15 App Router, React 19 |
|
|
||||||
| language | TypeScript 5.x |
|
|
||||||
| ui | shadcn/ui (new-york style), Tailwind CSS v4 |
|
|
||||||
| charts | Recharts |
|
|
||||||
| database | Cloudflare D1 (SQLite) via Drizzle ORM |
|
|
||||||
| auth | WorkOS (SSO, directory sync) |
|
|
||||||
| ai agent | AI SDK v6 + OpenRouter (kimi-k2.5 model) |
|
|
||||||
| integrations | NetSuite REST API, Google Drive API |
|
|
||||||
| mobile | Capacitor (iOS + Android webview) |
|
|
||||||
| themes | 10 presets + AI-generated custom themes (oklch) |
|
|
||||||
| state | React Context, server actions |
|
|
||||||
|
|
||||||
|
|
||||||
critical architecture patterns
|
|
||||||
---
|
|
||||||
|
|
||||||
### server actions & data flow
|
|
||||||
- all data mutations go through server actions in `src/app/actions/`
|
|
||||||
- pattern: `return { success: true } | { success: false; error: string }`
|
|
||||||
- server actions revalidate paths with `revalidatePath()` to update client
|
|
||||||
- no fetch() in components - use actions instead
|
|
||||||
- environment variables accessed via `getCloudflareContext()` → `env.DB` for D1
|
|
||||||
|
|
||||||
### database
|
|
||||||
- drizzle ORM with D1 (SQLite dialect)
|
|
||||||
- text IDs (UUIDs), text dates (ISO 8601 format)
|
|
||||||
- migrations in `drizzle/` directory - **add new migrations, never modify old ones**
|
|
||||||
- schema files:
|
|
||||||
- `src/db/schema.ts` - core tables (users, projects, customers, vendors, etc.)
|
|
||||||
- `src/db/schema-netsuite.ts` - netsuite sync tables
|
|
||||||
- `src/db/schema-plugins.ts` - plugins, plugin_config, plugin_events
|
|
||||||
- `src/db/schema-theme.ts` - custom_themes, user_theme_preference
|
|
||||||
|
|
||||||
### authentication & middleware
|
|
||||||
- WorkOS handles SSO, email/password, directory sync
|
|
||||||
- middleware in `src/middleware.ts` checks session and redirects unauthenticated users to `/login`
|
|
||||||
- public paths: `/`, `/login`, `/signup`, `/reset-password`, `/verify-email`, `/invite`, `/callback`, `/api/auth/*`, `/api/netsuite/*`
|
|
||||||
- `getCurrentUser()` from `lib/auth.ts` returns user info with database lookup fallback
|
|
||||||
|
|
||||||
### ai agent harness
|
|
||||||
- located in `src/lib/agent/` - a complete AI-assisted system
|
|
||||||
- `provider.ts`: OpenRouter setup for kimi-k2.5 model
|
|
||||||
- `tools.ts`: queryData, navigateTo, showNotification, renderComponent, plus theme tools (listThemes, setTheme, generateTheme, editTheme) and plugin tools (installSkill, uninstallSkill, toggleInstalledSkill, listInstalledSkills)
|
|
||||||
- `system-prompt.ts`: dynamic prompt builder with page/user context
|
|
||||||
- `catalog.ts`: component specs for DynamicUI rendering
|
|
||||||
- `chat-adapter.ts`: getTextFromParts, action registry, tool dispatch
|
|
||||||
- `src/app/api/agent/route.ts`: streamText endpoint with 10-step multi-tool loop
|
|
||||||
- `src/app/actions/agent.ts`: D1 persistence (save/load/delete conversations)
|
|
||||||
- unified chat architecture: one component, two presentations
|
|
||||||
- `ChatProvider` (layout level) owns chat state + panel open/close + persistence
|
|
||||||
- `ChatView variant="page"` on /dashboard (hero idle, typewriter, stats)
|
|
||||||
- `ChatView variant="panel"` in `ChatPanelShell` on all other pages
|
|
||||||
- `src/hooks/use-compass-chat.ts`: shared hook (useChat + action handlers + tool dispatch)
|
|
||||||
- chat state persists across navigation
|
|
||||||
- usage tracking: `agent_config`, `agent_usage`, `user_model_preference` tables track tokens/costs per conversation, per-user model override if admin allows
|
|
||||||
- ai sdk v6 gotchas:
|
|
||||||
- `inputSchema` not `parameters` for tool() definitions
|
|
||||||
- UIMessage uses `parts` array, no `.content` field
|
|
||||||
- useChat: `sendMessage({ text })` not `append({ role, content })`
|
|
||||||
- useChat: `status` is "streaming"|"submitted"|"ready"|"error", not `isGenerating`
|
|
||||||
- useChat: needs `transport: new DefaultChatTransport({ api })` not `api` prop
|
|
||||||
- zod schemas must use `import { z } from "zod/v4"` to match AI SDK internals
|
|
||||||
- ToolUIPart: properties may be flat or nested under toolInvocation
|
|
||||||
|
|
||||||
### netsuite integration
|
|
||||||
- full bidirectional sync via REST API
|
|
||||||
- key files in `src/lib/netsuite/`:
|
|
||||||
- `config.ts`: account setup, URL builders
|
|
||||||
- `auth/`: oauth 2.0 flow, token manager, AES-GCM encryption
|
|
||||||
- `client/`: base HTTP client (retry, circuit breaker), record client, suiteql client
|
|
||||||
- `rate-limiter/`: semaphore-based concurrency limiter (15 concurrent default)
|
|
||||||
- `sync/`: sync engine, delta sync, conflict resolver, push logic, idempotency
|
|
||||||
- `mappers/`: customer, vendor, project, invoice, vendor-bill mappers
|
|
||||||
- env vars: NETSUITE_CLIENT_ID, NETSUITE_CLIENT_SECRET, NETSUITE_ACCOUNT_ID, NETSUITE_REDIRECT_URI, NETSUITE_TOKEN_ENCRYPTION_KEY, NETSUITE_CONCURRENCY_LIMIT
|
|
||||||
- gotchas:
|
|
||||||
- 401 errors can mean timeout, not auth failure
|
|
||||||
- "field doesn't exist" often means permission denied
|
|
||||||
- 15 concurrent request limit shared across ALL integrations
|
|
||||||
- no batch create/update via REST (single record per request)
|
|
||||||
- sandbox URLs use different separators (123456-sb1 vs 123456_SB1)
|
|
||||||
- omitting "line" param on line items adds new line (doesn't update)
|
|
||||||
|
|
||||||
### capacitor mobile app
|
|
||||||
- webview-based native wrapper loading the live cloudflare deployment (not a static export)
|
|
||||||
- the web app must never break because of native code: all capacitor imports are dynamic (`await import()` inside effects), gated behind `isNative()` checks, components return `null` on web
|
|
||||||
- platform detection: `src/lib/native/platform.ts` exports `isNative()`, `isIOS()`, `isAndroid()`
|
|
||||||
- native hooks in `src/hooks/`: `use-native.ts`, `use-native-push.ts`, `use-native-camera.ts`, `use-biometric-auth.ts`, `use-photo-queue.ts`
|
|
||||||
- native components in `src/components/native/`: biometric lock screen, offline banner, status bar sync, upload queue indicator, push registration
|
|
||||||
- offline photo queue (`src/lib/native/photo-queue.ts`): survives app kill, uses filesystem + preferences + background uploader
|
|
||||||
- push notifications via FCM HTTP v1 (`src/lib/push/send.ts`), routes to both iOS APNS and Android FCM
|
|
||||||
- `src/app/api/push/register/route.ts`: POST/DELETE for device token management
|
|
||||||
- env: FCM_SERVER_KEY
|
|
||||||
- see `docs/native-mobile.md` for full iOS/Android setup and app store submission guide
|
|
||||||
|
|
||||||
### plugin/skills system
|
|
||||||
- agent can install external "skills" (github-hosted SKILL.md files in skills.sh format) or full plugin modules
|
|
||||||
- skills inject system prompt sections at priority 80, full plugins can also provide tools, components, query types, and action handlers
|
|
||||||
- source types: `builtin`, `local`, `npm`, `skills`
|
|
||||||
- key files in `src/lib/agent/plugins/`:
|
|
||||||
- `types.ts`: PluginManifest, PluginModule, SkillFrontmatter
|
|
||||||
- `skills-client.ts`: fetchSkillFromGitHub, parseSkillMd
|
|
||||||
- `loader.ts`: loadPluginModule (local/npm/builtin)
|
|
||||||
- `registry.ts`: buildRegistry, getRegistry (30s TTL cache per worker isolate)
|
|
||||||
- `src/app/actions/plugins.ts`: installSkill, uninstallSkill, toggleSkill, getInstalledSkills
|
|
||||||
- database: `plugins`, `plugin_config`, `plugin_events` tables in `src/db/schema-plugins.ts`
|
|
||||||
|
|
||||||
### visual theme system
|
|
||||||
- per-user themeable UI with 10 built-in presets + AI-generated custom themes
|
|
||||||
- themes are full oklch color maps (32 keys for light + dark), fonts (sans/serif/mono with google fonts), design tokens (radius, spacing, shadows)
|
|
||||||
- presets: native-compass (default), corpo, notebook, doom-64, bubblegum, developers-choice, anslopics-clood, violet-bloom, soy, mocha
|
|
||||||
- key files in `src/lib/theme/`:
|
|
||||||
- `types.ts`: ThemeDefinition, ColorMap, ThemeFonts, ThemeTokens
|
|
||||||
- `presets.ts`: all 10 preset definitions
|
|
||||||
- `apply.ts`: `applyTheme()` injects css vars into `<style id="compass-theme-vars">`, instant without page reload
|
|
||||||
- `fonts.ts`: `loadGoogleFonts()` dynamic `<link>` injection
|
|
||||||
- `src/app/actions/themes.ts`: getUserThemePreference, setUserThemePreference, saveCustomTheme, deleteCustomTheme
|
|
||||||
- database: `custom_themes`, `user_theme_preference` tables in `src/db/schema-theme.ts`
|
|
||||||
|
|
||||||
### google drive integration
|
|
||||||
- domain-wide delegation via service account (impersonates users by email)
|
|
||||||
- two-layer permissions: compass RBAC first, then google workspace permissions
|
|
||||||
- bidirectional: browse drive files in compass, upload from compass to drive, supports shared drives
|
|
||||||
- key files in `src/lib/google/`:
|
|
||||||
- `auth/service-account.ts`: JWT creation, token exchange (web crypto API, RS256)
|
|
||||||
- `client/drive-client.ts`: REST client with retry, rate limiting, impersonation
|
|
||||||
- `mapper.ts`: DriveFile -> FileItem
|
|
||||||
- `src/app/actions/google-drive.ts`: 17 server actions (connect, disconnect, list, search, create, rename, move, trash, restore, upload, etc.)
|
|
||||||
- file browser UI in `src/components/files/`
|
|
||||||
- database: `google_auth`, `google_starred_files` tables, `users.google_email` column
|
|
||||||
- env: GOOGLE_SERVICE_ACCOUNT_ENCRYPTION_KEY
|
|
||||||
- see `docs/google-drive/GOOGLE-DRIVE-INTEGRATION.md` for full setup guide
|
|
||||||
|
|
||||||
### typescript discipline
|
|
||||||
- strict types: no `any`, no `as`, no `!` - use `unknown` with proper narrowing
|
|
||||||
- discriminated unions over optional properties: `{ status: 'ok'; data: T } | { status: 'error'; error: E }`
|
|
||||||
- `readonly` everywhere mutation isn't intended: `ReadonlyArray<T>`, `Readonly<Record<K, V>>`
|
|
||||||
- no `enum` - use `as const` objects or union types instead
|
|
||||||
- branded types for primitive identifiers to prevent mixing up IDs of different types
|
|
||||||
- explicit return types on all exported functions
|
|
||||||
- result types over exceptions: return `{ success: true } | { success: false; error }` from actions
|
|
||||||
- effect-free module scope: no `console.log`, `fetch`, or mutations during import
|
|
||||||
|
|
||||||
project structure
|
|
||||||
---
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── app/ # next.js app router
|
|
||||||
│ ├── (auth)/ # auth pages (login, signup, etc)
|
|
||||||
│ ├── api/ # api routes (agent, push, netsuite, auth)
|
|
||||||
│ ├── dashboard/ # protected dashboard routes
|
|
||||||
│ ├── actions/ # server actions (data mutations)
|
|
||||||
│ ├── globals.css # tailwind + theme variables
|
|
||||||
│ ├── layout.tsx # root layout
|
|
||||||
│ └── page.tsx # home page
|
|
||||||
├── components/ # react components
|
|
||||||
│ ├── ui/ # shadcn/ui primitives (auto-generated)
|
|
||||||
│ ├── agent/ # ai chat (ChatView, ChatProvider, ChatPanelShell)
|
|
||||||
│ ├── native/ # capacitor mobile components
|
|
||||||
│ ├── netsuite/ # netsuite connection ui
|
|
||||||
│ ├── files/ # google drive file browser
|
|
||||||
│ └── *.tsx # app-specific components
|
|
||||||
├── db/
|
|
||||||
│ ├── index.ts # getDb() function
|
|
||||||
│ ├── schema.ts # core drizzle schema
|
|
||||||
│ ├── schema-netsuite.ts # netsuite sync tables
|
|
||||||
│ ├── schema-plugins.ts # plugin/skills tables
|
|
||||||
│ └── schema-theme.ts # theme tables
|
|
||||||
├── hooks/ # custom react hooks (incl. native hooks)
|
|
||||||
├── lib/
|
|
||||||
│ ├── agent/ # ai agent harness + plugins/
|
|
||||||
│ ├── google/ # google drive integration
|
|
||||||
│ ├── native/ # capacitor platform detection + photo queue
|
|
||||||
│ ├── netsuite/ # netsuite integration
|
|
||||||
│ ├── push/ # push notification sender
|
|
||||||
│ ├── theme/ # theme presets, apply, fonts
|
|
||||||
│ ├── auth.ts # workos integration
|
|
||||||
│ ├── permissions.ts # rbac checks
|
|
||||||
│ ├── utils.ts # cn() for class merging
|
|
||||||
│ └── validations/ # zod schemas
|
|
||||||
└── types/ # global typescript types
|
|
||||||
|
|
||||||
ios/ # xcode project (capacitor)
|
|
||||||
android/ # android studio project (capacitor)
|
|
||||||
drizzle/ # database migrations (auto-generated)
|
|
||||||
docs/ # user documentation
|
|
||||||
public/ # static assets
|
|
||||||
capacitor.config.ts # capacitor native config
|
|
||||||
wrangler.jsonc # cloudflare workers config
|
|
||||||
drizzle.config.ts # drizzle orm config
|
|
||||||
next.config.ts # next.js config
|
|
||||||
tsconfig.json # typescript config
|
|
||||||
```
|
|
||||||
|
|
||||||
component conventions
|
|
||||||
---
|
|
||||||
|
|
||||||
shadcn/ui setup:
|
|
||||||
- new-york style
|
|
||||||
- add components: `bunx shadcn@latest add <component-name>`
|
|
||||||
- aliases: `@/components`, `@/components/ui`, `@/lib`, `@/hooks`
|
|
||||||
|
|
||||||
ui patterns:
|
|
||||||
- use `cn()` from `@/lib/utils.ts` for conditional classes
|
|
||||||
- form validation via react-hook-form + zod
|
|
||||||
- animations via framer-motion or tailwind css
|
|
||||||
- icons from lucide-react or @tabler/icons-react (both configured with package import optimization)
|
|
||||||
- data tables via tanstack/react-table
|
|
||||||
|
|
||||||
environment variables
|
|
||||||
---
|
|
||||||
|
|
||||||
dev: `.dev.vars` (gitignored)
|
|
||||||
prod: cloudflare dashboard secrets
|
|
||||||
|
|
||||||
required:
|
|
||||||
- WORKOS_API_KEY, WORKOS_CLIENT_ID
|
|
||||||
- OPENROUTER_API_KEY (for ai agent)
|
|
||||||
- NETSUITE_* (if using netsuite sync)
|
|
||||||
- GOOGLE_SERVICE_ACCOUNT_ENCRYPTION_KEY (if using google drive)
|
|
||||||
- FCM_SERVER_KEY (if using push notifications)
|
|
||||||
|
|
||||||
development tips
|
|
||||||
---
|
|
||||||
|
|
||||||
### accessing database in actions
|
|
||||||
```typescript
|
|
||||||
import { getCloudflareContext } from "@opennextjs/cloudflare"
|
|
||||||
const { env } = await getCloudflareContext()
|
|
||||||
const db = env.DB // D1 binding
|
|
||||||
```
|
|
||||||
|
|
||||||
### using getCurrentUser() in actions
|
|
||||||
```typescript
|
|
||||||
import { getCurrentUser } from "@/lib/auth"
|
|
||||||
const user = await getCurrentUser()
|
|
||||||
if (!user) throw new Error("Not authenticated")
|
|
||||||
```
|
|
||||||
|
|
||||||
### revalidating paths after mutations
|
|
||||||
```typescript
|
|
||||||
import { revalidatePath } from "next/cache"
|
|
||||||
// after changing data
|
|
||||||
revalidatePath("/dashboard/projects") // specific path
|
|
||||||
revalidatePath("/", "layout") // entire layout
|
|
||||||
```
|
|
||||||
|
|
||||||
### querying data in components
|
|
||||||
- server components can use `getDb()` directly
|
|
||||||
- client components must call server actions
|
|
||||||
- never `fetch()` from client components - use actions
|
|
||||||
|
|
||||||
### type guards for discriminated unions
|
|
||||||
don't use `as` - write proper type guards:
|
|
||||||
```typescript
|
|
||||||
function isError<E>(result: { success: boolean; error?: E }): result is { success: false; error: E } {
|
|
||||||
return !result.success && result.error !== undefined
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
known issues & WIP
|
|
||||||
---
|
|
||||||
|
|
||||||
- gantt chart vertical panning: zoom/horizontal pan work, but vertical pan conflicts with frappe-gantt container sizing. needs transform-based rendering approach with fixed header.
|
|
||||||
|
|
||||||
open source contribution notes
|
|
||||||
---
|
|
||||||
|
|
||||||
- repo at github.com/High-Performance-Structures/compass (private, invite-only)
|
|
||||||
- branching: `<username>/<feature>` off main
|
|
||||||
- conventional commits: `type(scope): subject`
|
|
||||||
- PRs are squash-merged to main
|
|
||||||
- deployment to cloudflare is manual via `bun deploy`
|
|
||||||
60
docs/README.md
Executable file
60
docs/README.md
Executable file
@ -0,0 +1,60 @@
|
|||||||
|
Compass Documentation
|
||||||
|
===
|
||||||
|
|
||||||
|
Compass is two things: a platform and a product.
|
||||||
|
|
||||||
|
**Compass Core** is an agentic dashboard platform -- authentication, an AI assistant, visual theming, a plugin system, and custom dashboards. It's built with Next.js 15, React 19, Cloudflare D1, and the AI SDK. It's generic. Any industry could use it.
|
||||||
|
|
||||||
|
**HPS Compass** is a construction project management product built on top of Compass Core. It adds scheduling with Gantt charts, financial tracking tied to NetSuite, Google Drive integration for project documents, and a Capacitor mobile app for field workers. It's specific to construction, but the architecture is designed so other industries could build their own module packages.
|
||||||
|
|
||||||
|
|
||||||
|
architecture
|
||||||
|
---
|
||||||
|
|
||||||
|
How the core platform works.
|
||||||
|
|
||||||
|
- [overview](architecture/overview.md) -- the two-layer architecture, tech stack, project structure, how everything connects
|
||||||
|
- [data layer](architecture/data-layer.md) -- Drizzle ORM on Cloudflare D1, schema conventions, migration workflow
|
||||||
|
- [server actions](architecture/server-actions.md) -- the data mutation pattern, auth checks, error handling, revalidation
|
||||||
|
- [auth system](architecture/auth-system.md) -- WorkOS integration, middleware, session management, RBAC
|
||||||
|
- [AI agent](architecture/ai-agent.md) -- OpenRouter provider, tool system, system prompt, unified chat architecture, usage tracking
|
||||||
|
|
||||||
|
|
||||||
|
modules
|
||||||
|
---
|
||||||
|
|
||||||
|
The construction-specific modules that make up HPS Compass.
|
||||||
|
|
||||||
|
- [overview](modules/overview.md) -- what the module system is, core vs module boundary, how modules integrate
|
||||||
|
- [netsuite](modules/netsuite.md) -- bidirectional ERP sync: OAuth, HTTP client, rate limiter, sync engine, mappers, gotchas
|
||||||
|
- [google drive](modules/google-drive.md) -- domain-wide delegation, JWT auth, drive client, two-layer permissions, file browser
|
||||||
|
- [scheduling](modules/scheduling.md) -- Gantt charts, critical path analysis, dependency management, baselines, workday exceptions
|
||||||
|
- [financials](modules/financials.md) -- invoices, vendor bills, payments, credit memos, NetSuite sync tie-in
|
||||||
|
- [mobile](modules/mobile.md) -- Capacitor native app, offline photo queue, push notifications, biometric auth
|
||||||
|
|
||||||
|
|
||||||
|
development
|
||||||
|
---
|
||||||
|
|
||||||
|
How to work on Compass.
|
||||||
|
|
||||||
|
- [getting started](development/getting-started.md) -- local setup, environment variables, dev server, database, deployment
|
||||||
|
- [conventions](development/conventions.md) -- TypeScript discipline, component patterns, file organization
|
||||||
|
- [theming](development/theming.md) -- oklch color system, preset themes, custom theme generation, how applyTheme works
|
||||||
|
- [plugins](development/plugins.md) -- skills system, plugin manifests, registry, building new plugins
|
||||||
|
|
||||||
|
|
||||||
|
quick reference
|
||||||
|
---
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun dev # dev server on :3000
|
||||||
|
bun run build # production build
|
||||||
|
bun deploy # build + deploy to cloudflare workers
|
||||||
|
bun run db:generate # generate migrations from schema
|
||||||
|
bun run db:migrate:local # apply migrations locally
|
||||||
|
bun run db:migrate:prod # apply migrations to production
|
||||||
|
bun lint # eslint
|
||||||
|
```
|
||||||
|
|
||||||
|
See [getting started](development/getting-started.md) for full setup instructions.
|
||||||
422
docs/architecture/ai-agent.md
Executable file
422
docs/architecture/ai-agent.md
Executable file
@ -0,0 +1,422 @@
|
|||||||
|
AI Agent
|
||||||
|
===
|
||||||
|
|
||||||
|
The AI agent is the centerpiece of Compass. It's not a chatbot bolted onto a CRUD app -- it's the primary interface for interacting with the platform. Users ask it to pull data, navigate pages, build dashboards, manage themes, install skills, and remember preferences. The agent has tools for all of these things, and the system prompt tells it when to use each one.
|
||||||
|
|
||||||
|
This document covers the full stack: provider configuration, tool definitions, system prompt architecture, the API route, chat persistence, the unified chat UI architecture, and AI SDK v6 patterns.
|
||||||
|
|
||||||
|
|
||||||
|
provider setup
|
||||||
|
---
|
||||||
|
|
||||||
|
Compass routes all LLM calls through OpenRouter, which means any model OpenRouter supports can be the agent's brain. The provider configuration lives in `src/lib/agent/provider.ts`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||||
|
|
||||||
|
export const DEFAULT_MODEL_ID = "qwen/qwen3-coder-next"
|
||||||
|
|
||||||
|
export function createModelFromId(apiKey: string, modelId: string) {
|
||||||
|
const openrouter = createOpenRouter({ apiKey })
|
||||||
|
return openrouter(modelId, {
|
||||||
|
provider: { allow_fallbacks: false },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`allow_fallbacks: false` is deliberate. OpenRouter can silently fall back to a different model if the requested one is unavailable. This would break the cost tracking and prompt tuning, so we disable it.
|
||||||
|
|
||||||
|
The model is configurable at two levels:
|
||||||
|
|
||||||
|
1. **Global config.** A singleton row in `agent_config` (id = "global") sets the default model for all users. Admins can change this through the settings UI.
|
||||||
|
|
||||||
|
2. **Per-user override.** Users can select their own model via `user_model_preference`. The override is subject to a cost ceiling -- if the admin sets `maxCostPerMillion` in the global config, user-selected models that exceed this ceiling are silently downgraded to the global default.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function resolveModelForUser(
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
userId: string
|
||||||
|
): Promise<string> {
|
||||||
|
const config = await db
|
||||||
|
.select()
|
||||||
|
.from(agentConfig)
|
||||||
|
.where(eq(agentConfig.id, "global"))
|
||||||
|
.get()
|
||||||
|
|
||||||
|
if (!config) return DEFAULT_MODEL_ID
|
||||||
|
|
||||||
|
const pref = await db
|
||||||
|
.select()
|
||||||
|
.from(userModelPreference)
|
||||||
|
.where(eq(userModelPreference.userId, userId))
|
||||||
|
.get()
|
||||||
|
|
||||||
|
if (!pref) return config.modelId
|
||||||
|
|
||||||
|
// enforce cost ceiling
|
||||||
|
if (ceiling !== null) {
|
||||||
|
const outputPerMillion = parseFloat(pref.completionCost) * 1_000_000
|
||||||
|
if (outputPerMillion > ceiling) return config.modelId
|
||||||
|
}
|
||||||
|
|
||||||
|
return pref.modelId
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This design means the admin controls the budget, and users control the experience within that budget.
|
||||||
|
|
||||||
|
|
||||||
|
tools
|
||||||
|
---
|
||||||
|
|
||||||
|
The agent's tools are defined in `src/lib/agent/tools.ts`. Each tool uses the AI SDK's `tool()` function with a Zod v4 schema for input validation.
|
||||||
|
|
||||||
|
The tools break into categories:
|
||||||
|
|
||||||
|
**Data access**
|
||||||
|
|
||||||
|
- `queryData` -- queries the database for customers, vendors, projects, invoices, vendor bills, schedule tasks, or record details. Takes a `queryType` enum, optional `search` string, optional `id` for detail queries, and optional `limit`. This is the agent's read interface to the application database.
|
||||||
|
|
||||||
|
**Navigation**
|
||||||
|
|
||||||
|
- `navigateTo` -- tells the client to navigate to a specific page. Validates against a whitelist of routes using regex patterns. Returns `{ action: "navigate", path, reason }` which the client-side action dispatcher intercepts and executes.
|
||||||
|
|
||||||
|
**UI generation**
|
||||||
|
|
||||||
|
- `generateUI` -- the most powerful tool. Takes a text description and optional data context, returns `{ action: "generateUI", renderPrompt, dataContext }`. The client intercepts this, sends the render prompt to a separate `/api/agent/render` endpoint that generates a JSON UI spec (json-render format), and streams the result into the dashboard area.
|
||||||
|
|
||||||
|
- `saveDashboard` / `listDashboards` / `editDashboard` / `deleteDashboard` -- CRUD for persisted dashboards built with `generateUI`.
|
||||||
|
|
||||||
|
**Notifications**
|
||||||
|
|
||||||
|
- `showNotification` -- triggers a toast notification on the client.
|
||||||
|
|
||||||
|
**Memory**
|
||||||
|
|
||||||
|
- `rememberContext` -- saves a preference, decision, fact, or workflow to persistent memory (the `slab_memories` table). The system prompt instructs the agent to use this proactively when users share information worth retaining.
|
||||||
|
|
||||||
|
- `recallMemory` -- searches persistent memories by keyword. Used when the user asks "do you remember..." or when the agent needs to look up a past preference.
|
||||||
|
|
||||||
|
**Skills/Plugins**
|
||||||
|
|
||||||
|
- `installSkill` / `uninstallSkill` / `toggleInstalledSkill` / `listInstalledSkills` -- manage the plugin/skills system. Install and uninstall require admin role.
|
||||||
|
|
||||||
|
**Theming**
|
||||||
|
|
||||||
|
- `listThemes` / `setTheme` -- list available themes and switch the active one.
|
||||||
|
- `generateTheme` -- create a custom theme from scratch. Accepts complete light/dark oklch color maps (32 keys each), font families, optional Google Font names, and design tokens. Saves to the database and returns a preview action.
|
||||||
|
- `editTheme` -- incrementally edit an existing custom theme. Only changed properties need to be provided; the rest are preserved via deep merge.
|
||||||
|
|
||||||
|
All tools follow the same pattern: validate input via Zod schema, do the work (query DB, check permissions), return an action object that the client-side dispatcher handles. The agent never directly manipulates the DOM or calls browser APIs -- it returns declarative action objects that the client interprets.
|
||||||
|
|
||||||
|
|
||||||
|
system prompt architecture
|
||||||
|
---
|
||||||
|
|
||||||
|
The system prompt is assembled by `buildSystemPrompt()` in `src/lib/agent/system-prompt.ts`. This follows the same section-builder pattern documented in OpenClaw's architecture: independent functions each return a string array, and the assembler concatenates and filters.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function buildSystemPrompt(ctx: PromptContext): string {
|
||||||
|
const state = computeDerivedState(ctx)
|
||||||
|
|
||||||
|
const sections: ReadonlyArray<ReadonlyArray<string>> = [
|
||||||
|
buildIdentity(state.mode),
|
||||||
|
buildUserContext(ctx, state),
|
||||||
|
buildMemoryContext(ctx, state.mode),
|
||||||
|
buildFirstInteraction(state.mode, state.page),
|
||||||
|
buildDomainKnowledge(state.mode),
|
||||||
|
buildToolDocs(state.tools),
|
||||||
|
buildCatalogSection(state.mode, state.catalogComponents),
|
||||||
|
buildInterviewProtocol(state.mode),
|
||||||
|
buildGitHubGuidance(state.mode),
|
||||||
|
buildThemingRules(state.mode),
|
||||||
|
buildDashboardRules(ctx, state.mode),
|
||||||
|
buildGuidelines(state.mode),
|
||||||
|
buildPluginSections(ctx.pluginSections, state.mode),
|
||||||
|
]
|
||||||
|
|
||||||
|
return sections
|
||||||
|
.filter((s) => s.length > 0)
|
||||||
|
.map((s) => s.join("\n"))
|
||||||
|
.join("\n\n")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prompt modes.** Three modes control how much of the prompt is included:
|
||||||
|
|
||||||
|
- `"full"` -- everything. Used for the main chat interaction.
|
||||||
|
- `"minimal"` -- only data, navigation, and UI tools. Strips memory, domain knowledge, interview protocol, theming rules, and plugin sections.
|
||||||
|
- `"none"` -- a single identity line. For cases where injected context does the heavy lifting.
|
||||||
|
|
||||||
|
**The sections in detail:**
|
||||||
|
|
||||||
|
*Identity* -- "You are Dr. Slab Diggems, the AI assistant built into Compass." The agent has a name and a personality: reliable, direct, always ready to help.
|
||||||
|
|
||||||
|
*User context* -- injects the user's name, role, current page, current date/time, and timezone. This is what makes the agent aware of who it's talking to and where they are in the app.
|
||||||
|
|
||||||
|
*Memory context* -- in full mode, includes the user's saved memories. If no memories exist, the prompt tells the agent to start saving them when relevant information appears.
|
||||||
|
|
||||||
|
*First interaction* -- suggestions for what the agent can do when a user first messages. Tailored to the current page (project page gets project suggestions, financials page gets invoice suggestions).
|
||||||
|
|
||||||
|
*Domain knowledge* -- construction management terminology. The agent knows about phases, change orders, submittals, RFIs, and punch lists.
|
||||||
|
|
||||||
|
*Tool docs* -- auto-generated from a `TOOL_REGISTRY` array. Each tool gets a name, summary, category, and optional `adminOnly` flag. In minimal mode, only data/navigation/UI tools are included. Admin-only tools are filtered out for non-admin users.
|
||||||
|
|
||||||
|
*Catalog section* -- lists the components available for `generateUI` (DataTable, StatCard, BarChart, Form, Input, Checkbox, etc.) with usage examples for interactive patterns (creating records, editing, inline toggles, row actions).
|
||||||
|
|
||||||
|
*Interview protocol* -- instructions for conducting UX research interviews. The agent asks one question at a time, covers specific areas, and saves results via `saveInterviewFeedback`.
|
||||||
|
|
||||||
|
*GitHub guidance* -- rate limit awareness and instructions to translate developer jargon into business language for construction professionals.
|
||||||
|
|
||||||
|
*Theming rules* -- detailed instructions for `generateTheme` (all 32 oklch color keys, contrast requirements, chart color distinctness) and `editTheme` (partial updates, deep merge behavior).
|
||||||
|
|
||||||
|
*Dashboard rules* -- workflow for building, saving, editing, and loading custom dashboards. Includes limits (5 per user) and UX guidance (when to offer saving).
|
||||||
|
|
||||||
|
*Guidelines* -- behavioral rules. The most important: "ACT FIRST, don't ask." When the user requests data, the agent should call `queryData` immediately, not ask clarifying questions. This is the difference between a helpful tool and an annoying chatbot.
|
||||||
|
|
||||||
|
*Plugin sections* -- injected at priority 80 from installed skills. Each skill's SKILL.md content gets parsed and added as a prompt section.
|
||||||
|
|
||||||
|
|
||||||
|
the API route
|
||||||
|
---
|
||||||
|
|
||||||
|
The streaming endpoint lives at `src/app/api/agent/route.ts`. It handles a single concern: take messages in, stream responses out.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function POST(req: Request): Promise<Response> {
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user) return new Response("Unauthorized", { status: 401 })
|
||||||
|
|
||||||
|
const { env, ctx } = await getCloudflareContext()
|
||||||
|
const db = getDb(env.DB)
|
||||||
|
|
||||||
|
// resolve model, load memories, get plugin registry, fetch dashboards
|
||||||
|
const [memories, registry, dashboardResult] =
|
||||||
|
await Promise.all([
|
||||||
|
loadMemoriesForPrompt(db, user.id),
|
||||||
|
getRegistry(db, envRecord),
|
||||||
|
getCustomDashboards(),
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = streamText({
|
||||||
|
model,
|
||||||
|
system: buildSystemPrompt({ /* full context */ }),
|
||||||
|
messages: await convertToModelMessages(body.messages),
|
||||||
|
tools: {
|
||||||
|
...agentTools,
|
||||||
|
...githubTools,
|
||||||
|
...pluginTools,
|
||||||
|
},
|
||||||
|
toolChoice: "auto",
|
||||||
|
stopWhen: stepCountIs(10),
|
||||||
|
onError({ error }) { /* log with model context */ },
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.waitUntil(
|
||||||
|
saveStreamUsage(db, conversationId, user.id, modelId, result)
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.toUIMessageStreamResponse({ /* error mapping */ })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key details:
|
||||||
|
|
||||||
|
**Parallel loading.** Memories, plugin registry, and dashboard data are loaded concurrently with `Promise.all()`. This cuts the cold-start latency by ~60% compared to sequential loading.
|
||||||
|
|
||||||
|
**Multi-tool loop.** `stopWhen: stepCountIs(10)` allows the agent up to 10 back-and-forth steps (call tool, get result, call another tool, etc.) before the response is finalized. This is what enables complex workflows like "query data, then build a dashboard with it."
|
||||||
|
|
||||||
|
**Plugin tool injection.** The plugin registry provides additional tools from installed skills. These are spread into the tools object alongside the built-in tools, so the agent can use them transparently.
|
||||||
|
|
||||||
|
**Usage tracking.** `saveStreamUsage()` runs via `ctx.waitUntil()` so it doesn't block the response stream. It records token counts and cost estimates per invocation.
|
||||||
|
|
||||||
|
**Client headers.** The request includes `x-current-page`, `x-timezone`, and `x-conversation-id` as custom headers. These flow into the system prompt context so the agent knows the user's current location and timezone.
|
||||||
|
|
||||||
|
**Error handling.** The `onError` callback unwraps `APICallError` (from the provider) and `RetryError` (from the SDK's retry logic) to log meaningful error messages with model context. The `toUIMessageStreamResponse` error handler maps these to user-facing error strings.
|
||||||
|
|
||||||
|
|
||||||
|
chat persistence
|
||||||
|
---
|
||||||
|
|
||||||
|
Conversations are persisted to D1 via server actions in `src/app/actions/agent.ts`:
|
||||||
|
|
||||||
|
- `saveConversation(conversationId, messages, title?)` -- upserts the conversation row and replaces all message rows. The delete-and-reinsert pattern is simpler than diffing.
|
||||||
|
- `loadConversations()` -- returns the user's 20 most recent conversations, ordered by last message time.
|
||||||
|
- `loadConversation(conversationId)` -- returns all messages for a conversation, with parts metadata restored from JSON.
|
||||||
|
- `deleteConversation(conversationId)` -- cascade deletes the conversation and all its messages.
|
||||||
|
|
||||||
|
Messages are stored in `agent_memories` with the role, content (text only), and full parts array (JSON in the metadata column). The parts array preserves tool calls, reasoning, and other non-text content so conversations can be fully restored.
|
||||||
|
|
||||||
|
|
||||||
|
the unified chat architecture
|
||||||
|
---
|
||||||
|
|
||||||
|
This is the most architecturally interesting part of the UI layer. There's one chat component (`ChatView`) that renders in two completely different modes depending on a `variant` prop.
|
||||||
|
|
||||||
|
**Page variant** (`variant="page"`) -- renders on `/dashboard` as a full-page experience with an idle hero state (animated typewriter placeholder, repo stats from GitHub) that transitions to an active conversation state.
|
||||||
|
|
||||||
|
**Panel variant** (`variant="panel"`) -- renders in `ChatPanelShell` as a resizable sidebar on every other page. Keyboard shortcut (Cmd+.) to toggle, mobile FAB button, resize handle (320-720px range).
|
||||||
|
|
||||||
|
Both variants share all chat state through the same hook and context. Navigating from the dashboard to a project page seamlessly moves the conversation from full-page to sidebar without losing any messages.
|
||||||
|
|
||||||
|
The state architecture has three layers, all provided by `ChatProvider`:
|
||||||
|
|
||||||
|
```
|
||||||
|
ChatProvider
|
||||||
|
├── PanelContext (isOpen, open, close, toggle)
|
||||||
|
├── ChatStateContext (messages, sendMessage, status, conversationId, newChat)
|
||||||
|
└── RenderContext (spec, isRendering, triggerRender, clearRender, loadSpec)
|
||||||
|
```
|
||||||
|
|
||||||
|
**PanelContext** manages the sidebar open/close state. It auto-opens the panel when navigating away from the dashboard with existing messages.
|
||||||
|
|
||||||
|
**ChatStateContext** wraps `useCompassChat()`, which wraps `useChat()` from AI SDK. It adds conversation ID management, new-chat functionality, and persistence callbacks.
|
||||||
|
|
||||||
|
**RenderContext** manages the json-render stream for `generateUI`. When the agent calls `generateUI`, the `ChatProvider` detects the tool result, sends the render prompt to `/api/agent/render`, and streams the resulting UI spec into the dashboard area.
|
||||||
|
|
||||||
|
|
||||||
|
the useCompassChat hook
|
||||||
|
---
|
||||||
|
|
||||||
|
`src/hooks/use-compass-chat.ts` is the shared hook that wraps AI SDK's `useChat()`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function useCompassChat(options?: UseCompassChatOptions) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const chatState = useChat({
|
||||||
|
transport: new DefaultChatTransport({
|
||||||
|
api: "/api/agent",
|
||||||
|
headers: {
|
||||||
|
"x-current-page": pathname,
|
||||||
|
"x-timezone": Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
"x-conversation-id": options?.conversationId ?? "",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
onFinish: options?.onFinish,
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
// dispatch tool-based client actions on new messages
|
||||||
|
useEffect(() => {
|
||||||
|
const last = chatState.messages.at(-1)
|
||||||
|
if (last?.role !== "assistant") return
|
||||||
|
dispatchToolActions(last.parts, dispatchedRef.current)
|
||||||
|
}, [chatState.messages])
|
||||||
|
|
||||||
|
// initialize action handlers (navigate, toast, etc.)
|
||||||
|
useEffect(() => {
|
||||||
|
initializeActionHandlers(
|
||||||
|
() => routerRef.current,
|
||||||
|
() => openPanelRef.current?.()
|
||||||
|
)
|
||||||
|
// ...
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: chatState.messages,
|
||||||
|
setMessages: chatState.setMessages,
|
||||||
|
sendMessage: chatState.sendMessage,
|
||||||
|
// ...
|
||||||
|
isGenerating,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The hook does three things beyond what `useChat()` provides:
|
||||||
|
|
||||||
|
1. **Injects request context** (current page, timezone, conversation ID) as HTTP headers.
|
||||||
|
2. **Dispatches tool actions** by scanning assistant message parts for tool results with known action types (navigate, toast, generateUI, etc.).
|
||||||
|
3. **Registers action handlers** that translate action types into browser operations (router.push, window.dispatchEvent, etc.).
|
||||||
|
|
||||||
|
|
||||||
|
the action dispatch system
|
||||||
|
---
|
||||||
|
|
||||||
|
`src/lib/agent/chat-adapter.ts` is the bridge between tool results (server-side) and browser actions (client-side).
|
||||||
|
|
||||||
|
When a tool returns `{ action: "navigate", path: "/dashboard/projects" }`, the dispatch system:
|
||||||
|
|
||||||
|
1. Scans the assistant message's parts array for tool parts with `state: "output-available"`
|
||||||
|
2. Checks if the output has an `action` field
|
||||||
|
3. Maps the action to an `executeAction()` call with the appropriate type
|
||||||
|
4. The registered handler for `NAVIGATE_TO` calls `router.push(path)`
|
||||||
|
|
||||||
|
The handler registry supports: `NAVIGATE_TO`, `SHOW_TOAST`, `OPEN_MODAL`, `CLOSE_MODAL`, `SCROLL_TO`, `FOCUS_ELEMENT`, `GENERATE_UI`, `SAVE_DASHBOARD`, `LOAD_DASHBOARD`, `APPLY_THEME`, `PREVIEW_THEME`.
|
||||||
|
|
||||||
|
A `Set<string>` of dispatched tool call IDs prevents re-execution on React re-renders. Each tool result is dispatched exactly once.
|
||||||
|
|
||||||
|
AI SDK v6 has two tool part formats that the dispatch system handles:
|
||||||
|
- Static parts: `type: "tool-queryData"`, properties are flat on the part object
|
||||||
|
- Dynamic parts: `type: "dynamic-tool"`, `toolName` field, same structure otherwise
|
||||||
|
|
||||||
|
|
||||||
|
AI SDK v6 patterns and gotchas
|
||||||
|
---
|
||||||
|
|
||||||
|
Compass uses AI SDK v6, which has significant API differences from v5. These are the patterns that matter:
|
||||||
|
|
||||||
|
**Tool definitions use `inputSchema`, not `parameters`:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const myTool = tool({
|
||||||
|
description: "...",
|
||||||
|
inputSchema: z.object({ /* ... */ }), // not `parameters`
|
||||||
|
execute: async (input) => { /* ... */ },
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zod must come from `zod/v4`:** AI SDK v6 internally uses Zod v4 for schema validation. If you import from `zod` instead of `zod/v4`, runtime validation fails silently.
|
||||||
|
|
||||||
|
**`useChat()` requires a transport, not an `api` prop:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
useChat({
|
||||||
|
transport: new DefaultChatTransport({ api: "/api/agent" }),
|
||||||
|
// NOT: api: "/api/agent"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Messages are sent with `sendMessage({ text })`, not `append({ role, content })`.**
|
||||||
|
|
||||||
|
**Status is a string enum, not a boolean:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
chatState.status // "streaming" | "submitted" | "ready" | "error"
|
||||||
|
// NOT: chatState.isGenerating
|
||||||
|
```
|
||||||
|
|
||||||
|
The `isGenerating` convenience boolean is computed in `useCompassChat`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const isGenerating =
|
||||||
|
chatState.status === "streaming" ||
|
||||||
|
chatState.status === "submitted"
|
||||||
|
```
|
||||||
|
|
||||||
|
**`UIMessage` uses a `parts` array, not a `content` field.** Text extraction requires filtering parts by type:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function getTextFromParts(
|
||||||
|
parts: ReadonlyArray<{ type: string; text?: string }>
|
||||||
|
): string {
|
||||||
|
return parts
|
||||||
|
.filter((p): p is { type: "text"; text: string } =>
|
||||||
|
p.type === "text"
|
||||||
|
)
|
||||||
|
.map((p) => p.text)
|
||||||
|
.join("")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`convertToModelMessages()` expects a mutable array.** The SDK's type signature requires `UIMessage[]`, not `ReadonlyArray<UIMessage>`. The API route handles this by receiving the messages as a mutable type from the request body.
|
||||||
|
|
||||||
|
**Environment variable access needs a double cast:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const envRecord = env as unknown as Record<string, string>
|
||||||
|
const apiKey = envRecord.OPENROUTER_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
This is because the Cloudflare env type doesn't include manually-set secrets.
|
||||||
259
docs/architecture/auth-system.md
Executable file
259
docs/architecture/auth-system.md
Executable file
@ -0,0 +1,259 @@
|
|||||||
|
Authentication and Authorization
|
||||||
|
===
|
||||||
|
|
||||||
|
Compass uses WorkOS for authentication and a custom RBAC system for authorization. This document covers both layers: how users get in, and what they're allowed to do once they're in.
|
||||||
|
|
||||||
|
|
||||||
|
why WorkOS
|
||||||
|
---
|
||||||
|
|
||||||
|
The decision came down to enterprise SSO. Construction companies have IT departments. Those IT departments use Active Directory, Okta, or Google Workspace. They want their employees to log in with their existing credentials, not create yet another account.
|
||||||
|
|
||||||
|
WorkOS provides SSO out of the box -- SAML, OIDC, and directory sync (meaning when someone gets added or removed in Active Directory, Compass picks it up automatically). Auth0 and Clerk offer similar features but at higher price points for the enterprise tier. WorkOS is SSO-first, which means the enterprise features aren't paywalled behind a premium plan.
|
||||||
|
|
||||||
|
The tradeoff: WorkOS's UI components are less polished than Clerk's, and the documentation assumes more backend knowledge. For a developer-built product, this is fine. For a no-code builder, it would be a problem.
|
||||||
|
|
||||||
|
|
||||||
|
the authentication flow
|
||||||
|
---
|
||||||
|
|
||||||
|
Authentication happens in three places: the middleware, the auth library, and the WorkOS SDK.
|
||||||
|
|
||||||
|
**Middleware** (`src/middleware.ts`) runs on every request. It does two things:
|
||||||
|
|
||||||
|
1. Gets the session from WorkOS via `authkit(request)`, which checks cookies and refreshes tokens automatically.
|
||||||
|
2. Decides whether to allow or redirect.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default async function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl
|
||||||
|
const { session, headers } = await authkit(request)
|
||||||
|
|
||||||
|
if (isPublicPath(pathname)) {
|
||||||
|
return handleAuthkitHeaders(request, headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.user) {
|
||||||
|
const loginUrl = new URL("/login", request.url)
|
||||||
|
loginUrl.searchParams.set("from", pathname)
|
||||||
|
return handleAuthkitHeaders(request, headers, {
|
||||||
|
redirect: loginUrl.toString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return handleAuthkitHeaders(request, headers)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Public paths bypass authentication entirely:
|
||||||
|
|
||||||
|
- `/`, `/login`, `/signup`, `/reset-password`, `/verify-email`, `/invite`, `/callback`
|
||||||
|
- `/api/auth/*` (WorkOS callback routes)
|
||||||
|
- `/api/netsuite/*` (OAuth callback from NetSuite)
|
||||||
|
- `/api/google/*` (Google integration webhooks)
|
||||||
|
|
||||||
|
Everything else requires a valid session. Unauthenticated users get redirected to `/login` with a `from` query parameter so they return to the right page after logging in.
|
||||||
|
|
||||||
|
The middleware matcher excludes static assets:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**The auth library** (`src/lib/auth.ts`) provides `getCurrentUser()`, which is called by every server action that needs to know who's making the request.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function getCurrentUser(): Promise<AuthUser | null> {
|
||||||
|
const isWorkOSConfigured =
|
||||||
|
process.env.WORKOS_API_KEY &&
|
||||||
|
process.env.WORKOS_CLIENT_ID &&
|
||||||
|
!process.env.WORKOS_API_KEY.includes("placeholder")
|
||||||
|
|
||||||
|
if (!isWorkOSConfigured) {
|
||||||
|
return {
|
||||||
|
id: "dev-user-1",
|
||||||
|
email: "dev@compass.io",
|
||||||
|
// ... mock admin user for development
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await withAuth()
|
||||||
|
if (!session || !session.user) return null
|
||||||
|
|
||||||
|
const { env } = await getCloudflareContext()
|
||||||
|
const db = getDb(env.DB)
|
||||||
|
|
||||||
|
let dbUser = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, workosUser.id))
|
||||||
|
.get()
|
||||||
|
|
||||||
|
if (!dbUser) {
|
||||||
|
dbUser = await ensureUserExists(workosUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
// update last login timestamp
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({ lastLoginAt: now })
|
||||||
|
.where(eq(users.id, workosUser.id))
|
||||||
|
.run()
|
||||||
|
|
||||||
|
return { /* AuthUser from DB fields */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The function has a development mode fallback. When WorkOS credentials aren't configured (or contain "placeholder"), it returns a mock admin user. This lets the app run locally without setting up WorkOS.
|
||||||
|
|
||||||
|
In production, the flow is:
|
||||||
|
|
||||||
|
1. Call WorkOS `withAuth()` to get the session from cookies
|
||||||
|
2. Look up the user in D1 by their WorkOS ID
|
||||||
|
3. If they don't exist in D1, create them with `ensureUserExists()` (auto-provisioning)
|
||||||
|
4. Update their `lastLoginAt` timestamp
|
||||||
|
5. Return an `AuthUser` object with the role from D1 (not from WorkOS)
|
||||||
|
|
||||||
|
The role comes from D1, not WorkOS, because roles are application-specific. WorkOS handles identity (who is this person?). Compass handles authorization (what can they do?).
|
||||||
|
|
||||||
|
|
||||||
|
auto-provisioning
|
||||||
|
---
|
||||||
|
|
||||||
|
When a user authenticates via WorkOS for the first time, `ensureUserExists()` creates their D1 record:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function ensureUserExists(workosUser: {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
firstName?: string | null
|
||||||
|
lastName?: string | null
|
||||||
|
profilePictureUrl?: string | null
|
||||||
|
}): Promise<User> {
|
||||||
|
// ... check if exists, return if so
|
||||||
|
|
||||||
|
const newUser = {
|
||||||
|
id: workosUser.id,
|
||||||
|
email: workosUser.email,
|
||||||
|
firstName: workosUser.firstName ?? null,
|
||||||
|
lastName: workosUser.lastName ?? null,
|
||||||
|
displayName: /* firstName + lastName, or email prefix */,
|
||||||
|
avatarUrl: workosUser.profilePictureUrl ?? null,
|
||||||
|
role: "office", // default role
|
||||||
|
isActive: true,
|
||||||
|
lastLoginAt: now,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(users).values(newUser).run()
|
||||||
|
return newUser
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
New users get the "office" role by default. An admin must explicitly promote them. This is a security decision: new users should have limited access until someone with authority grants more.
|
||||||
|
|
||||||
|
|
||||||
|
session management
|
||||||
|
---
|
||||||
|
|
||||||
|
Sessions are managed by WorkOS's AuthKit SDK. The session cookie (`wos-session`) is a JWT that the middleware validates on every request. The SDK handles token refresh automatically -- when a session token is close to expiry, `authkit(request)` refreshes it and returns updated headers that the middleware passes through.
|
||||||
|
|
||||||
|
`src/lib/session.ts` provides two utility functions for JWT inspection:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function decodeJwtPayload(token: string): Record<string, unknown> | null
|
||||||
|
export function isTokenExpired(token: string): boolean
|
||||||
|
```
|
||||||
|
|
||||||
|
These are used for edge cases where the application needs to inspect the session token directly, outside the normal WorkOS flow.
|
||||||
|
|
||||||
|
|
||||||
|
the RBAC system
|
||||||
|
---
|
||||||
|
|
||||||
|
Authorization is handled by `src/lib/permissions.ts`, which defines a static permission matrix.
|
||||||
|
|
||||||
|
**Four roles**, ordered from most to least access:
|
||||||
|
|
||||||
|
- `admin` -- full access to everything, including user management and the AI agent
|
||||||
|
- `office` -- create and manage most entities, but no deletion or user management
|
||||||
|
- `field` -- mostly read-only, can update schedules and create change orders/documents
|
||||||
|
- `client` -- read-only access to everything, no AI agent access
|
||||||
|
|
||||||
|
**Thirteen resources:**
|
||||||
|
|
||||||
|
`project`, `schedule`, `budget`, `changeorder`, `document`, `user`, `organization`, `team`, `group`, `customer`, `vendor`, `finance`, `agent`
|
||||||
|
|
||||||
|
**Five actions:**
|
||||||
|
|
||||||
|
`create`, `read`, `update`, `delete`, `approve`
|
||||||
|
|
||||||
|
The permission matrix is a nested object:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const PERMISSIONS: RolePermissions = {
|
||||||
|
admin: {
|
||||||
|
project: ["create", "read", "update", "delete", "approve"],
|
||||||
|
schedule: ["create", "read", "update", "delete", "approve"],
|
||||||
|
// ... full access to all resources
|
||||||
|
agent: ["create", "read", "update", "delete"],
|
||||||
|
},
|
||||||
|
office: {
|
||||||
|
project: ["create", "read", "update"],
|
||||||
|
// ... no delete, no approve
|
||||||
|
agent: ["read"],
|
||||||
|
},
|
||||||
|
field: {
|
||||||
|
project: ["read"],
|
||||||
|
schedule: ["read", "update"],
|
||||||
|
// ... mostly read, some create
|
||||||
|
agent: ["read"],
|
||||||
|
},
|
||||||
|
client: {
|
||||||
|
project: ["read"],
|
||||||
|
// ... read-only everything
|
||||||
|
agent: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The check functions:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// returns boolean
|
||||||
|
export function can(user: AuthUser | null, resource: Resource, action: Action): boolean
|
||||||
|
|
||||||
|
// throws if not allowed
|
||||||
|
export function requirePermission(user: AuthUser | null, resource: Resource, action: Action): void
|
||||||
|
|
||||||
|
// returns allowed actions for a role/resource combo
|
||||||
|
export function getPermissions(role: string, resource: Resource): Action[]
|
||||||
|
|
||||||
|
// returns whether the user has ANY permission on a resource
|
||||||
|
export function hasAnyPermission(user: AuthUser | null, resource: Resource): boolean
|
||||||
|
```
|
||||||
|
|
||||||
|
`requirePermission()` is the most commonly used. It throws a descriptive error:
|
||||||
|
|
||||||
|
```
|
||||||
|
Permission denied: field cannot delete customer
|
||||||
|
```
|
||||||
|
|
||||||
|
This error gets caught by the server action's try/catch wrapper and returned as `{ success: false, error: "Permission denied: ..." }`.
|
||||||
|
|
||||||
|
|
||||||
|
notable design decisions
|
||||||
|
---
|
||||||
|
|
||||||
|
**Static over dynamic.** The permission matrix is a hardcoded object, not database rows. This means changing permissions requires a code change and deploy. The tradeoff is simplicity -- no admin UI for permission management, no risk of misconfiguration, no database queries to check permissions. For a product with four roles and a well-defined permission model, this is the right call. If the permission model becomes more dynamic (per-project permissions, custom roles), the matrix would need to move to the database.
|
||||||
|
|
||||||
|
**Roles on users, not sessions.** The user's role is stored in D1 and returned by `getCurrentUser()`. It's not embedded in the JWT. This means role changes take effect on the next request, not the next login. The tradeoff is an extra database read per request (to get the role), but `getCurrentUser()` already reads the database to update `lastLoginAt`, so the role lookup comes for free.
|
||||||
|
|
||||||
|
**Agent access is a permission.** The AI agent is a resource in the RBAC system. Clients get no agent access (`agent: []`). Field workers get read-only (`agent: ["read"]`). This means the chat panel visibility can be gated on `can(user, "agent", "read")`. Admin gets full access, which includes configuring the model, viewing usage stats, and managing skills.
|
||||||
|
|
||||||
|
**Inactive users are denied everything.** The `can()` function checks `user.isActive` before checking permissions. A deactivated user gets `false` for every permission check, even if their role would normally allow it. This is the kill switch for removing access without deleting the user record.
|
||||||
199
docs/architecture/data-layer.md
Executable file
199
docs/architecture/data-layer.md
Executable file
@ -0,0 +1,199 @@
|
|||||||
|
Data Layer
|
||||||
|
===
|
||||||
|
|
||||||
|
Compass stores everything in Cloudflare D1, which is SQLite at the edge. The ORM is Drizzle, configured in `drizzle.config.ts` to read from 8 schema files. This document covers the schema design, how the database is accessed, and why D1 over Postgres.
|
||||||
|
|
||||||
|
|
||||||
|
why D1
|
||||||
|
---
|
||||||
|
|
||||||
|
The short answer: co-location. Compass runs as a Cloudflare Worker, and D1 is SQLite running in the same data center as the worker. Queries take single-digit milliseconds because there's no network round-trip to a database server. For a project management tool where every page load fires 3-5 queries, this matters.
|
||||||
|
|
||||||
|
The longer answer involves tradeoffs. D1 is SQLite, which means:
|
||||||
|
|
||||||
|
- No native JSON column type (we store JSON as text and parse in application code)
|
||||||
|
- No `RETURNING` clause on inserts (we generate IDs before inserting)
|
||||||
|
- No concurrent writes from multiple connections (fine for Workers, where each request gets its own isolate)
|
||||||
|
- No `ENUM` types (we use text columns with TypeScript union types)
|
||||||
|
|
||||||
|
What we get in return: zero configuration, no connection pooling, no cold start penalty, automatic replication, and a database that survives the worker being evicted. For an application that reads more than it writes and runs at the edge, the tradeoffs are favorable.
|
||||||
|
|
||||||
|
|
||||||
|
schema organization
|
||||||
|
---
|
||||||
|
|
||||||
|
The schema is split across 8 files, registered in `drizzle.config.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// drizzle.config.ts
|
||||||
|
export default defineConfig({
|
||||||
|
schema: [
|
||||||
|
"./src/db/schema.ts",
|
||||||
|
"./src/db/schema-netsuite.ts",
|
||||||
|
"./src/db/schema-plugins.ts",
|
||||||
|
"./src/db/schema-agent.ts",
|
||||||
|
"./src/db/schema-ai-config.ts",
|
||||||
|
"./src/db/schema-theme.ts",
|
||||||
|
"./src/db/schema-google.ts",
|
||||||
|
"./src/db/schema-dashboards.ts",
|
||||||
|
],
|
||||||
|
out: "./drizzle",
|
||||||
|
dialect: "sqlite",
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The split isn't arbitrary. Each file maps to a feature boundary:
|
||||||
|
|
||||||
|
**`schema.ts`** -- core tables. This is the foundation that everything else references.
|
||||||
|
|
||||||
|
- `users` -- WorkOS user IDs as primary keys, roles (admin/office/field/client), optional `google_email` for Drive impersonation
|
||||||
|
- `organizations`, `organizationMembers` -- multi-tenant org structure
|
||||||
|
- `teams`, `teamMembers` -- teams within orgs
|
||||||
|
- `groups`, `groupMembers` -- cross-org permission groups
|
||||||
|
- `projects`, `projectMembers` -- the central domain entity
|
||||||
|
- `scheduleTasks`, `taskDependencies` -- Gantt chart data
|
||||||
|
- `workdayExceptions`, `scheduleBaselines` -- calendar and snapshot tracking
|
||||||
|
- `customers`, `vendors` -- CRM basics, with optional `netsuite_id` columns
|
||||||
|
- `feedback`, `feedbackInterviews` -- user feedback and UX research
|
||||||
|
- `agentConversations`, `agentMemories` -- chat persistence
|
||||||
|
- `slabMemories` -- persistent memory (preferences, workflows, facts, decisions)
|
||||||
|
- `pushTokens` -- native app push notification tokens
|
||||||
|
|
||||||
|
**`schema-netsuite.ts`** -- NetSuite integration tables.
|
||||||
|
|
||||||
|
- `netsuiteAuth` -- encrypted OAuth tokens
|
||||||
|
- `netsuiteSyncMetadata` -- per-record sync tracking (status, conflicts, retries)
|
||||||
|
- `netsuiteSyncLog` -- sync run history
|
||||||
|
- `invoices`, `vendorBills`, `payments`, `creditMemos` -- financial records that sync bidirectionally
|
||||||
|
|
||||||
|
**`schema-plugins.ts`** -- plugin/skills system.
|
||||||
|
|
||||||
|
- `plugins` -- installed plugin registry (name, source, capabilities, status)
|
||||||
|
- `pluginConfig` -- per-plugin key-value settings (supports encrypted values)
|
||||||
|
- `pluginEvents` -- audit log of installs, enables, disables
|
||||||
|
|
||||||
|
**`schema-agent.ts`** -- agent items (todos, notes, checklists created by the AI).
|
||||||
|
|
||||||
|
- `agentItems` -- type-polymorphic items with parent-child relationships
|
||||||
|
|
||||||
|
**`schema-ai-config.ts`** -- AI model configuration and usage tracking.
|
||||||
|
|
||||||
|
- `agentConfig` -- singleton config row (global model selection, cost ceiling)
|
||||||
|
- `userModelPreference` -- per-user model override
|
||||||
|
- `agentUsage` -- token counts and cost estimates per streamText invocation
|
||||||
|
|
||||||
|
**`schema-theme.ts`** -- visual themes.
|
||||||
|
|
||||||
|
- `customThemes` -- user-created themes (full oklch color maps stored as JSON)
|
||||||
|
- `userThemePreference` -- which theme each user has active
|
||||||
|
|
||||||
|
**`schema-google.ts`** -- Google Drive integration.
|
||||||
|
|
||||||
|
- `googleAuth` -- encrypted service account keys per organization
|
||||||
|
- `googleStarredFiles` -- per-user starred Drive files
|
||||||
|
|
||||||
|
**`schema-dashboards.ts`** -- agent-built custom dashboards.
|
||||||
|
|
||||||
|
- `customDashboards` -- saved dashboard specs (JSON), queries, and render prompts
|
||||||
|
|
||||||
|
|
||||||
|
design conventions
|
||||||
|
---
|
||||||
|
|
||||||
|
Every table follows the same patterns:
|
||||||
|
|
||||||
|
**Text IDs.** Primary keys are text columns containing UUIDs, generated via `crypto.randomUUID()` before insert. Not auto-increment integers. This avoids sequential ID exposure and works across distributed systems.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const customers = sqliteTable("customers", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
// ...
|
||||||
|
createdAt: text("created_at").notNull(),
|
||||||
|
updatedAt: text("updated_at"),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Text dates.** All timestamps are ISO 8601 strings stored in text columns, not integer unix timestamps. This makes them human-readable in raw queries and avoids timezone confusion. The application always generates them with `new Date().toISOString()`.
|
||||||
|
|
||||||
|
**Explicit foreign keys.** References use Drizzle's `.references()` with explicit `onDelete` behavior. Most use `cascade` for cleanup, some use no action for soft-reference relationships.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const organizationMembers = sqliteTable("organization_members", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
organizationId: text("organization_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => organizations.id, { onDelete: "cascade" }),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
role: text("role").notNull(),
|
||||||
|
joinedAt: text("joined_at").notNull(),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Type exports.** Every table exports both `Select` and `Insert` types via Drizzle's `$inferSelect` and `$inferInsert`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type Customer = typeof customers.$inferSelect
|
||||||
|
export type NewCustomer = typeof customers.$inferInsert
|
||||||
|
```
|
||||||
|
|
||||||
|
**JSON-as-text.** Complex data (theme definitions, dashboard specs, sync conflict data, line items) is stored as JSON-stringified text. This is a D1 constraint -- there's no native JSON column type. Parsing happens in application code.
|
||||||
|
|
||||||
|
|
||||||
|
accessing the database
|
||||||
|
---
|
||||||
|
|
||||||
|
The database is accessed through `getDb()` in `src/db/index.ts`, which wraps Drizzle's `drizzle()` function with all schema files merged:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { drizzle } from "drizzle-orm/d1"
|
||||||
|
import * as schema from "./schema"
|
||||||
|
import * as netsuiteSchema from "./schema-netsuite"
|
||||||
|
import * as pluginSchema from "./schema-plugins"
|
||||||
|
import * as agentSchema from "./schema-agent"
|
||||||
|
import * as aiConfigSchema from "./schema-ai-config"
|
||||||
|
import * as themeSchema from "./schema-theme"
|
||||||
|
import * as googleSchema from "./schema-google"
|
||||||
|
import * as dashboardSchema from "./schema-dashboards"
|
||||||
|
|
||||||
|
const allSchemas = {
|
||||||
|
...schema,
|
||||||
|
...netsuiteSchema,
|
||||||
|
...pluginSchema,
|
||||||
|
...agentSchema,
|
||||||
|
...aiConfigSchema,
|
||||||
|
...themeSchema,
|
||||||
|
...googleSchema,
|
||||||
|
...dashboardSchema,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDb(d1: D1Database) {
|
||||||
|
return drizzle(d1, { schema: allSchemas })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `D1Database` binding comes from the Cloudflare runtime. In server actions, you get it via:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { env } = await getCloudflareContext()
|
||||||
|
const db = getDb(env.DB)
|
||||||
|
```
|
||||||
|
|
||||||
|
The `getDb()` function is called per-request -- it creates a new Drizzle instance each time. This is intentional. Cloudflare Workers are stateless isolates; there's no persistent connection to reuse. The cost of creating the Drizzle wrapper is negligible compared to the query itself.
|
||||||
|
|
||||||
|
|
||||||
|
migration workflow
|
||||||
|
---
|
||||||
|
|
||||||
|
Migrations are generated by Drizzle Kit and stored in the `drizzle/` directory. The workflow:
|
||||||
|
|
||||||
|
1. Edit schema files in `src/db/`
|
||||||
|
2. Run `bun run db:generate` -- Drizzle Kit diffs the schema against the last migration and generates a new SQL migration file
|
||||||
|
3. Run `bun run db:migrate:local` -- applies migrations to the local D1 database (via Wrangler)
|
||||||
|
4. Run `bun run db:migrate:prod` -- applies migrations to the production D1 database
|
||||||
|
|
||||||
|
Migrations are append-only. Never modify an existing migration file. If a migration is wrong, generate a new one that corrects it.
|
||||||
|
|
||||||
|
The generated SQL files are plain SQLite DDL. No Drizzle-specific runtime is needed to apply them -- Wrangler's `d1 migrations apply` handles execution directly. This means the migration system is ORM-portable: if you swapped Drizzle for something else, the existing migrations would still work.
|
||||||
107
docs/architecture/overview.md
Executable file
107
docs/architecture/overview.md
Executable file
@ -0,0 +1,107 @@
|
|||||||
|
Compass Core Architecture
|
||||||
|
===
|
||||||
|
|
||||||
|
Compass is a construction project management platform, but the architecture underneath it is designed to be something more general: a composable, AI-native dashboard framework that can serve any domain. The construction-specific features (schedules, change orders, submittals) are a *module* built on top of a generic platform layer. This document describes that platform layer.
|
||||||
|
|
||||||
|
|
||||||
|
why two layers
|
||||||
|
---
|
||||||
|
|
||||||
|
Enterprise software tends to calcify into monoliths. BuilderTrend, Procore, and their competitors each bundle project management, accounting, document management, and communication into a single product. If you want the scheduling but not the accounting, too bad. If their accounting doesn't match your workflow, too bad again.
|
||||||
|
|
||||||
|
The alternative is composability. Compass Core provides the infrastructure that every enterprise tool needs: authentication, authorization, a database layer, an AI agent, a plugin system, and a theming engine. Domain-specific features are modules that plug into this infrastructure. The construction module is the first one. It won't be the last.
|
||||||
|
|
||||||
|
This isn't theoretical. The architecture already enforces the separation. Core platform tables (users, organizations, themes, plugins, agent conversations) live in dedicated schema files. Domain tables (projects, schedules, customers, vendors, invoices) live in their own schema files. The AI agent's tools query domain data through the same server action layer that the UI uses. Swapping the domain module means replacing the schema files and the action handlers, not rewiring the platform.
|
||||||
|
|
||||||
|
|
||||||
|
the layers in practice
|
||||||
|
---
|
||||||
|
|
||||||
|
```
|
||||||
|
+--------------------------------------------------+
|
||||||
|
| HPS Compass Module |
|
||||||
|
| (projects, schedules, customers, vendors, |
|
||||||
|
| invoices, vendor bills, NetSuite sync, |
|
||||||
|
| Google Drive, Gantt charts) |
|
||||||
|
+--------------------------------------------------+
|
||||||
|
| Compass Core Platform |
|
||||||
|
| +--------------------------------------------+ |
|
||||||
|
| | AI Agent | Plugins/Skills | Themes | |
|
||||||
|
| | (tools, system | (install from | (10 | |
|
||||||
|
| | prompt, chat | GitHub, inject | presets | |
|
||||||
|
| | persistence, | into prompt, | + AI- | |
|
||||||
|
| | usage tracking)| per-user) | gen'd) | |
|
||||||
|
| +--------------------------------------------+ |
|
||||||
|
| | Auth (WorkOS) | RBAC | Dashbds | |
|
||||||
|
| | (SSO, email/pw, | (4 roles, 13 | (agent- | |
|
||||||
|
| | directory sync) | resources, | built, | |
|
||||||
|
| | | 5 actions) | saved) | |
|
||||||
|
| +--------------------------------------------+ |
|
||||||
|
| | Server Actions | Drizzle + D1 | Next.js | |
|
||||||
|
| | (typed RPC, | (SQLite at the | 15 App | |
|
||||||
|
| | revalidation) | edge) | Router) | |
|
||||||
|
| +--------------------------------------------+ |
|
||||||
|
+--------------------------------------------------+
|
||||||
|
| Cloudflare Workers Runtime |
|
||||||
|
| (D1, KV, R2, edge deployment, zero cold start) |
|
||||||
|
+--------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
The bottom layer is the Cloudflare Workers runtime. Compass deploys as a Next.js 15 application via OpenNext, running on Cloudflare's edge network. The database is Cloudflare D1 (SQLite), co-located with the worker for single-digit-millisecond query latency.
|
||||||
|
|
||||||
|
The middle layer is Compass Core. This is where the platform capabilities live: authentication via WorkOS, role-based access control, the server action pattern for all data mutations, the AI agent harness, the plugin/skills system, the visual theme engine, and agent-built custom dashboards.
|
||||||
|
|
||||||
|
The top layer is the domain module. For HPS (High Performance Structures), this is construction project management. The module contributes its own database tables, server actions, UI pages, and AI agent tools. It also brings integrations (NetSuite for accounting, Google Drive for document management) that make sense for the construction domain.
|
||||||
|
|
||||||
|
|
||||||
|
relationship to OpenClaw
|
||||||
|
---
|
||||||
|
|
||||||
|
Compass's AI architecture was informed by studying OpenClaw's agent framework. The key ideas that carried over:
|
||||||
|
|
||||||
|
- *Section-based prompt building.* OpenClaw assembles system prompts from independent section builders, each returning a string array. Compass does exactly this in `buildSystemPrompt()` -- identity, user context, memory, domain knowledge, tool docs, guidelines, and plugin sections are each built by separate functions and concatenated. Sections can be omitted entirely based on a `PromptMode` parameter ("full", "minimal", "none").
|
||||||
|
|
||||||
|
- *Tool-first agent design.* The agent's primary interface with the application is through tools, not free-text generation. `queryData` reads the database, `navigateTo` controls the UI, `generateUI` builds dashboards, `rememberContext` persists memories. The LLM's text output is the explanation layer; the tools are the action layer.
|
||||||
|
|
||||||
|
- *Plugin extensibility through prompt injection.* OpenClaw's skills system lets external files inject instructions into the system prompt. Compass implements the same pattern: GitHub-hosted SKILL.md files get parsed, stored in the database, and injected into the prompt at priority 80 during the next agent invocation.
|
||||||
|
|
||||||
|
Where Compass diverges from OpenClaw is in the transport layer. OpenClaw uses a gateway with multi-channel routing (WhatsApp, Telegram, Discord, IDE). Compass uses a single HTTP streaming endpoint (`POST /api/agent`) because it only needs to serve its own web UI. This is simpler but means Compass doesn't get OpenClaw's multi-channel capabilities out of the box.
|
||||||
|
|
||||||
|
|
||||||
|
the tech stack
|
||||||
|
---
|
||||||
|
|
||||||
|
| layer | choice | why |
|
||||||
|
|-------------|--------------------------------|--------------------------------------------------|
|
||||||
|
| framework | Next.js 15, React 19 | App Router, server components, server actions |
|
||||||
|
| language | TypeScript 5.x (strict) | No `any`, no `as`, discriminated unions |
|
||||||
|
| ui | shadcn/ui + Tailwind CSS v4 | Composable primitives, not a component library |
|
||||||
|
| database | Drizzle ORM + Cloudflare D1 | Type-safe SQL, edge-native SQLite |
|
||||||
|
| auth | WorkOS AuthKit | Enterprise SSO from day one |
|
||||||
|
| ai | AI SDK v6 + OpenRouter | Model-agnostic, streaming, multi-tool loops |
|
||||||
|
| mobile | Capacitor | WebView wrapper, same codebase |
|
||||||
|
| deployment | Cloudflare Workers via OpenNext | Edge deployment, zero cold starts |
|
||||||
|
|
||||||
|
The choices are opinionated. D1 over Postgres means giving up some SQL features in exchange for edge co-location and zero-config. WorkOS over Auth0/Clerk means paying more but getting enterprise SSO without building it. OpenRouter over direct provider APIs means one integration point for any model. Each tradeoff is documented in detail in the relevant architecture doc.
|
||||||
|
|
||||||
|
|
||||||
|
file organization
|
||||||
|
---
|
||||||
|
|
||||||
|
The codebase follows Next.js 15 App Router conventions with a few additions:
|
||||||
|
|
||||||
|
- `src/app/actions/` -- server actions (25 files, all data mutations)
|
||||||
|
- `src/db/` -- Drizzle schema files (8 files, split by domain)
|
||||||
|
- `src/lib/agent/` -- AI agent harness (provider, tools, prompt, memory, plugins)
|
||||||
|
- `src/lib/theme/` -- visual theme engine (presets, apply, fonts)
|
||||||
|
- `src/lib/netsuite/` -- NetSuite integration (auth, client, sync, mappers)
|
||||||
|
- `src/lib/google/` -- Google Drive integration (auth, client, mapper)
|
||||||
|
- `src/components/agent/` -- chat UI (ChatProvider, ChatView, ChatPanelShell)
|
||||||
|
- `src/hooks/` -- shared React hooks (chat, native, audio)
|
||||||
|
|
||||||
|
Each subsystem is documented in its own architecture doc:
|
||||||
|
|
||||||
|
- [data-layer.md](./data-layer.md) -- database schema, Drizzle ORM, D1, migrations
|
||||||
|
- [server-actions.md](./server-actions.md) -- the server action pattern, all 25 action files
|
||||||
|
- [auth-system.md](./auth-system.md) -- WorkOS, middleware, RBAC, sessions
|
||||||
|
- [ai-agent.md](./ai-agent.md) -- the AI agent harness, tools, prompt, chat architecture
|
||||||
179
docs/architecture/server-actions.md
Executable file
179
docs/architecture/server-actions.md
Executable file
@ -0,0 +1,179 @@
|
|||||||
|
Server Actions
|
||||||
|
===
|
||||||
|
|
||||||
|
Every data mutation in Compass goes through a server action. Not an API route, not a fetch call, not a GraphQL resolver. Server actions. This document explains the pattern, lists all 25 action files, and covers why this was chosen over alternatives.
|
||||||
|
|
||||||
|
|
||||||
|
the pattern
|
||||||
|
---
|
||||||
|
|
||||||
|
Every server action file starts with `"use server"` and exports async functions that follow a consistent shape:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
"use server"
|
||||||
|
|
||||||
|
import { getCloudflareContext } from "@opennextjs/cloudflare"
|
||||||
|
import { eq } from "drizzle-orm"
|
||||||
|
import { getDb } from "@/db"
|
||||||
|
import { customers, type NewCustomer } from "@/db/schema"
|
||||||
|
import { getCurrentUser } from "@/lib/auth"
|
||||||
|
import { requirePermission } from "@/lib/permissions"
|
||||||
|
import { revalidatePath } from "next/cache"
|
||||||
|
|
||||||
|
export async function createCustomer(
|
||||||
|
data: Omit<NewCustomer, "id" | "createdAt" | "updatedAt">
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
requirePermission(user, "customer", "create")
|
||||||
|
|
||||||
|
const { env } = await getCloudflareContext()
|
||||||
|
const db = getDb(env.DB)
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
|
||||||
|
await db.insert(customers).values({
|
||||||
|
id,
|
||||||
|
...data,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath("/dashboard/customers")
|
||||||
|
return { success: true, id }
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
err instanceof Error ? err.message : "Failed to create customer",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The steps are always the same:
|
||||||
|
|
||||||
|
1. **Authenticate.** Call `getCurrentUser()` to get the current user from the WorkOS session.
|
||||||
|
2. **Authorize.** Call `requirePermission(user, resource, action)` to check RBAC. This throws if the user's role doesn't have the required permission.
|
||||||
|
3. **Get the database.** Call `getCloudflareContext()` for the D1 binding, then `getDb(env.DB)` for the Drizzle instance.
|
||||||
|
4. **Do the work.** Run the query/mutation.
|
||||||
|
5. **Revalidate.** Call `revalidatePath()` to bust the Next.js cache for affected pages.
|
||||||
|
6. **Return a discriminated union.** `{ success: true }` or `{ success: false; error: string }`.
|
||||||
|
|
||||||
|
The return type is the most important convention. Every mutation returns a discriminated union, never throws to the caller. This means the calling component always knows whether the operation succeeded and can show appropriate feedback without try/catch boilerplate.
|
||||||
|
|
||||||
|
Read-only actions (like `getCustomers()`) skip the try/catch wrapper and return data directly, since read failures are handled by the component's error boundary.
|
||||||
|
|
||||||
|
|
||||||
|
why server actions over API routes
|
||||||
|
---
|
||||||
|
|
||||||
|
Three reasons:
|
||||||
|
|
||||||
|
**Type safety.** Server actions are regular TypeScript functions. The parameter types and return types flow through the compiler. If you change the shape of `NewCustomer`, every call site that passes invalid data becomes a compile error. With API routes, you'd need to manually validate request bodies and keep client-side types in sync.
|
||||||
|
|
||||||
|
**No fetch boilerplate.** Calling a server action from a client component is a function call. No `fetch()`, no URL construction, no JSON serialization, no Content-Type headers. Next.js handles the RPC transport automatically.
|
||||||
|
|
||||||
|
**Automatic revalidation.** `revalidatePath()` inside a server action tells Next.js to refetch the data for that page. The client gets fresh data without explicitly re-querying. This is the mechanism that makes optimistic UI possible without a state management library.
|
||||||
|
|
||||||
|
The tradeoff: server actions are Next.js-specific. If you wanted to call these mutations from a mobile app or a third-party integration, you'd need to wrap them in API routes anyway. Compass handles this by having the Capacitor mobile app load the web UI directly (it's a WebView, so server actions work normally) and by exposing dedicated API routes only for external integrations (NetSuite callbacks, push notification registration).
|
||||||
|
|
||||||
|
|
||||||
|
accessing environment
|
||||||
|
---
|
||||||
|
|
||||||
|
Cloudflare Workers don't have `process.env`. Environment variables come from the Cloudflare runtime context:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { env } = await getCloudflareContext()
|
||||||
|
const db = getDb(env.DB)
|
||||||
|
```
|
||||||
|
|
||||||
|
For non-D1 environment variables (API keys, secrets), the common pattern casts `env` to a string record:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const envRecord = env as unknown as Record<string, string>
|
||||||
|
const apiKey = envRecord.OPENROUTER_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
The double cast (`as unknown as Record<string, string>`) is necessary because the CloudflareEnv type is auto-generated and doesn't include manually-set secrets. This is the one place where the TypeScript discipline bends.
|
||||||
|
|
||||||
|
|
||||||
|
all action files
|
||||||
|
---
|
||||||
|
|
||||||
|
25 files in `src/app/actions/`, grouped by domain:
|
||||||
|
|
||||||
|
**core platform**
|
||||||
|
|
||||||
|
- `agent.ts` -- AI chat persistence (save/load/delete conversations)
|
||||||
|
- `agent-items.ts` -- agent-created items (todos, notes, checklists)
|
||||||
|
- `ai-config.ts` -- model configuration (get/set global model, user preferences, usage stats)
|
||||||
|
- `dashboards.ts` -- custom dashboard CRUD (save/load/delete/execute queries)
|
||||||
|
- `github.ts` -- GitHub API proxy (repo stats, commits, PRs, issues, create issues)
|
||||||
|
- `memories.ts` -- persistent memory CRUD (save, search, pin, delete)
|
||||||
|
- `plugins.ts` -- skill/plugin management (install, uninstall, toggle, list)
|
||||||
|
- `profile.ts` -- user profile updates
|
||||||
|
- `themes.ts` -- theme preference management (get/set preference, save/delete custom themes)
|
||||||
|
- `users.ts` -- user administration (list, update roles, deactivate)
|
||||||
|
|
||||||
|
**domain: people and orgs**
|
||||||
|
|
||||||
|
- `customers.ts` -- customer CRUD (create, read, update, delete)
|
||||||
|
- `vendors.ts` -- vendor CRUD
|
||||||
|
- `organizations.ts` -- organization management
|
||||||
|
- `teams.ts` -- team CRUD and membership
|
||||||
|
- `groups.ts` -- group CRUD and membership
|
||||||
|
|
||||||
|
**domain: projects and scheduling**
|
||||||
|
|
||||||
|
- `projects.ts` -- project listing (currently read-only, creation happens through the UI)
|
||||||
|
- `schedule.ts` -- schedule task CRUD (create, update, delete, reorder, bulk update)
|
||||||
|
- `baselines.ts` -- schedule baseline snapshots (save, load, delete)
|
||||||
|
- `workday-exceptions.ts` -- calendar exceptions (holidays, non-working days)
|
||||||
|
|
||||||
|
**domain: financials**
|
||||||
|
|
||||||
|
- `invoices.ts` -- invoice CRUD
|
||||||
|
- `vendor-bills.ts` -- vendor bill CRUD
|
||||||
|
- `payments.ts` -- payment recording
|
||||||
|
- `credit-memos.ts` -- credit memo management
|
||||||
|
|
||||||
|
**integrations**
|
||||||
|
|
||||||
|
- `netsuite-sync.ts` -- sync triggers, connection status, conflict resolution
|
||||||
|
- `google-drive.ts` -- 17 actions covering connect, disconnect, list, search, create, rename, move, trash, restore, upload, and more
|
||||||
|
|
||||||
|
|
||||||
|
the revalidation pattern
|
||||||
|
---
|
||||||
|
|
||||||
|
After every mutation, the action calls `revalidatePath()` to tell Next.js which pages have stale data:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
revalidatePath("/dashboard/customers") // specific page
|
||||||
|
revalidatePath("/dashboard/customers", "page") // just the page, not the layout
|
||||||
|
revalidatePath("/", "layout") // the entire app (nuclear option)
|
||||||
|
```
|
||||||
|
|
||||||
|
This is what keeps the UI in sync without client-side state management. When `createCustomer()` returns `{ success: true }`, the customer list page already has a pending revalidation. The next render will show the new customer.
|
||||||
|
|
||||||
|
The pattern avoids over-revalidation. Each action revalidates only the paths it affects. `createCustomer()` revalidates `/dashboard/customers`, not the entire dashboard. This keeps cache invalidation surgical.
|
||||||
|
|
||||||
|
|
||||||
|
the permission check
|
||||||
|
---
|
||||||
|
|
||||||
|
Most mutation actions call `requirePermission()` before doing anything:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
requirePermission(user, "customer", "create")
|
||||||
|
```
|
||||||
|
|
||||||
|
This throws an error if the user's role doesn't include the requested permission. The error is caught by the try/catch wrapper and returned as `{ success: false, error: "Permission denied: ..." }`.
|
||||||
|
|
||||||
|
Read-only actions use `requirePermission` with the "read" action. Some actions (like `getProjects()`) skip the permission check entirely because they return minimal data (just IDs and names) that's needed for UI dropdowns regardless of role.
|
||||||
|
|
||||||
|
The permission system is documented in detail in [auth-system.md](./auth-system.md).
|
||||||
@ -1,199 +0,0 @@
|
|||||||
# Authentication System Implementation
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Custom authentication system integrated with WorkOS API, replacing hosted UI with mobile-first custom pages that match Compass design language.
|
|
||||||
|
|
||||||
## Implemented Features
|
|
||||||
|
|
||||||
### Phase 1: Foundation ✅
|
|
||||||
- WorkOS client wrapper (`src/lib/workos-client.ts`)
|
|
||||||
- Auth layout with centered card (`src/app/(auth)/layout.tsx`)
|
|
||||||
- Reusable password input with visibility toggle (`src/components/auth/password-input.tsx`)
|
|
||||||
|
|
||||||
### Phase 2: Login Flow ✅
|
|
||||||
- Login API endpoint (`src/app/api/auth/login/route.ts`)
|
|
||||||
- Password login form (`src/components/auth/login-form.tsx`)
|
|
||||||
- Passwordless login form with 6-digit codes (`src/components/auth/passwordless-form.tsx`)
|
|
||||||
- Login page with tabs (`src/app/(auth)/login/page.tsx`)
|
|
||||||
|
|
||||||
### Phase 3: Signup & Verification ✅
|
|
||||||
- Signup API endpoint (`src/app/api/auth/signup/route.ts`)
|
|
||||||
- Email verification API endpoint (`src/app/api/auth/verify-email/route.ts`)
|
|
||||||
- Signup form with validation (`src/components/auth/signup-form.tsx`)
|
|
||||||
- Email verification form (`src/components/auth/verify-email-form.tsx`)
|
|
||||||
- Signup page (`src/app/(auth)/signup/page.tsx`)
|
|
||||||
- Verification page (`src/app/(auth)/verify-email/page.tsx`)
|
|
||||||
|
|
||||||
### Phase 4: Password Reset ✅
|
|
||||||
- Password reset request API (`src/app/api/auth/password-reset/route.ts`)
|
|
||||||
- Password reset confirmation API (`src/app/api/auth/reset-password/route.ts`)
|
|
||||||
- Reset request form (`src/components/auth/reset-password-form.tsx`)
|
|
||||||
- Set new password form (`src/components/auth/set-password-form.tsx`)
|
|
||||||
- Reset password pages (`src/app/(auth)/reset-password/`)
|
|
||||||
|
|
||||||
### Phase 5: Invite Acceptance ✅
|
|
||||||
- Invite acceptance API (`src/app/api/auth/accept-invite/route.ts`)
|
|
||||||
- Invite form (`src/components/auth/invite-form.tsx`)
|
|
||||||
- Invite page (`src/app/(auth)/invite/[token]/page.tsx`)
|
|
||||||
|
|
||||||
### Phase 6: Middleware & Polish ✅
|
|
||||||
- Route protection middleware (`src/middleware.ts`)
|
|
||||||
- Security headers (X-Frame-Options, X-Content-Type-Options, HSTS)
|
|
||||||
- Helper functions in `src/lib/auth.ts` (requireAuth, requireEmailVerified)
|
|
||||||
- OAuth callback route (`src/app/api/auth/callback/route.ts`)
|
|
||||||
- Updated wrangler.jsonc with WORKOS_REDIRECT_URI
|
|
||||||
|
|
||||||
## Dev Mode Functionality
|
|
||||||
|
|
||||||
All authentication flows work in development mode without WorkOS credentials:
|
|
||||||
- Login redirects to dashboard immediately
|
|
||||||
- Signup creates mock users
|
|
||||||
- Protected routes are accessible
|
|
||||||
- All forms validate correctly
|
|
||||||
|
|
||||||
## Production Deployment Checklist
|
|
||||||
|
|
||||||
### 1. WorkOS Configuration
|
|
||||||
Set these secrets via `wrangler secret put`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
wrangler secret put WORKOS_API_KEY
|
|
||||||
# Enter: sk_live_...
|
|
||||||
|
|
||||||
wrangler secret put WORKOS_CLIENT_ID
|
|
||||||
# Enter: client_...
|
|
||||||
|
|
||||||
wrangler secret put WORKOS_COOKIE_PASSWORD
|
|
||||||
# Enter: [32+ character random string]
|
|
||||||
```
|
|
||||||
|
|
||||||
Generate cookie password:
|
|
||||||
```bash
|
|
||||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Environment Variables
|
|
||||||
Already configured in `wrangler.jsonc`:
|
|
||||||
- `WORKOS_REDIRECT_URI: https://compass.openrangeconstruction.ltd/api/auth/callback`
|
|
||||||
|
|
||||||
### 3. WorkOS Dashboard Setup
|
|
||||||
1. Go to https://dashboard.workos.com
|
|
||||||
2. Create a new organization (or use existing)
|
|
||||||
3. Configure redirect URI: `https://compass.openrangeconstruction.ltd/api/auth/callback`
|
|
||||||
4. Enable authentication methods:
|
|
||||||
- Email/Password
|
|
||||||
- Magic Auth (passwordless codes)
|
|
||||||
5. Copy Client ID and API Key
|
|
||||||
|
|
||||||
### 4. Cloudflare Rate Limiting
|
|
||||||
Configure rate limiting rules in Cloudflare dashboard:
|
|
||||||
- `/api/auth/login`: 5 attempts per 15 minutes per IP
|
|
||||||
- `/api/auth/signup`: 3 attempts per hour per IP
|
|
||||||
- `/api/auth/password-reset`: 3 attempts per hour per IP
|
|
||||||
|
|
||||||
### 5. Test Production Auth Flow
|
|
||||||
1. Deploy to production: `bun run deploy`
|
|
||||||
2. Navigate to login page
|
|
||||||
3. Test password login
|
|
||||||
4. Test passwordless login
|
|
||||||
5. Test signup flow
|
|
||||||
6. Test email verification
|
|
||||||
7. Test password reset
|
|
||||||
8. Verify protected routes redirect to login
|
|
||||||
|
|
||||||
### 6. Invite Users
|
|
||||||
Use existing People page to invite users:
|
|
||||||
1. Go to `/dashboard/people`
|
|
||||||
2. Click "Invite User"
|
|
||||||
3. User receives WorkOS invitation email
|
|
||||||
4. User accepts via `/invite/[token]` page
|
|
||||||
|
|
||||||
## Security Features
|
|
||||||
|
|
||||||
- HTTPS-only (enforced via Cloudflare)
|
|
||||||
- CSRF protection (Next.js built-in + WorkOS)
|
|
||||||
- Rate limiting (via Cloudflare rules - needs setup)
|
|
||||||
- Password strength validation (8+ chars, uppercase, lowercase, number)
|
|
||||||
- Code expiration (10 minutes for magic auth)
|
|
||||||
- Session rotation (WorkOS handles refresh tokens)
|
|
||||||
- Secure headers (X-Frame-Options, HSTS, nosniff)
|
|
||||||
- Email verification enforcement (via middleware)
|
|
||||||
- Cookie encryption (AES-GCM via WORKOS_COOKIE_PASSWORD)
|
|
||||||
|
|
||||||
## Mobile Optimizations
|
|
||||||
|
|
||||||
- 44px touch targets for primary actions
|
|
||||||
- 16px input text (prevents iOS zoom)
|
|
||||||
- Responsive layouts (flex-col sm:flex-row)
|
|
||||||
- Proper keyboard types (email, password, numeric)
|
|
||||||
- Auto-submit on 6-digit code completion
|
|
||||||
- Full-width buttons on mobile
|
|
||||||
|
|
||||||
## Known Issues
|
|
||||||
|
|
||||||
None currently. All lint errors have been fixed.
|
|
||||||
|
|
||||||
## Next Steps (Future Enhancements)
|
|
||||||
|
|
||||||
1. Add 2FA/MFA support (WorkOS supports this)
|
|
||||||
2. Add OAuth providers (Google, Microsoft) for SSO
|
|
||||||
3. Add audit logging for sensitive auth events
|
|
||||||
4. Implement session timeout warnings
|
|
||||||
5. Add "remember me" functionality
|
|
||||||
6. Add account lockout after failed attempts
|
|
||||||
7. Add "Login with passkey" support
|
|
||||||
|
|
||||||
## Files Created
|
|
||||||
|
|
||||||
### Core Infrastructure
|
|
||||||
- `src/lib/workos-client.ts`
|
|
||||||
- `src/app/(auth)/layout.tsx`
|
|
||||||
- `src/components/auth/password-input.tsx`
|
|
||||||
|
|
||||||
### Login
|
|
||||||
- `src/app/api/auth/login/route.ts`
|
|
||||||
- `src/components/auth/login-form.tsx`
|
|
||||||
- `src/components/auth/passwordless-form.tsx`
|
|
||||||
- `src/app/(auth)/login/page.tsx`
|
|
||||||
|
|
||||||
### Signup & Verification
|
|
||||||
- `src/app/api/auth/signup/route.ts`
|
|
||||||
- `src/app/api/auth/verify-email/route.ts`
|
|
||||||
- `src/components/auth/signup-form.tsx`
|
|
||||||
- `src/components/auth/verify-email-form.tsx`
|
|
||||||
- `src/app/(auth)/signup/page.tsx`
|
|
||||||
- `src/app/(auth)/verify-email/page.tsx`
|
|
||||||
|
|
||||||
### Password Reset
|
|
||||||
- `src/app/api/auth/password-reset/route.ts`
|
|
||||||
- `src/app/api/auth/reset-password/route.ts`
|
|
||||||
- `src/components/auth/reset-password-form.tsx`
|
|
||||||
- `src/components/auth/set-password-form.tsx`
|
|
||||||
- `src/app/(auth)/reset-password/page.tsx`
|
|
||||||
- `src/app/(auth)/reset-password/[token]/page.tsx`
|
|
||||||
|
|
||||||
### Invites
|
|
||||||
- `src/app/api/auth/accept-invite/route.ts`
|
|
||||||
- `src/components/auth/invite-form.tsx`
|
|
||||||
- `src/app/(auth)/invite/[token]/page.tsx`
|
|
||||||
|
|
||||||
### OAuth
|
|
||||||
- `src/app/api/auth/callback/route.ts`
|
|
||||||
|
|
||||||
## Files Modified
|
|
||||||
|
|
||||||
- `src/lib/auth.ts` - Added requireAuth() and requireEmailVerified()
|
|
||||||
- `src/middleware.ts` - Added route protection and security headers
|
|
||||||
- `wrangler.jsonc` - Added WORKOS_REDIRECT_URI variable
|
|
||||||
|
|
||||||
## Testing in Dev Mode
|
|
||||||
|
|
||||||
All authentication pages are accessible at:
|
|
||||||
- http://localhost:3004/login
|
|
||||||
- http://localhost:3004/signup
|
|
||||||
- http://localhost:3004/reset-password
|
|
||||||
- http://localhost:3004/verify-email
|
|
||||||
- http://localhost:3004/invite/[token]
|
|
||||||
|
|
||||||
Dev server running on port 3004 (3000 was in use).
|
|
||||||
@ -1,266 +0,0 @@
|
|||||||
people management system - implementation status
|
|
||||||
===
|
|
||||||
|
|
||||||
completed work
|
|
||||||
---
|
|
||||||
|
|
||||||
### phase 1: database and auth foundation ✅
|
|
||||||
|
|
||||||
**database schema** (`src/db/schema.ts`)
|
|
||||||
- users table (workos user sync)
|
|
||||||
- organizations table (internal vs client orgs)
|
|
||||||
- organization_members (user-org mapping)
|
|
||||||
- teams and team_members
|
|
||||||
- groups and group_members
|
|
||||||
- project_members (project-level access)
|
|
||||||
- migration generated and applied: `drizzle/0006_brainy_vulcan.sql`
|
|
||||||
|
|
||||||
**auth integration**
|
|
||||||
- workos authkit installed: `@workos-inc/authkit-nextjs`
|
|
||||||
- middleware with dev mode fallback: `src/middleware.ts`
|
|
||||||
- bypasses auth when workos not configured
|
|
||||||
- allows dev without real credentials
|
|
||||||
- auth utilities: `src/lib/auth.ts`
|
|
||||||
- getCurrentUser() - returns mock user in dev mode
|
|
||||||
- ensureUserExists() - syncs workos users to db
|
|
||||||
- handleSignOut() - logout functionality
|
|
||||||
- permissions system: `src/lib/permissions.ts`
|
|
||||||
- 4 roles: admin, office, field, client
|
|
||||||
- resource-based permissions (project, schedule, budget, etc)
|
|
||||||
- can(), requirePermission(), getPermissions() helpers
|
|
||||||
- callback handler: `src/app/callback/route.ts`
|
|
||||||
|
|
||||||
**environment setup**
|
|
||||||
- `.dev.vars` updated with workos placeholders
|
|
||||||
- `wrangler.jsonc` configured with WORKOS_REDIRECT_URI
|
|
||||||
|
|
||||||
### phase 2: server actions ✅
|
|
||||||
|
|
||||||
**user management** (`src/app/actions/users.ts`)
|
|
||||||
- getUsers() - fetch all users with relations
|
|
||||||
- updateUserRole() - change user role
|
|
||||||
- deactivateUser() - soft delete
|
|
||||||
- assignUserToProject() - project assignment
|
|
||||||
- assignUserToTeam() - team assignment
|
|
||||||
- assignUserToGroup() - group assignment
|
|
||||||
- inviteUser() - create invited user
|
|
||||||
|
|
||||||
**organizations** (`src/app/actions/organizations.ts`)
|
|
||||||
- getOrganizations() - fetch all orgs
|
|
||||||
- createOrganization() - create new org
|
|
||||||
|
|
||||||
**teams** (`src/app/actions/teams.ts`)
|
|
||||||
- getTeams() - fetch all teams
|
|
||||||
- createTeam() - create new team
|
|
||||||
- deleteTeam() - remove team
|
|
||||||
|
|
||||||
**groups** (`src/app/actions/groups.ts`)
|
|
||||||
- getGroups() - fetch all groups
|
|
||||||
- createGroup() - create new group
|
|
||||||
- deleteGroup() - remove group
|
|
||||||
|
|
||||||
all actions follow existing project patterns:
|
|
||||||
- use getCloudflareContext() for D1 access
|
|
||||||
- permission checks with requirePermission()
|
|
||||||
- return { success, error? } format
|
|
||||||
- revalidatePath() after mutations
|
|
||||||
|
|
||||||
### phase 3: basic ui ✅
|
|
||||||
|
|
||||||
**navigation**
|
|
||||||
- people nav item added to sidebar (`src/components/app-sidebar.tsx`)
|
|
||||||
|
|
||||||
**people page** (`src/app/dashboard/people/page.tsx`)
|
|
||||||
- client component with useEffect data loading
|
|
||||||
- loading state
|
|
||||||
- empty state
|
|
||||||
- table integration
|
|
||||||
- edit and deactivate handlers
|
|
||||||
|
|
||||||
**people table** (`src/components/people-table.tsx`)
|
|
||||||
- tanstack react table integration
|
|
||||||
- columns: checkbox, name/email, role, teams, groups, projects, actions
|
|
||||||
- search by name/email
|
|
||||||
- filter by role dropdown
|
|
||||||
- row selection
|
|
||||||
- pagination
|
|
||||||
- actions dropdown (edit, assign, deactivate)
|
|
||||||
|
|
||||||
**seed data**
|
|
||||||
- seed-users.sql with 5 users, 2 orgs, 2 teams, 2 groups
|
|
||||||
- applied to local database
|
|
||||||
- users include admin, office, field, and client roles
|
|
||||||
|
|
||||||
remaining work
|
|
||||||
---
|
|
||||||
|
|
||||||
### phase 4: advanced ui components
|
|
||||||
|
|
||||||
**user drawer** (`src/components/people/user-drawer.tsx`)
|
|
||||||
- full profile editing
|
|
||||||
- tabs: profile, access, activity
|
|
||||||
- role/team/group assignment
|
|
||||||
- avatar upload
|
|
||||||
|
|
||||||
**invite dialog** (`src/components/people/invite-user-dialog.tsx`)
|
|
||||||
- email input with validation
|
|
||||||
- user type selection (team/client)
|
|
||||||
- organization selection
|
|
||||||
- role/group/team assignment
|
|
||||||
- integration with inviteUser() action
|
|
||||||
|
|
||||||
**bulk actions** (`src/components/people/bulk-actions-bar.tsx`)
|
|
||||||
- appears when rows selected
|
|
||||||
- bulk role assignment
|
|
||||||
- bulk team/group assignment
|
|
||||||
- bulk deactivate
|
|
||||||
|
|
||||||
**supporting components**
|
|
||||||
- role-selector.tsx
|
|
||||||
- group-selector.tsx
|
|
||||||
- team-selector.tsx
|
|
||||||
- permissions-editor.tsx (advanced permissions UI)
|
|
||||||
- user-avatar-upload.tsx
|
|
||||||
|
|
||||||
### phase 5: workos configuration
|
|
||||||
|
|
||||||
**dashboard setup**
|
|
||||||
1. create workos account
|
|
||||||
2. create organization
|
|
||||||
3. get API keys (client_id, api_key)
|
|
||||||
4. generate cookie password (32+ chars)
|
|
||||||
|
|
||||||
**update credentials**
|
|
||||||
- `.dev.vars` - local development
|
|
||||||
- wrangler secrets - production
|
|
||||||
```bash
|
|
||||||
wrangler secret put WORKOS_API_KEY
|
|
||||||
wrangler secret put WORKOS_CLIENT_ID
|
|
||||||
wrangler secret put WORKOS_COOKIE_PASSWORD
|
|
||||||
```
|
|
||||||
|
|
||||||
**test auth flow**
|
|
||||||
- login/logout
|
|
||||||
- user creation on first login
|
|
||||||
- session management
|
|
||||||
- redirect after auth
|
|
||||||
|
|
||||||
### phase 6: integration and testing
|
|
||||||
|
|
||||||
**end-to-end testing**
|
|
||||||
- invite user flow
|
|
||||||
- edit user profile
|
|
||||||
- role assignment
|
|
||||||
- team/group assignment
|
|
||||||
- project access
|
|
||||||
- permission enforcement
|
|
||||||
- mobile responsive
|
|
||||||
- accessibility
|
|
||||||
|
|
||||||
**cross-browser testing**
|
|
||||||
- chrome, firefox, safari
|
|
||||||
- mobile browsers
|
|
||||||
|
|
||||||
### phase 7: production deployment
|
|
||||||
|
|
||||||
**database migration**
|
|
||||||
```bash
|
|
||||||
bun run db:migrate:prod
|
|
||||||
```
|
|
||||||
|
|
||||||
**deploy**
|
|
||||||
```bash
|
|
||||||
bun deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
**post-deployment**
|
|
||||||
- verify workos callback URL
|
|
||||||
- test production auth flow
|
|
||||||
- invite real users
|
|
||||||
- verify permissions
|
|
||||||
|
|
||||||
technical notes
|
|
||||||
---
|
|
||||||
|
|
||||||
### dev mode behavior
|
|
||||||
when workos env vars contain "placeholder" or are missing:
|
|
||||||
- middleware allows all requests through
|
|
||||||
- getCurrentUser() returns mock admin user
|
|
||||||
- no actual authentication happens
|
|
||||||
- allows building/testing UI without workos setup
|
|
||||||
|
|
||||||
### database patterns
|
|
||||||
- all IDs are text (UUIDs)
|
|
||||||
- all dates are text (ISO 8601)
|
|
||||||
- boolean columns use integer(mode: "boolean")
|
|
||||||
- foreign keys with onDelete: "cascade"
|
|
||||||
- getCloudflareContext() for D1 access in actions
|
|
||||||
|
|
||||||
### permission model
|
|
||||||
- role-based by default (4 roles)
|
|
||||||
- resource + action pattern
|
|
||||||
- extensible for granular permissions later
|
|
||||||
- enforced in server actions
|
|
||||||
|
|
||||||
### ui patterns
|
|
||||||
- client components use "use client"
|
|
||||||
- server actions called from client
|
|
||||||
- toast notifications for user feedback
|
|
||||||
- optimistic updates where appropriate
|
|
||||||
- revalidatePath after mutations
|
|
||||||
|
|
||||||
files created/modified
|
|
||||||
---
|
|
||||||
|
|
||||||
**new files**
|
|
||||||
- src/middleware.ts
|
|
||||||
- src/lib/auth.ts
|
|
||||||
- src/lib/permissions.ts
|
|
||||||
- src/app/callback/route.ts
|
|
||||||
- src/app/actions/users.ts
|
|
||||||
- src/app/actions/organizations.ts
|
|
||||||
- src/app/actions/teams.ts
|
|
||||||
- src/app/actions/groups.ts
|
|
||||||
- src/app/dashboard/people/page.tsx
|
|
||||||
- src/components/people-table.tsx
|
|
||||||
- src/components/people/ (directory for future components)
|
|
||||||
- drizzle/0006_brainy_vulcan.sql
|
|
||||||
- seed-users.sql
|
|
||||||
|
|
||||||
**modified files**
|
|
||||||
- src/db/schema.ts (added auth tables and types)
|
|
||||||
- src/components/app-sidebar.tsx (added people nav item)
|
|
||||||
- .dev.vars (added workos placeholders)
|
|
||||||
- wrangler.jsonc (added WORKOS_REDIRECT_URI)
|
|
||||||
|
|
||||||
next steps
|
|
||||||
---
|
|
||||||
|
|
||||||
1. **test current implementation**
|
|
||||||
```bash
|
|
||||||
bun dev
|
|
||||||
# visit http://localhost:3000/dashboard/people
|
|
||||||
# verify table loads with seed data
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **build user drawer** - most important next component
|
|
||||||
- allows editing user profiles
|
|
||||||
- assign roles/teams/groups
|
|
||||||
- view activity
|
|
||||||
|
|
||||||
3. **build invite dialog** - enables adding new users
|
|
||||||
- email validation
|
|
||||||
- role selection
|
|
||||||
- organization assignment
|
|
||||||
|
|
||||||
4. **configure workos** - when ready for real auth
|
|
||||||
- set up dashboard
|
|
||||||
- update credentials
|
|
||||||
- test login flow
|
|
||||||
|
|
||||||
5. **deploy** - when ready
|
|
||||||
- migrate prod database
|
|
||||||
- set prod secrets
|
|
||||||
- deploy to cloudflare
|
|
||||||
|
|
||||||
the foundation is solid. remaining work is primarily ui polish and workos configuration.
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
AI Chat Panel
|
|
||||||
===
|
|
||||||
|
|
||||||
status: disabled
|
|
||||||
branch: `feat/ai-chat-panel`
|
|
||||||
|
|
||||||
overview
|
|
||||||
---
|
|
||||||
|
|
||||||
a collapsible right-side chat panel that mirrors the left sidebar's behavior. uses prompt-kit components (shadcn-compatible AI primitives) for the chat UI. styled to match the sidebar's color scheme (bg-sidebar, text-sidebar-foreground). currently uses mock responses - structured for a real AI backend later.
|
|
||||||
|
|
||||||
enabling the feature
|
|
||||||
---
|
|
||||||
|
|
||||||
to re-enable, update `src/app/dashboard/layout.tsx`:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { cookies } from "next/headers"
|
|
||||||
import { ChatPanel } from "@/components/chat-panel"
|
|
||||||
import { ChatPanelTrigger } from "@/components/chat-panel-trigger"
|
|
||||||
import { ChatPanelProvider } from "@/hooks/use-chat-panel"
|
|
||||||
|
|
||||||
// inside the component:
|
|
||||||
const cookieStore = await cookies()
|
|
||||||
const chatPanelOpen = cookieStore.get("chat_panel_state")?.value === "true"
|
|
||||||
|
|
||||||
// wrap SidebarProvider contents with:
|
|
||||||
<ChatPanelProvider defaultOpen={chatPanelOpen}>
|
|
||||||
{/* existing sidebar + content */}
|
|
||||||
<ChatPanel />
|
|
||||||
<ChatPanelTrigger />
|
|
||||||
</ChatPanelProvider>
|
|
||||||
```
|
|
||||||
|
|
||||||
file structure
|
|
||||||
---
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── components/
|
|
||||||
│ ├── ui/
|
|
||||||
│ │ ├── chat-container.tsx (prompt-kit: auto-scrolling message area)
|
|
||||||
│ │ ├── message.tsx (prompt-kit: message with avatar + content)
|
|
||||||
│ │ ├── prompt-input.tsx (prompt-kit: auto-resize textarea + actions)
|
|
||||||
│ │ ├── prompt-suggestion.tsx (prompt-kit: button-style suggestion pills)
|
|
||||||
│ │ ├── markdown.tsx (prompt-kit dep: markdown rendering)
|
|
||||||
│ │ └── code-block.tsx (prompt-kit dep: syntax highlighted code)
|
|
||||||
│ ├── chat-panel.tsx (panel shell - gap div + fixed container)
|
|
||||||
│ ├── chat-panel-trigger.tsx (floating FAB button, bottom-right)
|
|
||||||
│ └── chat-panel-content.tsx (messages + input + suggestions inner UI)
|
|
||||||
├── hooks/
|
|
||||||
│ └── use-chat-panel.tsx (context provider, cookie persistence, Ctrl+I)
|
|
||||||
└── lib/
|
|
||||||
└── chat-suggestions.ts (route-based suggestion configs)
|
|
||||||
```
|
|
||||||
|
|
||||||
components used
|
|
||||||
---
|
|
||||||
|
|
||||||
**prompt-kit** (installed via shadcn registry: `https://prompt-kit.com/c/[name].json`)
|
|
||||||
|
|
||||||
- `ChatContainerRoot` / `ChatContainerContent` / `ChatContainerScrollAnchor` - wraps `use-stick-to-bottom` for auto-scroll behavior
|
|
||||||
- `Message` / `MessageAvatar` / `MessageContent` - composable message layout with avatar support
|
|
||||||
- `PromptInput` / `PromptInputTextarea` / `PromptInputActions` / `PromptInputAction` - compound input component with auto-resize, enter-to-submit, tooltip actions
|
|
||||||
- `PromptSuggestion` - button-based suggestions with highlight support
|
|
||||||
|
|
||||||
**dependencies added:**
|
|
||||||
|
|
||||||
- `use-stick-to-bottom` - handles the auto-scroll-to-bottom behavior in the chat container
|
|
||||||
|
|
||||||
how it works
|
|
||||||
---
|
|
||||||
|
|
||||||
**panel architecture:**
|
|
||||||
|
|
||||||
mirrors the left sidebar's pattern exactly:
|
|
||||||
1. a "gap" div in the flex flow that transitions width (0 <-> 24rem) to push content
|
|
||||||
2. a fixed container that transitions its `right` position to slide in/out
|
|
||||||
3. inner div uses `bg-sidebar` to match the sidebar's color
|
|
||||||
|
|
||||||
**state management:**
|
|
||||||
|
|
||||||
- `ChatPanelProvider` wraps everything, provides open/close state
|
|
||||||
- cookie persistence via `chat_panel_state` cookie (7-day max-age)
|
|
||||||
- keyboard shortcut: `Cmd/Ctrl+I` (doesn't conflict with sidebar's `Cmd+B`)
|
|
||||||
- mobile: renders as a Sheet from the right side
|
|
||||||
|
|
||||||
**styling approach:**
|
|
||||||
|
|
||||||
uses sidebar CSS variables throughout to maintain visual consistency:
|
|
||||||
- `bg-sidebar` / `text-sidebar-foreground` for panel background
|
|
||||||
- `bg-sidebar-accent` / `text-sidebar-accent-foreground` for user messages
|
|
||||||
- `bg-sidebar-foreground/10` for assistant messages
|
|
||||||
- `border-sidebar-foreground/20` for borders/input
|
|
||||||
- `bg-sidebar-primary` for the send button
|
|
||||||
|
|
||||||
**mock responses:**
|
|
||||||
|
|
||||||
currently uses a random canned response with a simulated delay (800-1500ms). the message handling is structured to be easily swapped for a real streaming backend.
|
|
||||||
|
|
||||||
what's left to do
|
|
||||||
---
|
|
||||||
|
|
||||||
1. **real AI backend** - replace the mock `setTimeout` in `chat-panel-content.tsx` with actual API calls (vercel AI SDK `useChat` would slot in nicely here)
|
|
||||||
|
|
||||||
2. **streaming responses** - the `MessageContent` component supports markdown rendering (`markdown` prop). when streaming is added, use it for formatted AI responses
|
|
||||||
|
|
||||||
3. **token usage** - the prompt-kit components support more features than currently used:
|
|
||||||
- `MessageActions` / `MessageAction` for copy/thumbs-up/thumbs-down on messages
|
|
||||||
- `PromptSuggestion` highlight mode for autocomplete-style suggestions
|
|
||||||
- multiple file attachment via the prompt input actions area
|
|
||||||
|
|
||||||
4. **context awareness** - the suggestion system (`chat-suggestions.ts`) currently just matches pathname prefix. could be enhanced to include:
|
|
||||||
- current project data in the prompt
|
|
||||||
- file contents when on the files route
|
|
||||||
- schedule data when on the schedule route
|
|
||||||
|
|
||||||
5. **message persistence** - messages are currently in React state (lost on navigation). could persist to localStorage or a server-side store
|
|
||||||
|
|
||||||
6. **panel width** - currently hardcoded to 24rem. could be made resizable with `react-resizable-panels` (already in deps)
|
|
||||||
|
|
||||||
7. **animation polish** - the panel slide animation works but could benefit from the same `data-state` attribute pattern the sidebar uses for more granular CSS control
|
|
||||||
276
docs/development/conventions.md
Executable file
276
docs/development/conventions.md
Executable file
@ -0,0 +1,276 @@
|
|||||||
|
Coding Conventions
|
||||||
|
===
|
||||||
|
|
||||||
|
This document explains the conventions Compass follows and, more importantly, why. These aren't arbitrary rules - each one exists because it caught a real bug, prevented a class of errors, or made the codebase easier to work with at scale.
|
||||||
|
|
||||||
|
|
||||||
|
TypeScript discipline
|
||||||
|
---
|
||||||
|
|
||||||
|
The TypeScript config (`tsconfig.json`) targets ES2024 with strict mode enabled. The compiler is set to bundler module resolution, which means imports resolve the way Next.js expects them to. Beyond what the compiler enforces, we follow these additional conventions:
|
||||||
|
|
||||||
|
### No `any`
|
||||||
|
|
||||||
|
Every `any` is a hole in the type system. Use `unknown` instead and narrow with type guards. The difference matters: `any` silently disables checking on everything it touches, while `unknown` forces you to prove the type before using it.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// instead of:
|
||||||
|
function process(data: any) { return data.name }
|
||||||
|
|
||||||
|
// write:
|
||||||
|
function process(data: unknown): string {
|
||||||
|
if (typeof data === "object" && data !== null && "name" in data) {
|
||||||
|
return String((data as Record<string, unknown>).name)
|
||||||
|
}
|
||||||
|
throw new Error("expected object with name")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### No `as` type assertions
|
||||||
|
|
||||||
|
Type assertions tell the compiler "trust me" - but the compiler is usually smarter than us. If you need `as`, it means the types don't actually line up and you should fix that instead. The exception is when interfacing with external libraries that have imprecise types, and even then prefer type guards.
|
||||||
|
|
||||||
|
### No `!` non-null assertions
|
||||||
|
|
||||||
|
The `!` operator says "I know this isn't null even though TypeScript thinks it might be." That's exactly the kind of assumption that causes runtime crashes. Check for null explicitly.
|
||||||
|
|
||||||
|
### Discriminated unions over optional properties
|
||||||
|
|
||||||
|
When a value can be in different states, encode those states as a union rather than making everything optional. This way the compiler enforces that you handle every case.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// the server action pattern used throughout src/app/actions/:
|
||||||
|
type ActionResult<T> =
|
||||||
|
| { readonly success: true; readonly data: T }
|
||||||
|
| { readonly success: false; readonly error: string }
|
||||||
|
```
|
||||||
|
|
||||||
|
Every server action in the codebase returns this shape. When you check `result.success`, TypeScript narrows the type and you can't accidentally access `data` on a failed result.
|
||||||
|
|
||||||
|
### `readonly` everywhere mutation isn't intended
|
||||||
|
|
||||||
|
Mark arrays as `ReadonlyArray<T>`, records as `Readonly<Record<K, V>>`, and interface properties as `readonly`. This catches accidental mutations at compile time. The theme system types are a good example - `ColorMap` is `Readonly<Record<ThemeColorKey, string>>` because theme colors should never be mutated after creation.
|
||||||
|
|
||||||
|
### No `enum`
|
||||||
|
|
||||||
|
Enums compile to runtime objects with bidirectional mappings, which is surprising and adds bundle weight. Use `as const` arrays with derived union types instead:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// from src/lib/agent/plugins/types.ts:
|
||||||
|
export const PLUGIN_CAPABILITIES = [
|
||||||
|
"tools",
|
||||||
|
"actions",
|
||||||
|
"components",
|
||||||
|
"prompt",
|
||||||
|
"queries",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type PluginCapability =
|
||||||
|
(typeof PLUGIN_CAPABILITIES)[number]
|
||||||
|
```
|
||||||
|
|
||||||
|
This gives you the same type safety, works with `Array.includes()` for runtime checks, and compiles to just an array.
|
||||||
|
|
||||||
|
### Branded types for IDs
|
||||||
|
|
||||||
|
Primitive types can be accidentally swapped - a user ID and a project ID are both strings, but passing one where the other is expected is a bug. Branded types prevent this:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type SemVer = string & { readonly __brand: "SemVer" }
|
||||||
|
```
|
||||||
|
|
||||||
|
The plugin system uses this for version strings. The `isSemVer()` type guard validates the format and narrows the type.
|
||||||
|
|
||||||
|
### Explicit return types on exported functions
|
||||||
|
|
||||||
|
Every exported function declares its return type. This isn't redundant - it's documentation that the compiler enforces. It also prevents accidentally changing a function's return type when modifying its implementation.
|
||||||
|
|
||||||
|
### Effect-free module scope
|
||||||
|
|
||||||
|
No `console.log`, `fetch`, or mutations at the top level of any module. Side effects during import make code unpredictable and break tree-shaking. All the theme system's font loading, for example, happens inside `loadGoogleFonts()` - never at import time.
|
||||||
|
|
||||||
|
|
||||||
|
Server action conventions
|
||||||
|
---
|
||||||
|
|
||||||
|
All data mutations go through server actions in `src/app/actions/`. The pattern is consistent across the entire codebase:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
"use server"
|
||||||
|
|
||||||
|
import { getCloudflareContext } from "@opennextjs/cloudflare"
|
||||||
|
import { getDb } from "@/db"
|
||||||
|
import { getCurrentUser } from "@/lib/auth"
|
||||||
|
import { revalidatePath } from "next/cache"
|
||||||
|
|
||||||
|
export async function doSomething(input: string): Promise<
|
||||||
|
| { readonly success: true; readonly data: SomeType }
|
||||||
|
| { readonly success: false; readonly error: string }
|
||||||
|
> {
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user) return { success: false, error: "not authenticated" }
|
||||||
|
|
||||||
|
const { env } = await getCloudflareContext()
|
||||||
|
const db = getDb(env.DB)
|
||||||
|
|
||||||
|
// ... do work ...
|
||||||
|
|
||||||
|
revalidatePath("/dashboard/whatever")
|
||||||
|
return { success: true, data: result }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The key parts:
|
||||||
|
1. `"use server"` directive at the top of the file
|
||||||
|
2. Auth check via `getCurrentUser()` - always first
|
||||||
|
3. Database access via `getCloudflareContext()` then `getDb(env.DB)`
|
||||||
|
4. Discriminated union return type - never throw, always return
|
||||||
|
5. `revalidatePath()` after mutations to update the client
|
||||||
|
|
||||||
|
Components never call `fetch()`. They call server actions for mutations and use server components for reads.
|
||||||
|
|
||||||
|
|
||||||
|
Validation schemas
|
||||||
|
---
|
||||||
|
|
||||||
|
Zod schemas live in `src/lib/validations/`, organized by domain. The `common.ts` file provides reusable primitives:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// from src/lib/validations/common.ts:
|
||||||
|
export const emailSchema = z
|
||||||
|
.string()
|
||||||
|
.min(1, "Email address is required")
|
||||||
|
.email("Please enter a valid email address")
|
||||||
|
|
||||||
|
export const currencySchema = z
|
||||||
|
.number()
|
||||||
|
.nonnegative("Amount cannot be negative")
|
||||||
|
.multipleOf(0.01, "Amount must have at most 2 decimal places")
|
||||||
|
|
||||||
|
export const userRoles = [
|
||||||
|
"admin", "executive", "accounting",
|
||||||
|
"project_manager", "coordinator", "office",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type UserRole = (typeof userRoles)[number]
|
||||||
|
```
|
||||||
|
|
||||||
|
Domain-specific schemas compose these primitives. Authentication schemas in `auth.ts`, financial schemas in `financial.ts`, etc. Form validation uses react-hook-form with `@hookform/resolvers` to connect Zod schemas to the UI.
|
||||||
|
|
||||||
|
One thing to note: the AI SDK v6 uses `zod/v4` internally, so tool input schemas import from `"zod/v4"` while the rest of the app uses regular `"zod"`. Keep these separate.
|
||||||
|
|
||||||
|
|
||||||
|
Component conventions
|
||||||
|
---
|
||||||
|
|
||||||
|
### shadcn/ui
|
||||||
|
|
||||||
|
Compass uses shadcn/ui with the new-york style variant. Components live in `src/components/ui/` and are added via:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bunx shadcn@latest add <component-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
These are auto-generated and shouldn't be heavily customized. Build app-specific behavior in wrapper components instead.
|
||||||
|
|
||||||
|
### Class merging with cn()
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// from src/lib/utils.ts:
|
||||||
|
import { type ClassValue, clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`cn()` combines `clsx` (conditional classes) with `tailwind-merge` (deduplicates conflicting Tailwind classes). Use it everywhere you compose class strings.
|
||||||
|
|
||||||
|
### Icons
|
||||||
|
|
||||||
|
Two icon libraries are configured with package import optimization in `next.config.ts`:
|
||||||
|
|
||||||
|
- `lucide-react` - primary icon set
|
||||||
|
- `@tabler/icons-react` - supplementary icons
|
||||||
|
|
||||||
|
Import icons directly: `import { IconName } from "lucide-react"`. The bundler tree-shakes unused icons.
|
||||||
|
|
||||||
|
### Animations
|
||||||
|
|
||||||
|
Two animation libraries coexist:
|
||||||
|
- `framer-motion` / `motion` - for complex, stateful animations
|
||||||
|
- Tailwind CSS animations via `tw-animate-css` - for simple transitions
|
||||||
|
|
||||||
|
### Data tables
|
||||||
|
|
||||||
|
Built on `@tanstack/react-table`. The pattern uses a `DataTable` component that takes column definitions and data arrays.
|
||||||
|
|
||||||
|
### Forms
|
||||||
|
|
||||||
|
React Hook Form with Zod resolvers. The validation schemas from `src/lib/validations/` plug directly into form configuration.
|
||||||
|
|
||||||
|
|
||||||
|
File organization
|
||||||
|
---
|
||||||
|
|
||||||
|
### Import aliases
|
||||||
|
|
||||||
|
The `tsconfig.json` configures a single path alias:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All imports from `src/` use `@/` prefix: `@/components/ui/button`, `@/lib/auth`, `@/db/schema`. No relative imports that climb more than one level.
|
||||||
|
|
||||||
|
### Directory responsibilities
|
||||||
|
|
||||||
|
- `src/app/actions/` - server actions, one file per domain (projects, customers, themes, plugins, etc.)
|
||||||
|
- `src/app/api/` - API routes for things that can't be server actions (streaming, webhooks, OAuth callbacks)
|
||||||
|
- `src/components/` - React components, grouped by feature (agent/, native/, netsuite/, files/)
|
||||||
|
- `src/components/ui/` - shadcn primitives only, don't put app logic here
|
||||||
|
- `src/db/` - schema definitions and the `getDb()` helper. Schema files are split by domain to keep them manageable.
|
||||||
|
- `src/hooks/` - custom hooks, including native/mobile hooks
|
||||||
|
- `src/lib/` - business logic, integrations, utilities. Subdirectories for major systems (agent/, netsuite/, google/, theme/, native/)
|
||||||
|
- `src/lib/validations/` - Zod schemas, organized by domain
|
||||||
|
- `src/types/` - global TypeScript type definitions
|
||||||
|
|
||||||
|
### Database schema files
|
||||||
|
|
||||||
|
Schema is split across files to avoid a single massive file. Drizzle config lists all of them:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// drizzle.config.ts:
|
||||||
|
schema: [
|
||||||
|
"./src/db/schema.ts",
|
||||||
|
"./src/db/schema-netsuite.ts",
|
||||||
|
"./src/db/schema-plugins.ts",
|
||||||
|
"./src/db/schema-agent.ts",
|
||||||
|
"./src/db/schema-ai-config.ts",
|
||||||
|
"./src/db/schema-theme.ts",
|
||||||
|
"./src/db/schema-google.ts",
|
||||||
|
"./src/db/schema-dashboards.ts",
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
All tables use text IDs (UUIDs) and text dates (ISO 8601 strings). This is a deliberate choice for D1/SQLite compatibility - SQLite doesn't have native UUID or timestamp types, and storing them as text avoids ambiguity.
|
||||||
|
|
||||||
|
|
||||||
|
Environment access pattern
|
||||||
|
---
|
||||||
|
|
||||||
|
Cloudflare Workers don't have `process.env`. Environment variables come through the request context:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getCloudflareContext } from "@opennextjs/cloudflare"
|
||||||
|
|
||||||
|
const { env } = await getCloudflareContext()
|
||||||
|
const db = getDb(env.DB) // D1 binding
|
||||||
|
```
|
||||||
|
|
||||||
|
For environment variables that aren't D1 bindings (like API keys), access them as string properties on `env`. The Cloudflare type definitions from `wrangler.jsonc` are generated into `cloudflare-env.d.ts` via `bun run cf-typegen`.
|
||||||
215
docs/development/getting-started.md
Executable file
215
docs/development/getting-started.md
Executable file
@ -0,0 +1,215 @@
|
|||||||
|
Getting Started
|
||||||
|
===
|
||||||
|
|
||||||
|
This guide walks you through setting up Compass for local development. By the end you'll have the dev server running, a local D1 database with migrations applied, and a clear picture of every environment variable the app needs.
|
||||||
|
|
||||||
|
|
||||||
|
Prerequisites
|
||||||
|
---
|
||||||
|
|
||||||
|
You need these installed before anything else:
|
||||||
|
|
||||||
|
- **Bun** (v1.1+) - the package manager and runtime. Compass uses Bun exclusively; don't mix in npm or pnpm.
|
||||||
|
- **Wrangler CLI** (v4+) - Cloudflare's CLI for D1 databases, secrets management, and deployment. Installed as a dev dependency, but having it globally helps for ad-hoc commands.
|
||||||
|
- **Node.js** (v20+) - needed by Next.js and some tooling even though Bun handles package management.
|
||||||
|
|
||||||
|
For mobile development (optional):
|
||||||
|
- **Xcode** (macOS only) - for iOS builds via Capacitor
|
||||||
|
- **Android Studio** - for Android builds via Capacitor
|
||||||
|
|
||||||
|
|
||||||
|
Clone and install
|
||||||
|
---
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone git@github.com:High-Performance-Structures/compass.git
|
||||||
|
cd compass
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it. Bun resolves everything from `bun.lockb`.
|
||||||
|
|
||||||
|
|
||||||
|
Environment variables
|
||||||
|
---
|
||||||
|
|
||||||
|
Copy `.env.example` to `.dev.vars` for local development:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .dev.vars
|
||||||
|
```
|
||||||
|
|
||||||
|
Wrangler reads `.dev.vars` automatically when running the local dev server. For production, set these as Cloudflare secrets via `wrangler secret put <KEY>`.
|
||||||
|
|
||||||
|
### Required
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `WORKOS_API_KEY` | API key from your WorkOS dashboard. Powers all authentication. |
|
||||||
|
| `WORKOS_CLIENT_ID` | Client ID from WorkOS. Paired with the API key. |
|
||||||
|
| `WORKOS_COOKIE_PASSWORD` | At least 32 characters. Encrypts the session cookie. Generate with `openssl rand -base64 24`. |
|
||||||
|
| `NEXT_PUBLIC_WORKOS_REDIRECT_URI` | OAuth callback URL. Use `http://localhost:3000/callback` locally. |
|
||||||
|
|
||||||
|
### AI agent
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `OPENROUTER_API_KEY` | API key from OpenRouter. The AI agent routes through OpenRouter to access the kimi-k2.5 model. Without this, the chat agent won't function. |
|
||||||
|
|
||||||
|
### NetSuite integration (optional)
|
||||||
|
|
||||||
|
Only needed if connecting to NetSuite for financial sync.
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `NETSUITE_ACCOUNT_ID` | Your NetSuite account identifier. |
|
||||||
|
| `NETSUITE_CLIENT_ID` | OAuth 2.0 client ID from NetSuite. |
|
||||||
|
| `NETSUITE_CLIENT_SECRET` | OAuth 2.0 client secret. |
|
||||||
|
| `NETSUITE_REDIRECT_URI` | OAuth callback. Use `http://localhost:3000/api/netsuite/callback` locally. |
|
||||||
|
| `NETSUITE_TOKEN_ENCRYPTION_KEY` | AES-GCM encryption key for storing tokens at rest. Generate with `openssl rand -hex 32`. |
|
||||||
|
| `NETSUITE_CONCURRENCY_LIMIT` | Max concurrent API requests. Defaults to 15 (NetSuite's shared limit). |
|
||||||
|
|
||||||
|
### Google Drive integration (optional)
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `GOOGLE_SERVICE_ACCOUNT_ENCRYPTION_KEY` | Encrypts stored service account credentials. Generate with `openssl rand -hex 32`. |
|
||||||
|
|
||||||
|
### Push notifications (optional, mobile)
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `FCM_SERVER_KEY` | Firebase Cloud Messaging server key for sending push notifications to iOS/Android. |
|
||||||
|
|
||||||
|
### GitHub deployment (optional)
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `GITHUB_TOKEN` | GitHub repo token for automatic deployments. |
|
||||||
|
| `GITHUB_REPO` | Repository in `owner/repo` format. Default: `High-Performance-Structures/compass`. |
|
||||||
|
|
||||||
|
### Production-only
|
||||||
|
|
||||||
|
The `wrangler.jsonc` config sets `WORKOS_REDIRECT_URI` as a Worker var pointing to the production domain. You don't need this locally since `NEXT_PUBLIC_WORKOS_REDIRECT_URI` covers it.
|
||||||
|
|
||||||
|
|
||||||
|
Development commands
|
||||||
|
---
|
||||||
|
|
||||||
|
### Core
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|---------|-------------|
|
||||||
|
| `bun dev` | Starts the Next.js dev server with Turbopack on port 3000. |
|
||||||
|
| `bun run build` | Production build via Next.js. |
|
||||||
|
| `bun preview` | Builds then runs on the Cloudflare Workers runtime locally. Good for catching runtime differences between Node and Workers. |
|
||||||
|
| `bun lint` | Runs ESLint across the codebase. |
|
||||||
|
| `bun deploy` | Builds with OpenNext and deploys to Cloudflare Workers. |
|
||||||
|
|
||||||
|
### Database
|
||||||
|
|
||||||
|
Compass uses Cloudflare D1 (SQLite) with Drizzle ORM. The schema is split across multiple files and Drizzle generates migrations from them.
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|---------|-------------|
|
||||||
|
| `bun run db:generate` | Generates migration SQL from schema changes. Run this after modifying any `src/db/schema*.ts` file. |
|
||||||
|
| `bun run db:migrate:local` | Applies pending migrations to your local D1 instance. |
|
||||||
|
| `bun run db:migrate:prod` | Applies pending migrations to the production D1 database. |
|
||||||
|
|
||||||
|
The schema files that Drizzle watches (configured in `drizzle.config.ts`):
|
||||||
|
|
||||||
|
```
|
||||||
|
src/db/schema.ts - core tables (users, projects, customers, vendors, etc.)
|
||||||
|
src/db/schema-netsuite.ts - netsuite sync tables
|
||||||
|
src/db/schema-plugins.ts - plugin/skills tables
|
||||||
|
src/db/schema-agent.ts - agent conversation tables
|
||||||
|
src/db/schema-ai-config.ts - AI usage tracking and model preferences
|
||||||
|
src/db/schema-theme.ts - custom themes and user preferences
|
||||||
|
src/db/schema-google.ts - google drive auth and starred files
|
||||||
|
src/db/schema-dashboards.ts - custom AI-built dashboards
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrations live in `drizzle/` and are applied in order. Never modify an existing migration file - always generate a new one.
|
||||||
|
|
||||||
|
### Mobile (Capacitor)
|
||||||
|
|
||||||
|
The mobile app is a webview wrapper that loads the live Cloudflare deployment. It's not a static export.
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|---------|-------------|
|
||||||
|
| `bun cap:sync` | Syncs web assets and Capacitor plugins to native projects. Run after adding new Capacitor plugins. |
|
||||||
|
| `bun cap:ios` | Opens the Xcode project for iOS development. |
|
||||||
|
| `bun cap:android` | Opens the Android Studio project. |
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|---------|-------------|
|
||||||
|
| `bun run cf-typegen` | Regenerates the `cloudflare-env.d.ts` type definitions from `wrangler.jsonc` bindings. Run after changing Worker bindings. |
|
||||||
|
|
||||||
|
|
||||||
|
Running locally
|
||||||
|
---
|
||||||
|
|
||||||
|
1. Make sure `.dev.vars` has at least the WorkOS variables set.
|
||||||
|
2. Apply database migrations:
|
||||||
|
```bash
|
||||||
|
bun run db:migrate:local
|
||||||
|
```
|
||||||
|
3. Start the dev server:
|
||||||
|
```bash
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
4. Open `http://localhost:3000`.
|
||||||
|
|
||||||
|
The Turbopack dev server is fast. Hot reload works for both server and client components.
|
||||||
|
|
||||||
|
To test against the actual Cloudflare Workers runtime (catches D1 quirks, binding issues, etc.):
|
||||||
|
```bash
|
||||||
|
bun preview
|
||||||
|
```
|
||||||
|
|
||||||
|
This builds with OpenNext, then runs a local Workers emulator. It's slower to start but more representative of production behavior.
|
||||||
|
|
||||||
|
|
||||||
|
Deploying
|
||||||
|
---
|
||||||
|
|
||||||
|
Compass deploys to Cloudflare Workers via OpenNext:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs `opennextjs-cloudflare build` followed by `opennextjs-cloudflare deploy`. The Worker is configured in `wrangler.jsonc` with:
|
||||||
|
|
||||||
|
- D1 database binding (`DB`)
|
||||||
|
- Assets binding for static files
|
||||||
|
- Cloudflare Images binding
|
||||||
|
- AI binding
|
||||||
|
- Self-reference service binding (for internal routing)
|
||||||
|
- Custom domain route (`compass.openrangeconstruction.ltd`)
|
||||||
|
|
||||||
|
Production secrets are set via `wrangler secret put <KEY>` and managed in the Cloudflare dashboard.
|
||||||
|
|
||||||
|
|
||||||
|
Project structure overview
|
||||||
|
---
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
app/ - Next.js App Router pages, API routes, server actions
|
||||||
|
components/ - React components (ui/ for shadcn primitives)
|
||||||
|
db/ - Drizzle schema files and getDb() helper
|
||||||
|
hooks/ - Custom React hooks (including native/mobile hooks)
|
||||||
|
lib/ - Business logic, integrations, utilities
|
||||||
|
types/ - Global TypeScript type definitions
|
||||||
|
|
||||||
|
drizzle/ - Generated migration SQL files
|
||||||
|
ios/ - Xcode project (Capacitor)
|
||||||
|
android/ - Android Studio project (Capacitor)
|
||||||
|
docs/ - Documentation
|
||||||
|
public/ - Static assets
|
||||||
|
```
|
||||||
|
|
||||||
|
For a deeper dive into the architecture, see the `docs/architecture/` directory.
|
||||||
301
docs/development/plugins.md
Executable file
301
docs/development/plugins.md
Executable file
@ -0,0 +1,301 @@
|
|||||||
|
Plugin and Skills System
|
||||||
|
===
|
||||||
|
|
||||||
|
Compass's AI agent can be extended with plugins and skills. The two terms describe different levels of integration: skills are lightweight prompt injections loaded from GitHub, while full plugins can contribute tools, components, query types, and action handlers.
|
||||||
|
|
||||||
|
The system lives in `src/lib/agent/plugins/` with four core files: types, skills-client, loader, and registry.
|
||||||
|
|
||||||
|
|
||||||
|
Skills vs plugins
|
||||||
|
---
|
||||||
|
|
||||||
|
A **skill** is a SKILL.md file hosted on GitHub (following the skills.sh format). When installed, the markdown body gets injected into the agent's system prompt at priority 80. That's it - skills don't run code, they just add knowledge and instructions to the agent. Think of them as specialized system prompt modules.
|
||||||
|
|
||||||
|
A **plugin** is a full TypeScript module that exports a manifest and optional tools, prompt sections, components, query types, and action handlers. Plugins can actually extend the agent's capabilities with new tool calls and UI components.
|
||||||
|
|
||||||
|
The tradeoff is clear: skills are safe and easy to install (just markdown text), while plugins require more trust since they execute code.
|
||||||
|
|
||||||
|
|
||||||
|
Source types
|
||||||
|
---
|
||||||
|
|
||||||
|
Four source types are defined in `src/lib/agent/plugins/types.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const PLUGIN_SOURCE_TYPES = [
|
||||||
|
"builtin", // bundled with Compass
|
||||||
|
"local", // loaded from local filesystem
|
||||||
|
"npm", // installed from npm
|
||||||
|
"skills", // GitHub-hosted SKILL.md files
|
||||||
|
] as const
|
||||||
|
```
|
||||||
|
|
||||||
|
Currently, `builtin` and `skills` are the only fully implemented source types. The `local` and `npm` loaders return "not yet supported" errors - they're infrastructure for future expansion.
|
||||||
|
|
||||||
|
|
||||||
|
How skills work
|
||||||
|
---
|
||||||
|
|
||||||
|
### Installation flow
|
||||||
|
|
||||||
|
When a user asks the agent to install a skill, the `installSkill` tool triggers this sequence:
|
||||||
|
|
||||||
|
1. The source string (e.g., `"owner/repo"` or `"owner/repo/skill-name"`) is parsed into GitHub coordinates
|
||||||
|
2. `fetchSkillFromGitHub()` tries multiple URL patterns against raw.githubusercontent.com:
|
||||||
|
- `owner/repo/main/SKILL.md`
|
||||||
|
- `owner/repo/main/skills/SKILL.md`
|
||||||
|
- `owner/repo/main/<path>/SKILL.md`
|
||||||
|
- `owner/repo/main/skills/<path>/SKILL.md`
|
||||||
|
3. The fetched markdown is parsed by `parseSkillMd()`, which extracts YAML frontmatter and body
|
||||||
|
4. The skill is saved to the database as a plugin record with `sourceType: "skills"`
|
||||||
|
5. The markdown body is stored in the `plugin_config` table under the key `"content"`
|
||||||
|
6. The registry cache is cleared so the next request picks up the new skill
|
||||||
|
|
||||||
|
### SKILL.md format
|
||||||
|
|
||||||
|
A SKILL.md file has YAML frontmatter followed by a markdown body:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: "My Skill"
|
||||||
|
description: "What this skill teaches the agent"
|
||||||
|
allowedTools: "queryData, navigateTo"
|
||||||
|
userInvocable: true
|
||||||
|
---
|
||||||
|
|
||||||
|
The actual prompt content that gets injected into
|
||||||
|
the agent's system prompt goes here.
|
||||||
|
```
|
||||||
|
|
||||||
|
The frontmatter fields:
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `name` | yes | Display name for the skill |
|
||||||
|
| `description` | no | Brief description |
|
||||||
|
| `allowedTools` | no | Comma-separated list of tools this skill expects to use |
|
||||||
|
| `userInvocable` | no | Whether users can invoke this skill directly |
|
||||||
|
|
||||||
|
The parser (`parseSkillMd`) is intentionally lenient with YAML - it handles both quoted and unquoted values, normalizes key casing, and stores unknown frontmatter keys in a metadata bag.
|
||||||
|
|
||||||
|
### Prompt injection at priority 80
|
||||||
|
|
||||||
|
When the registry builds, skills become `PluginModule` objects with a single prompt section:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const mod: PluginModule = {
|
||||||
|
manifest: { /* ... */ },
|
||||||
|
promptSections: [{
|
||||||
|
heading: row.name,
|
||||||
|
content: configRow.value, // the SKILL.md body
|
||||||
|
priority: 80,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Priority 80 means skills inject after the core system prompt (which uses lower priorities) but before any high-priority overrides. The registry's `getPromptSections()` sorts all sections by priority, so skills slot into a predictable position.
|
||||||
|
|
||||||
|
|
||||||
|
Plugin architecture
|
||||||
|
---
|
||||||
|
|
||||||
|
### Capabilities
|
||||||
|
|
||||||
|
Plugins declare what they contribute:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const PLUGIN_CAPABILITIES = [
|
||||||
|
"tools", // new tool calls for the agent
|
||||||
|
"actions", // client-side action handlers
|
||||||
|
"components", // UI components for dynamic rendering
|
||||||
|
"prompt", // system prompt sections
|
||||||
|
"queries", // new query types for the queryData tool
|
||||||
|
] as const
|
||||||
|
```
|
||||||
|
|
||||||
|
### PluginManifest
|
||||||
|
|
||||||
|
Every plugin declares a manifest:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface PluginManifest {
|
||||||
|
readonly id: string // kebab-case identifier
|
||||||
|
readonly name: string
|
||||||
|
readonly description: string
|
||||||
|
readonly version: SemVer // branded string type, validated by isSemVer()
|
||||||
|
readonly capabilities: ReadonlyArray<PluginCapability>
|
||||||
|
readonly requiredEnvVars?: ReadonlyArray<string>
|
||||||
|
readonly optionalEnvVars?: ReadonlyArray<string>
|
||||||
|
readonly dependencies?: ReadonlyArray<string>
|
||||||
|
readonly author?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The loader validates manifests strictly: `id` must be kebab-case, `version` must be valid semver, and capabilities must be from the known set. Missing required env vars cause the plugin to be silently skipped during registry build (not errored) - this prevents a misconfigured plugin from breaking the entire agent.
|
||||||
|
|
||||||
|
### PluginModule
|
||||||
|
|
||||||
|
The runtime shape a full plugin exports:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface PluginModule {
|
||||||
|
readonly manifest: PluginManifest
|
||||||
|
readonly tools?: Readonly<Record<string, unknown>>
|
||||||
|
readonly promptSections?: ReadonlyArray<PromptSection>
|
||||||
|
readonly components?: ReadonlyArray<PluginComponent>
|
||||||
|
readonly queryTypes?: ReadonlyArray<PluginQueryType>
|
||||||
|
readonly actionHandlers?: ReadonlyArray<PluginActionHandler>
|
||||||
|
readonly onEnable?: (ctx: PluginContext) => Promise<PluginResult>
|
||||||
|
readonly onDisable?: (ctx: PluginContext) => Promise<void>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Lifecycle hooks (`onEnable`/`onDisable`) receive a context with the database, environment variables, and current user ID. The `onEnable` hook returns a `PluginResult` so it can report failures.
|
||||||
|
|
||||||
|
|
||||||
|
Plugin registry
|
||||||
|
---
|
||||||
|
|
||||||
|
The registry (`src/lib/agent/plugins/registry.ts`) aggregates all enabled plugins into a single interface:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PluginRegistry {
|
||||||
|
readonly plugins: ReadonlyMap<string, PluginModule>
|
||||||
|
getTools(): Readonly<Record<string, unknown>>
|
||||||
|
getPromptSections(): ReadonlyArray<PromptSection>
|
||||||
|
getComponents(): ReadonlyArray<PluginComponent>
|
||||||
|
getQueryTypes(): ReadonlyArray<PluginQueryType>
|
||||||
|
getActionHandlers(): ReadonlyArray<PluginActionHandler>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build process
|
||||||
|
|
||||||
|
`buildRegistry()` queries all enabled plugin rows from the database, then for each:
|
||||||
|
|
||||||
|
1. **Skills**: loads the `"content"` config value and wraps it as a PluginModule with a prompt section at priority 80
|
||||||
|
2. **Builtin/local/npm**: calls `loadPluginModule()` which validates the manifest and checks required env vars
|
||||||
|
|
||||||
|
The result is a `Map<string, PluginModule>` wrapped in a registry object that provides accessor methods. These methods merge contributions from all plugins - `getTools()` combines all tool definitions, `getPromptSections()` collects and sorts all prompt injections, etc.
|
||||||
|
|
||||||
|
### 30-second TTL cache
|
||||||
|
|
||||||
|
The registry is cached per worker isolate with a 30-second TTL:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
let cached: {
|
||||||
|
readonly registry: PluginRegistry
|
||||||
|
readonly expiresAt: number
|
||||||
|
} | null = null
|
||||||
|
|
||||||
|
const TTL_MS = 30_000
|
||||||
|
|
||||||
|
export async function getRegistry(
|
||||||
|
db: DbClient,
|
||||||
|
env: Readonly<Record<string, string>>,
|
||||||
|
): Promise<PluginRegistry> {
|
||||||
|
const now = Date.now()
|
||||||
|
if (cached && now < cached.expiresAt) {
|
||||||
|
return cached.registry
|
||||||
|
}
|
||||||
|
const registry = await buildRegistry(db, env)
|
||||||
|
cached = { registry, expiresAt: now + TTL_MS }
|
||||||
|
return registry
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This means installing or toggling a skill takes effect within 30 seconds, or immediately if `clearRegistryCache()` is called (which all the skill management actions do). The cache is per-isolate, so different Workers instances have independent caches.
|
||||||
|
|
||||||
|
|
||||||
|
Agent tools for skill management
|
||||||
|
---
|
||||||
|
|
||||||
|
Four tools in `src/lib/agent/tools.ts` let the AI agent manage skills:
|
||||||
|
|
||||||
|
### installSkill
|
||||||
|
|
||||||
|
Installs a skill from GitHub. Takes a `source` string in `owner/repo` or `owner/repo/skill-name` format. Requires admin role. The tool description instructs the agent to always confirm with the user before installing.
|
||||||
|
|
||||||
|
### listInstalledSkills
|
||||||
|
|
||||||
|
Lists all installed skills with their status, source, and a content preview (first 200 characters of the prompt body).
|
||||||
|
|
||||||
|
### toggleInstalledSkill
|
||||||
|
|
||||||
|
Enables or disables an installed skill. Takes a `pluginId` and `enabled` boolean. Requires admin role.
|
||||||
|
|
||||||
|
### uninstallSkill
|
||||||
|
|
||||||
|
Permanently removes a skill. Deletes the plugin record, its config entries, and all event log entries. Requires admin role. The tool description instructs the agent to always confirm before uninstalling.
|
||||||
|
|
||||||
|
All four tools gate on `user.role !== "admin"` and return error messages for non-admin users.
|
||||||
|
|
||||||
|
|
||||||
|
Database tables
|
||||||
|
---
|
||||||
|
|
||||||
|
Three tables in `src/db/schema-plugins.ts`:
|
||||||
|
|
||||||
|
### plugins
|
||||||
|
|
||||||
|
The main registry of installed plugins and skills.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `id` | text (PK) | For skills: `"skill-"` + kebab-cased source path |
|
||||||
|
| `name` | text | Display name from manifest or SKILL.md frontmatter |
|
||||||
|
| `description` | text | Optional description |
|
||||||
|
| `version` | text | Semver string |
|
||||||
|
| `source` | text | Source identifier (GitHub path for skills, package name for npm) |
|
||||||
|
| `source_type` | text | One of: builtin, local, npm, skills |
|
||||||
|
| `capabilities` | text | Comma-separated capability list |
|
||||||
|
| `required_env_vars` | text | Comma-separated env var names (optional) |
|
||||||
|
| `status` | text | `enabled`, `disabled`, or `error`. Defaults to `disabled`. |
|
||||||
|
| `status_reason` | text | Explanation for error status (optional) |
|
||||||
|
| `enabled_by` | text (FK -> users) | Who enabled this plugin |
|
||||||
|
| `enabled_at` | text | When it was enabled |
|
||||||
|
| `installed_at` | text | When it was installed |
|
||||||
|
| `updated_at` | text | Last modification |
|
||||||
|
|
||||||
|
### plugin_config
|
||||||
|
|
||||||
|
Key-value configuration per plugin. For skills, this stores the SKILL.md body under the key `"content"` and optionally the allowed tools under `"allowedTools"`.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `id` | text (PK) | UUID |
|
||||||
|
| `plugin_id` | text (FK -> plugins, cascade) | Parent plugin |
|
||||||
|
| `key` | text | Config key |
|
||||||
|
| `value` | text | Config value |
|
||||||
|
| `is_encrypted` | integer (boolean) | Whether the value is encrypted. Defaults to false. |
|
||||||
|
| `updated_at` | text | Last modification |
|
||||||
|
|
||||||
|
### plugin_events
|
||||||
|
|
||||||
|
Audit log for plugin lifecycle events.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `id` | text (PK) | UUID |
|
||||||
|
| `plugin_id` | text (FK -> plugins, cascade) | Related plugin |
|
||||||
|
| `event_type` | text | One of: installed, enabled, disabled, configured, error |
|
||||||
|
| `details` | text | Human-readable description (e.g., "installed from owner/repo by user@email.com") |
|
||||||
|
| `user_id` | text (FK -> users) | Who triggered the event |
|
||||||
|
| `created_at` | text | When it happened |
|
||||||
|
|
||||||
|
Events cascade-delete with their parent plugin, so uninstalling a skill cleans up its entire audit trail.
|
||||||
|
|
||||||
|
|
||||||
|
Server actions
|
||||||
|
---
|
||||||
|
|
||||||
|
Five actions in `src/app/actions/plugins.ts`:
|
||||||
|
|
||||||
|
- `installSkill(source)` - fetches from GitHub, creates plugin + config + event records, clears cache
|
||||||
|
- `uninstallSkill(pluginId)` - deletes events, config, and plugin records (in that order), clears cache
|
||||||
|
- `toggleSkill(pluginId, enabled)` - updates status, logs event, clears cache
|
||||||
|
- `getInstalledSkills()` - lists all skills-type plugins with content previews
|
||||||
|
|
||||||
|
The `skillId()` helper converts a GitHub source path to a stable ID: `"owner/repo/name"` becomes `"skill-owner-repo-name"`. This ensures the same source always maps to the same plugin ID, preventing duplicate installs.
|
||||||
|
|
||||||
|
All actions follow the standard pattern: auth check, discriminated union return, `clearRegistryCache()` after mutations.
|
||||||
237
docs/development/theming.md
Executable file
237
docs/development/theming.md
Executable file
@ -0,0 +1,237 @@
|
|||||||
|
Theme System
|
||||||
|
===
|
||||||
|
|
||||||
|
Compass has a per-user theme system with 10 built-in presets and support for AI-generated custom themes. Users can switch themes instantly without page reload, and each user's preference persists independently.
|
||||||
|
|
||||||
|
The system lives in `src/lib/theme/` with four files: types, presets, apply, and fonts.
|
||||||
|
|
||||||
|
|
||||||
|
Why oklch
|
||||||
|
---
|
||||||
|
|
||||||
|
Every color in the theme system is defined in oklch format: `oklch(0.6671 0.0935 170.4436)`.
|
||||||
|
|
||||||
|
The choice of oklch over hex or hsl is deliberate. oklch is a perceptually uniform color space, which means that two colors with the same lightness value actually look equally bright to the human eye. In hsl, "50% lightness" for blue looks dramatically different from "50% lightness" for yellow. This matters when you're defining 32 color keys that need to feel visually consistent across different hues.
|
||||||
|
|
||||||
|
oklch has three components:
|
||||||
|
- **L** (0-1): perceptual lightness
|
||||||
|
- **C** (0-0.4ish): chroma (color intensity)
|
||||||
|
- **H** (0-360): hue angle
|
||||||
|
|
||||||
|
This makes it straightforward to create coherent dark/light mode pairs - you adjust the lightness channel while keeping hue and chroma consistent.
|
||||||
|
|
||||||
|
|
||||||
|
Color map
|
||||||
|
---
|
||||||
|
|
||||||
|
Each theme defines 32 color keys, once for light mode and once for dark. The `ThemeColorKey` type in `src/lib/theme/types.ts` enumerates all of them:
|
||||||
|
|
||||||
|
**Core UI colors** (16 keys):
|
||||||
|
- `background`, `foreground` - page background and default text
|
||||||
|
- `card`, `card-foreground` - card surfaces
|
||||||
|
- `popover`, `popover-foreground` - dropdown/dialog surfaces
|
||||||
|
- `primary`, `primary-foreground` - primary action color
|
||||||
|
- `secondary`, `secondary-foreground` - secondary actions
|
||||||
|
- `muted`, `muted-foreground` - subdued elements
|
||||||
|
- `accent`, `accent-foreground` - accent highlights
|
||||||
|
- `destructive`, `destructive-foreground` - danger/error states
|
||||||
|
|
||||||
|
**Utility colors** (3 keys):
|
||||||
|
- `border` - borders and dividers
|
||||||
|
- `input` - form input borders
|
||||||
|
- `ring` - focus ring color
|
||||||
|
|
||||||
|
**Chart colors** (5 keys):
|
||||||
|
- `chart-1` through `chart-5` - used by Recharts visualizations
|
||||||
|
|
||||||
|
**Sidebar colors** (8 keys):
|
||||||
|
- `sidebar`, `sidebar-foreground`, `sidebar-primary`, `sidebar-primary-foreground`, `sidebar-accent`, `sidebar-accent-foreground`, `sidebar-border`, `sidebar-ring`
|
||||||
|
|
||||||
|
The sidebar has its own color set because it's often visually distinct from the main content area. The native-compass preset, for example, uses a teal sidebar against a warm off-white background.
|
||||||
|
|
||||||
|
The type is defined as:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type ColorMap = Readonly<Record<ThemeColorKey, string>>
|
||||||
|
```
|
||||||
|
|
||||||
|
Readonly because theme colors should never be mutated after creation.
|
||||||
|
|
||||||
|
|
||||||
|
Fonts
|
||||||
|
---
|
||||||
|
|
||||||
|
Each theme specifies three font stacks:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface ThemeFonts {
|
||||||
|
readonly sans: string
|
||||||
|
readonly serif: string
|
||||||
|
readonly mono: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
These map to CSS variables `--font-sans`, `--font-serif`, and `--font-mono` that Tailwind v4 picks up.
|
||||||
|
|
||||||
|
Themes can also declare Google Fonts to load dynamically:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
fontSources: { googleFonts: ["Oxanium", "Source Code Pro"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
The `loadGoogleFonts()` function in `src/lib/theme/fonts.ts` handles this. It maintains a `Set<string>` of already-loaded fonts to avoid duplicate requests, constructs the Google Fonts CSS URL with weights 300-700, and injects a `<link>` element into the document head.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const families = toLoad
|
||||||
|
.map((f) => `family=${f.replace(/ /g, "+")}:wght@300;400;500;600;700`)
|
||||||
|
.join("&")
|
||||||
|
|
||||||
|
const href =
|
||||||
|
`https://fonts.googleapis.com/css2?${families}&display=swap`
|
||||||
|
```
|
||||||
|
|
||||||
|
The `display=swap` parameter ensures text remains visible while the font loads.
|
||||||
|
|
||||||
|
|
||||||
|
Design tokens
|
||||||
|
---
|
||||||
|
|
||||||
|
Beyond colors and fonts, each theme defines spatial and shadow tokens:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface ThemeTokens {
|
||||||
|
readonly radius: string // border radius (e.g., "1.575rem")
|
||||||
|
readonly spacing: string // base spacing unit (e.g., "0.3rem")
|
||||||
|
readonly trackingNormal: string // letter spacing
|
||||||
|
readonly shadowColor: string
|
||||||
|
readonly shadowOpacity: string
|
||||||
|
readonly shadowBlur: string
|
||||||
|
readonly shadowSpread: string
|
||||||
|
readonly shadowOffsetX: string
|
||||||
|
readonly shadowOffsetY: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Themes also define a full shadow scale from `2xs` to `2xl`, separately for light and dark modes. This allows themes to have fundamentally different shadow characters - doom-64 uses hard directional shadows while bubblegum uses pop-art style drop shadows.
|
||||||
|
|
||||||
|
|
||||||
|
How applyTheme() works
|
||||||
|
---
|
||||||
|
|
||||||
|
The core of the theme system is `applyTheme()` in `src/lib/theme/apply.ts`. It works by injecting a `<style>` element that overrides CSS custom properties:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function applyTheme(theme: ThemeDefinition): void {
|
||||||
|
const lightCSS = [
|
||||||
|
buildColorBlock(theme.light),
|
||||||
|
buildTokenBlock(theme),
|
||||||
|
buildShadowBlock(theme.shadows.light),
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
const darkCSS = [
|
||||||
|
buildColorBlock(theme.dark),
|
||||||
|
buildTokenBlock(theme),
|
||||||
|
buildShadowBlock(theme.shadows.dark),
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
const css =
|
||||||
|
`:root {\n${lightCSS}\n}\n.dark {\n${darkCSS}\n}`
|
||||||
|
|
||||||
|
let el = document.getElementById(STYLE_ID)
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement("style")
|
||||||
|
el.id = STYLE_ID
|
||||||
|
document.head.appendChild(el)
|
||||||
|
}
|
||||||
|
el.textContent = css
|
||||||
|
|
||||||
|
if (theme.fontSources.googleFonts.length > 0) {
|
||||||
|
loadGoogleFonts(theme.fontSources.googleFonts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The approach is straightforward: build CSS strings for light and dark modes, find or create a `<style id="compass-theme-vars">` element, and set its content. Since these CSS variables are what Tailwind and shadcn components already reference, the entire UI updates instantly. No page reload, no React re-render cascade.
|
||||||
|
|
||||||
|
`removeThemeOverride()` removes the injected style element, reverting to whatever the base CSS defines.
|
||||||
|
|
||||||
|
|
||||||
|
Built-in presets
|
||||||
|
---
|
||||||
|
|
||||||
|
Ten presets are defined in `src/lib/theme/presets.ts`:
|
||||||
|
|
||||||
|
| ID | Name | Description |
|
||||||
|
|----|------|-------------|
|
||||||
|
| `native-compass` | Native Compass | The default teal-forward construction palette. Sora font. |
|
||||||
|
| `corpo` | Corpo | Clean, professional blue palette for corporate environments. |
|
||||||
|
| `notebook` | Notebook | Warm, handwritten feel with sketchy aesthetics. |
|
||||||
|
| `doom-64` | Doom 64 | Gritty, industrial palette with sharp edges and no mercy. Oxanium font, 0px border radius. |
|
||||||
|
| `bubblegum` | Bubblegum | Playful pink and pastel palette with pop art shadows. |
|
||||||
|
| `developers-choice` | Developer's Choice | Retro pixel-font terminal aesthetic in teal-grey tones. |
|
||||||
|
| `anslopics-clood` | Anslopics Clood | Warm amber-orange palette with clean corporate lines. |
|
||||||
|
| `violet-bloom` | Violet Bloom | Deep violet primary with elegant rounded corners and tight tracking. |
|
||||||
|
| `soy` | Soy | Rosy pink and magenta palette with warm romantic tones. |
|
||||||
|
| `mocha` | Mocha | Warm coffee-brown palette with cozy earthy tones and offset shadows. |
|
||||||
|
|
||||||
|
`native-compass` is the default when no preference is set. Each preset demonstrates different design personalities - doom-64 uses 0px radius for sharp industrial edges, while native-compass uses 1.575rem for soft rounded corners.
|
||||||
|
|
||||||
|
The `DEFAULT_THEME_ID` export and `findPreset()` helper make it easy to look up presets by ID.
|
||||||
|
|
||||||
|
|
||||||
|
Custom themes via AI
|
||||||
|
---
|
||||||
|
|
||||||
|
The AI agent can generate custom themes through tool calls. The theme tools defined in `src/lib/agent/tools.ts` allow the agent to:
|
||||||
|
|
||||||
|
- `listThemes` - list all presets and custom themes
|
||||||
|
- `setTheme` - switch the user's active theme
|
||||||
|
- `generateTheme` - create a new custom theme from a description
|
||||||
|
- `editTheme` - modify an existing custom theme
|
||||||
|
|
||||||
|
When the agent generates a theme, it produces a complete `ThemeDefinition` (all 32 color keys for both light and dark, fonts, tokens, shadows) and saves it via the `saveCustomTheme` server action.
|
||||||
|
|
||||||
|
|
||||||
|
Database tables
|
||||||
|
---
|
||||||
|
|
||||||
|
Two tables in `src/db/schema-theme.ts` persist theme data:
|
||||||
|
|
||||||
|
### custom_themes
|
||||||
|
|
||||||
|
Stores AI-generated or user-created themes.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `id` | text (PK) | UUID |
|
||||||
|
| `user_id` | text (FK -> users) | Owner. Cascade delete. |
|
||||||
|
| `name` | text | Display name |
|
||||||
|
| `description` | text | Theme description |
|
||||||
|
| `theme_data` | text | Full ThemeDefinition as JSON |
|
||||||
|
| `created_at` | text | ISO 8601 timestamp |
|
||||||
|
| `updated_at` | text | ISO 8601 timestamp |
|
||||||
|
|
||||||
|
### user_theme_preference
|
||||||
|
|
||||||
|
Tracks which theme each user has active.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `user_id` | text (PK, FK -> users) | One preference per user. Cascade delete. |
|
||||||
|
| `active_theme_id` | text | ID of active theme (preset or custom) |
|
||||||
|
| `updated_at` | text | ISO 8601 timestamp |
|
||||||
|
|
||||||
|
|
||||||
|
Server actions
|
||||||
|
---
|
||||||
|
|
||||||
|
Five actions in `src/app/actions/themes.ts`:
|
||||||
|
|
||||||
|
- `getUserThemePreference()` - returns the user's active theme ID, defaulting to `"native-compass"`
|
||||||
|
- `setUserThemePreference(themeId)` - validates the theme exists (as preset or custom), then upserts the preference
|
||||||
|
- `getCustomThemes()` - lists all custom themes for the current user
|
||||||
|
- `getCustomThemeById(themeId)` - fetches a single custom theme
|
||||||
|
- `saveCustomTheme(name, description, themeData, existingId?)` - creates or updates a custom theme
|
||||||
|
- `deleteCustomTheme(themeId)` - deletes a custom theme and resets the user's preference to native-compass if they were using it
|
||||||
|
|
||||||
|
All follow the standard server action pattern: auth check, discriminated union return, `revalidatePath("/", "layout")` after mutations. The `setUserThemePreference` action uses `onConflictDoUpdate` for upsert behavior since the preference table is keyed by user ID.
|
||||||
150
docs/modules/financials.md
Executable file
150
docs/modules/financials.md
Executable file
@ -0,0 +1,150 @@
|
|||||||
|
Financials Module
|
||||||
|
===
|
||||||
|
|
||||||
|
The financials module tracks invoices, vendor bills, payments, and credit memos. These are the bread-and-butter financial documents in construction project management: invoices go out to clients, vendor bills come in from subcontractors and suppliers, payments record money moving, and credit memos adjust balances.
|
||||||
|
|
||||||
|
The module is designed to work both standalone (manual data entry in Compass) and as the local representation of NetSuite records (synced bidirectionally by the NetSuite module). Every financial table has a `netsuiteId` column that links to the NetSuite internal record when sync is active.
|
||||||
|
|
||||||
|
|
||||||
|
data model
|
||||||
|
---
|
||||||
|
|
||||||
|
All four financial tables are defined in `src/db/schema-netsuite.ts` alongside the sync infrastructure tables. They live in this schema file rather than the core schema because they were built specifically for the NetSuite integration, even though they're useful independently.
|
||||||
|
|
||||||
|
**`invoices`** -- customer-facing bills. Each invoice belongs to a customer and optionally a project. Tracks status (draft, open, paid, voided), issue date, due date, line-item subtotal/tax/total, amount paid, and amount remaining. Line items are stored as a JSON text column.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const invoices = sqliteTable("invoices", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
netsuiteId: text("netsuite_id"),
|
||||||
|
customerId: text("customer_id").notNull().references(() => customers.id),
|
||||||
|
projectId: text("project_id").references(() => projects.id),
|
||||||
|
invoiceNumber: text("invoice_number"),
|
||||||
|
status: text("status").notNull().default("draft"),
|
||||||
|
issueDate: text("issue_date").notNull(),
|
||||||
|
dueDate: text("due_date"),
|
||||||
|
subtotal: real("subtotal").notNull().default(0),
|
||||||
|
tax: real("tax").notNull().default(0),
|
||||||
|
total: real("total").notNull().default(0),
|
||||||
|
amountPaid: real("amount_paid").notNull().default(0),
|
||||||
|
amountDue: real("amount_due").notNull().default(0),
|
||||||
|
memo: text("memo"),
|
||||||
|
lineItems: text("line_items"),
|
||||||
|
createdAt: text("created_at").notNull(),
|
||||||
|
updatedAt: text("updated_at").notNull(),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**`vendor_bills`** -- bills from vendors (subcontractors, material suppliers). Same structure as invoices but references vendors instead of customers. Status defaults to "pending" rather than "draft" since bills arrive from external parties.
|
||||||
|
|
||||||
|
**`payments`** -- money in or out. Payments can reference a customer (incoming) or vendor (outgoing) and optionally a project. Tracks payment type, amount, date, method, and reference number. The `paymentType` field distinguishes between customer payments and vendor payments.
|
||||||
|
|
||||||
|
**`credit_memos`** -- adjustments to customer balances. Track status, total amount, amount applied to invoices, and remaining balance. Like invoices, line items are stored as JSON.
|
||||||
|
|
||||||
|
|
||||||
|
line items
|
||||||
|
---
|
||||||
|
|
||||||
|
Invoices, vendor bills, and credit memos store their line items as a JSON string in the `lineItems` column. This is a deliberate choice over normalized line-item tables: it simplifies the CRUD operations (one insert/update instead of insert-parent-then-insert-children), works well with the single-request-per-record constraint of NetSuite's REST API, and avoids the impedance mismatch between Compass's flat line items and NetSuite's nested `item.items` array.
|
||||||
|
|
||||||
|
When syncing with NetSuite, the line items are mapped between the local JSON format and NetSuite's `NetSuiteLineItem` type:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface NetSuiteLineItem {
|
||||||
|
line: number
|
||||||
|
item: { id: string; refName: string }
|
||||||
|
quantity: number
|
||||||
|
rate: number
|
||||||
|
amount: number
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `line` field is critical when updating line items in NetSuite. Omitting it causes NetSuite to add new line items instead of updating existing ones. This is one of the documented gotchas in the NetSuite module.
|
||||||
|
|
||||||
|
|
||||||
|
server actions
|
||||||
|
---
|
||||||
|
|
||||||
|
Each financial entity has its own action file with the same five operations:
|
||||||
|
|
||||||
|
`src/app/actions/invoices.ts`:
|
||||||
|
- `getInvoices(projectId?)` -- list all invoices, optionally filtered by project
|
||||||
|
- `getInvoice(id)` -- get a single invoice
|
||||||
|
- `createInvoice(data)` -- create an invoice
|
||||||
|
- `updateInvoice(id, data)` -- update an invoice
|
||||||
|
- `deleteInvoice(id)` -- delete an invoice
|
||||||
|
|
||||||
|
`src/app/actions/vendor-bills.ts`:
|
||||||
|
- `getVendorBills(projectId?)` / `getVendorBill(id)` / `createVendorBill(data)` / `updateVendorBill(id, data)` / `deleteVendorBill(id)`
|
||||||
|
|
||||||
|
`src/app/actions/payments.ts`:
|
||||||
|
- `getPayments()` / `getPayment(id)` / `createPayment(data)` / `updatePayment(id, data)` / `deletePayment(id)`
|
||||||
|
|
||||||
|
`src/app/actions/credit-memos.ts`:
|
||||||
|
- `getCreditMemos()` / `getCreditMemo(id)` / `createCreditMemo(data)` / `updateCreditMemo(id, data)` / `deleteCreditMemo(id)`
|
||||||
|
|
||||||
|
All actions require `finance` permission scope. Read operations need `finance:read`, creates need `finance:create`, updates need `finance:update`, deletes need `finance:delete`. All mutations revalidate `/dashboard/financials`.
|
||||||
|
|
||||||
|
The action structure is intentionally uniform. Each file follows the same pattern:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function createInvoice(
|
||||||
|
data: Omit<NewInvoice, "id" | "createdAt" | "updatedAt">
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
requirePermission(user, "finance", "create")
|
||||||
|
const { env } = await getCloudflareContext()
|
||||||
|
const db = getDb(env.DB)
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
await db.insert(invoices).values({ id, ...data, createdAt: now, updatedAt: now })
|
||||||
|
revalidatePath("/dashboard/financials")
|
||||||
|
return { success: true, id }
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err instanceof Error ? err.message : "..." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
NetSuite sync integration
|
||||||
|
---
|
||||||
|
|
||||||
|
When the NetSuite module is active, financial records flow bidirectionally:
|
||||||
|
|
||||||
|
**Pull (NetSuite -> Compass):** The `InvoiceMapper` and `VendorBillMapper` in `src/lib/netsuite/mappers/` translate NetSuite records into the local format. The delta sync pulls invoices and vendor bills modified since the last sync, upserts them locally, and tracks sync status per record.
|
||||||
|
|
||||||
|
**Push (Compass -> NetSuite):** Records created or modified in Compass get their sync metadata set to `pending_push`. The push operation sends them to NetSuite using idempotency keys to prevent duplicate creation.
|
||||||
|
|
||||||
|
**Linking:** The `netsuiteId` column on each financial table stores NetSuite's internal record ID. The `netsuite_sync_metadata` table tracks per-record sync state (synced, pending_push, conflict, error) with timestamps and retry counts.
|
||||||
|
|
||||||
|
Financial records can exist without a `netsuiteId` if they were created locally and haven't been synced yet, or if NetSuite integration isn't active. The UI works the same either way.
|
||||||
|
|
||||||
|
|
||||||
|
UI components
|
||||||
|
---
|
||||||
|
|
||||||
|
`src/components/financials/` provides 13 components:
|
||||||
|
|
||||||
|
**Tables:**
|
||||||
|
- `invoices-table.tsx` -- data table with sorting, filtering, status badges
|
||||||
|
- `vendor-bills-table.tsx` -- same for vendor bills
|
||||||
|
- `payments-table.tsx` -- payments list
|
||||||
|
- `credit-memos-table.tsx` -- credit memos list
|
||||||
|
- `customers-table.tsx` -- customer directory
|
||||||
|
- `vendors-table.tsx` -- vendor directory
|
||||||
|
|
||||||
|
**Dialogs:**
|
||||||
|
- `invoice-dialog.tsx` -- create/edit invoice form
|
||||||
|
- `vendor-bill-dialog.tsx` -- create/edit vendor bill form
|
||||||
|
- `payment-dialog.tsx` -- create/edit payment form
|
||||||
|
- `credit-memo-dialog.tsx` -- create/edit credit memo form
|
||||||
|
- `customer-dialog.tsx` -- create/edit customer form
|
||||||
|
- `vendor-dialog.tsx` -- create/edit vendor form
|
||||||
|
|
||||||
|
**Shared:**
|
||||||
|
- `line-items-editor.tsx` -- reusable line-item editor component used by invoices, vendor bills, and credit memos. Supports adding, removing, and editing line items with automatic total calculation.
|
||||||
|
|
||||||
|
The customer and vendor components live in the financials directory because they're primarily used in financial contexts (selecting a customer for an invoice, selecting a vendor for a bill), even though the underlying `customers` and `vendors` tables are core entities used elsewhere.
|
||||||
222
docs/modules/google-drive.md
Executable file
222
docs/modules/google-drive.md
Executable file
@ -0,0 +1,222 @@
|
|||||||
|
Google Drive Integration
|
||||||
|
===
|
||||||
|
|
||||||
|
The Google Drive module gives Compass users a native file browser that reads from and writes to Google Workspace. It uses domain-wide delegation via a service account, which means no per-user OAuth consent flow -- the service account impersonates workspace users directly using JWT-based authentication.
|
||||||
|
|
||||||
|
This design was chosen because construction companies typically have a Google Workspace domain and want all project documents accessible in Compass without each user having to individually authorize access. The tradeoff is that setup requires a Workspace admin to configure domain-wide delegation in the Google Admin console.
|
||||||
|
|
||||||
|
|
||||||
|
architecture overview
|
||||||
|
---
|
||||||
|
|
||||||
|
```
|
||||||
|
src/lib/google/
|
||||||
|
config.ts # encryption key, service account type, API URLs
|
||||||
|
auth/
|
||||||
|
service-account.ts # JWT creation + token exchange (Web Crypto API)
|
||||||
|
token-cache.ts # in-memory per-user token cache
|
||||||
|
client/
|
||||||
|
drive-client.ts # REST API v3 wrapper with retry + rate limiting
|
||||||
|
types.ts # DriveFile, DriveFileList, DriveAbout, etc.
|
||||||
|
mapper.ts # DriveFile -> FileItem conversion
|
||||||
|
|
||||||
|
src/app/actions/google-drive.ts # 17 server actions
|
||||||
|
src/db/schema-google.ts # google_auth, google_starred_files
|
||||||
|
src/components/files/ # file browser UI (14 components)
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
domain-wide delegation
|
||||||
|
---
|
||||||
|
|
||||||
|
Standard OAuth requires each user to click "Allow" in a consent screen. Domain-wide delegation skips this: a service account is authorized (once, by an admin) to impersonate any user in the Workspace domain.
|
||||||
|
|
||||||
|
The flow:
|
||||||
|
|
||||||
|
1. Admin creates a service account in Google Cloud Console
|
||||||
|
2. Admin grants the service account domain-wide delegation in Google Admin
|
||||||
|
3. Admin pastes the service account JSON key into Compass settings
|
||||||
|
4. Compass encrypts and stores the key in the `google_auth` table
|
||||||
|
5. For each API request, Compass creates a JWT claiming to be the user, signs it with the service account's private key, and exchanges it for an access token
|
||||||
|
|
||||||
|
This means Compass sees exactly what each user would see in Google Drive. If Alice can't access a folder in Workspace, she can't access it in Compass either.
|
||||||
|
|
||||||
|
|
||||||
|
JWT creation with Web Crypto
|
||||||
|
---
|
||||||
|
|
||||||
|
`auth/service-account.ts` builds JWTs without any Node.js crypto dependencies, using only the Web Crypto API. This is necessary because Compass runs on Cloudflare Workers, which doesn't have Node.js built-ins.
|
||||||
|
|
||||||
|
The JWT contains:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const payload = {
|
||||||
|
iss: serviceAccountKey.client_email, // service account email
|
||||||
|
sub: userEmail, // user to impersonate
|
||||||
|
scope: scopes.join(" "), // "https://www.googleapis.com/auth/drive"
|
||||||
|
aud: GOOGLE_TOKEN_URL,
|
||||||
|
iat: now,
|
||||||
|
exp: now + 3600, // 1 hour
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The private key is imported from PEM format into a CryptoKey object for RS256 signing:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function importPrivateKey(pem: string): Promise<CryptoKey> {
|
||||||
|
const pemBody = pem
|
||||||
|
.replace(/-----BEGIN PRIVATE KEY-----/g, "")
|
||||||
|
.replace(/-----END PRIVATE KEY-----/g, "")
|
||||||
|
.replace(/\s/g, "")
|
||||||
|
|
||||||
|
const binaryString = atob(pemBody)
|
||||||
|
const bytes = new Uint8Array(binaryString.length)
|
||||||
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
|
bytes[i] = binaryString.charCodeAt(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
return crypto.subtle.importKey(
|
||||||
|
"pkcs8", bytes.buffer,
|
||||||
|
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
||||||
|
false, ["sign"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The signed JWT is exchanged for a standard OAuth access token via Google's token endpoint with the `urn:ietf:params:oauth:grant-type:jwt-bearer` grant type.
|
||||||
|
|
||||||
|
|
||||||
|
drive client
|
||||||
|
---
|
||||||
|
|
||||||
|
`client/drive-client.ts` wraps the Google Drive REST API v3. Each method accepts a `userEmail` parameter for impersonation.
|
||||||
|
|
||||||
|
The client has:
|
||||||
|
|
||||||
|
**Token caching.** Access tokens are cached in memory per user email with a 1-hour TTL. The cache is per-worker-isolate, so different Cloudflare Workers instances maintain separate caches. On 401 responses, the cached token is cleared and the request retries.
|
||||||
|
|
||||||
|
**Rate limiting.** Reuses the same `ConcurrencyLimiter` from the NetSuite module (defaulting to 10 concurrent requests). On 429 responses, concurrency is adaptively reduced.
|
||||||
|
|
||||||
|
**Retry with exponential backoff.** Up to 3 attempts for 401 (auth refresh), 429 (rate limit), and 5xx (server error). Non-retryable errors (400, 403, 404) fail immediately.
|
||||||
|
|
||||||
|
Available operations:
|
||||||
|
|
||||||
|
- `listFiles` -- list files in a folder with query filtering, ordering, pagination, shared drive support
|
||||||
|
- `getFile` -- get a single file's metadata
|
||||||
|
- `createFolder` -- create a folder in a specified parent
|
||||||
|
- `initiateResumableUpload` -- start a resumable upload session (returns a session URI)
|
||||||
|
- `downloadFile` -- download file content
|
||||||
|
- `exportFile` -- export a Google Docs/Sheets/Slides file to a different format (e.g., PDF)
|
||||||
|
- `renameFile` -- rename a file
|
||||||
|
- `moveFile` -- move a file between parents
|
||||||
|
- `trashFile` / `restoreFile` -- soft delete and restore
|
||||||
|
- `getStorageQuota` -- get the user's storage usage
|
||||||
|
- `searchFiles` -- full-text search across files
|
||||||
|
- `listSharedDrives` -- enumerate shared drives the user can access
|
||||||
|
|
||||||
|
|
||||||
|
two-layer permissions
|
||||||
|
---
|
||||||
|
|
||||||
|
Every file operation goes through two permission checks:
|
||||||
|
|
||||||
|
1. **Compass RBAC** -- the server action checks `requirePermission(user, "document", "read|create|update|delete")`. This determines whether the user's Compass role allows the operation at all.
|
||||||
|
|
||||||
|
2. **Google Workspace permissions** -- the API request is made as the impersonated user. Google enforces whatever sharing permissions apply to that user's account. If a user doesn't have edit access to a folder in Drive, the `createFolder` call will fail with a 403 from Google.
|
||||||
|
|
||||||
|
The Google email used for impersonation defaults to the user's login email but can be overridden via the `googleEmail` column on the `users` table. This handles cases where a user's Compass login email differs from their Workspace email.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function resolveGoogleEmail(user: AuthUser): string {
|
||||||
|
return user.googleEmail ?? user.email
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
mapper
|
||||||
|
---
|
||||||
|
|
||||||
|
`mapper.ts` converts Google Drive API responses into Compass's `FileItem` type for the UI. The mapper handles:
|
||||||
|
|
||||||
|
- MIME type to file type classification (folder, document, spreadsheet, image, video, etc.)
|
||||||
|
- Permission role mapping (Google's `writer`/`owner` become `editor`, everything else becomes `viewer`)
|
||||||
|
- Owner and sharing info extraction
|
||||||
|
- Local starred file tracking (via the `google_starred_files` table, since Google's star API is per-Google-account, not per-Compass-user)
|
||||||
|
|
||||||
|
Google Docs, Sheets, and Slides are "native" files that can't be downloaded directly. The mapper includes `getExportMimeType` to determine what format to export them as (Docs -> PDF, Sheets -> XLSX, Slides -> PDF).
|
||||||
|
|
||||||
|
|
||||||
|
server actions
|
||||||
|
---
|
||||||
|
|
||||||
|
`src/app/actions/google-drive.ts` exports 17 actions:
|
||||||
|
|
||||||
|
**Connection management:**
|
||||||
|
- `getGoogleDriveConnectionStatus` -- checks if a service account key is stored
|
||||||
|
- `connectGoogleDrive` -- validates and stores the service account key (makes a test API call first)
|
||||||
|
- `disconnectGoogleDrive` -- deletes the stored key
|
||||||
|
- `listAvailableSharedDrives` -- lists shared drives for the setup UI
|
||||||
|
- `selectSharedDrive` -- sets the default shared drive for the org
|
||||||
|
|
||||||
|
**File operations:**
|
||||||
|
- `listDriveFiles` -- list files in a folder (or shared drive root)
|
||||||
|
- `listDriveFilesForView` -- list files for special views: shared, recent, starred, trash
|
||||||
|
- `searchDriveFiles` -- full-text search
|
||||||
|
- `getDriveFileInfo` -- get metadata for a single file
|
||||||
|
- `listDriveFolders` -- list only folders (for move dialog)
|
||||||
|
- `createDriveFolder` -- create a new folder
|
||||||
|
- `renameDriveFile` -- rename a file or folder
|
||||||
|
- `moveDriveFile` -- move a file between folders
|
||||||
|
- `trashDriveFile` / `restoreDriveFile` -- soft delete and restore
|
||||||
|
- `getUploadSessionUrl` -- initiate a resumable upload and return the session URL
|
||||||
|
- `getDriveStorageQuota` -- get storage usage info
|
||||||
|
|
||||||
|
**User preferences:**
|
||||||
|
- `toggleStarFile` -- star/unstar a file (stored locally, not in Google)
|
||||||
|
- `getStarredFileIds` -- get the current user's starred file IDs
|
||||||
|
- `updateUserGoogleEmail` -- set a user's Google impersonation email override
|
||||||
|
|
||||||
|
Each action follows the same structure: authenticate, check RBAC, resolve the Google email, build a `DriveClient`, execute the operation, map results.
|
||||||
|
|
||||||
|
|
||||||
|
schema
|
||||||
|
---
|
||||||
|
|
||||||
|
`src/db/schema-google.ts` defines two tables:
|
||||||
|
|
||||||
|
**`google_auth`** -- organization-level Google connection. One row per org. Stores the encrypted service account key, workspace domain, optional shared drive selection, and who connected it.
|
||||||
|
|
||||||
|
**`google_starred_files`** -- per-user file starring. References user ID and stores the Google file ID. Stars are local to Compass because Google's starring is per-Google-account, not per-app.
|
||||||
|
|
||||||
|
|
||||||
|
file browser UI
|
||||||
|
---
|
||||||
|
|
||||||
|
`src/components/files/` contains 14 components that make up the file browser:
|
||||||
|
|
||||||
|
- `file-browser.tsx` -- main container, manages folder navigation state and view mode
|
||||||
|
- `file-grid.tsx` / `file-list.tsx` -- grid and list view layouts
|
||||||
|
- `file-item.tsx` / `file-row.tsx` -- individual file rendering for grid and list views
|
||||||
|
- `file-icon.tsx` -- MIME type to icon mapping
|
||||||
|
- `file-breadcrumb.tsx` -- folder path breadcrumb navigation
|
||||||
|
- `file-toolbar.tsx` -- view toggles, search, upload button
|
||||||
|
- `file-context-menu.tsx` -- right-click menu with rename, move, trash, star, open in Drive
|
||||||
|
- `file-drop-zone.tsx` -- drag-and-drop file upload area
|
||||||
|
- `file-upload-dialog.tsx` -- upload progress dialog
|
||||||
|
- `file-new-folder-dialog.tsx` -- folder creation dialog
|
||||||
|
- `file-rename-dialog.tsx` / `file-move-dialog.tsx` -- rename and move dialogs
|
||||||
|
- `storage-indicator.tsx` -- storage usage bar
|
||||||
|
|
||||||
|
|
||||||
|
encryption
|
||||||
|
---
|
||||||
|
|
||||||
|
The service account key JSON is encrypted at rest using the same shared AES-GCM encryption used by the NetSuite module (`src/lib/crypto.ts`), but with a Google-specific PBKDF2 salt (`compass-google-service-account`). The encryption key comes from the `GOOGLE_SERVICE_ACCOUNT_ENCRYPTION_KEY` environment variable.
|
||||||
|
|
||||||
|
This means the service account's private RSA key is never stored in plaintext in D1. Decryption happens per-request when the `DriveClient` is constructed.
|
||||||
|
|
||||||
|
|
||||||
|
setup reference
|
||||||
|
---
|
||||||
|
|
||||||
|
For the full setup guide (Google Cloud Console configuration, domain-wide delegation setup, admin permissions), see `docs/google-drive/GOOGLE-DRIVE-INTEGRATION.md`.
|
||||||
257
docs/modules/mobile.md
Executable file
257
docs/modules/mobile.md
Executable file
@ -0,0 +1,257 @@
|
|||||||
|
Mobile Module
|
||||||
|
===
|
||||||
|
|
||||||
|
The mobile module wraps Compass in a native iOS and Android app using Capacitor. It's not a separate codebase or a React Native port -- it's a WebView that loads the live Cloudflare deployment. The native layer adds device-specific capabilities: biometric authentication, push notifications, camera access with GPS tagging, offline photo queuing, and status bar theming.
|
||||||
|
|
||||||
|
The fundamental design principle: **the web app must never break because of native code.** Every Capacitor import is dynamic (`await import()`), every native feature is gated behind `isNative()` checks, and every native component returns `null` on web. If Capacitor isn't present, the app works exactly as it does in a browser.
|
||||||
|
|
||||||
|
|
||||||
|
platform detection
|
||||||
|
---
|
||||||
|
|
||||||
|
`src/lib/native/platform.ts` provides the detection layer. It checks for the `Capacitor` global that the native runtime injects before hydration:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function getCapacitor(): CapacitorGlobal | undefined {
|
||||||
|
if (typeof window === "undefined") return undefined
|
||||||
|
return (window as unknown as Record<string, unknown>)
|
||||||
|
.Capacitor as CapacitorGlobal | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNative(): boolean {
|
||||||
|
return getCapacitor()?.isNative ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isIOS(): boolean {
|
||||||
|
return getCapacitor()?.getPlatform() === "ios"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAndroid(): boolean {
|
||||||
|
return getCapacitor()?.getPlatform() === "android"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The key detail: `isNative()` returns `false` on the server (no `window`), `false` in a normal browser (no `Capacitor` global), and `true` only in the native WebView. This three-way distinction matters for SSR -- server-rendered HTML assumes web, and the native state is only known after hydration.
|
||||||
|
|
||||||
|
There's also `src/lib/native/detect-server.ts` for server-side detection via User-Agent:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function isNativeApp(request: Request): boolean {
|
||||||
|
const ua = request.headers.get("user-agent") ?? ""
|
||||||
|
return ua.includes("CapacitorApp")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
the useNative hook
|
||||||
|
---
|
||||||
|
|
||||||
|
`src/hooks/use-native.ts` wraps platform detection in a React hook using `useSyncExternalStore`. The snapshot never changes after initial load (Capacitor injects before hydration), so the hook is stable.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function useNative(): boolean {
|
||||||
|
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`getServerSnapshot` returns `false` (SSR assumes web). `getSnapshot` returns `isNative()`. The `subscribe` function is a no-op because the value never changes after mount.
|
||||||
|
|
||||||
|
Every native feature checks `useNative()` before attempting to load Capacitor plugins. This is the gate that prevents web breakage.
|
||||||
|
|
||||||
|
|
||||||
|
native hooks
|
||||||
|
---
|
||||||
|
|
||||||
|
Each native capability has its own hook:
|
||||||
|
|
||||||
|
**`use-native-push.ts`** -- push notification registration. On mount (if native), requests notification permissions, registers with APNS/FCM, listens for token registration events, and POSTs the token to `/api/push/register`. Also handles foreground notifications and deep-linking when a notification is tapped.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const actionListener = await PushNotifications.addListener(
|
||||||
|
"pushNotificationActionPerformed",
|
||||||
|
(action) => {
|
||||||
|
const url = action.notification.data?.url
|
||||||
|
if (typeof url === "string" && url.startsWith("/")) {
|
||||||
|
router.push(url)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**`use-native-camera.ts`** -- camera access with EXIF extraction. Captures photos at 85% quality, 2048px width, saves to gallery, extracts GPS coordinates and timestamp from EXIF data.
|
||||||
|
|
||||||
|
**`use-biometric-auth.ts`** -- Face ID / fingerprint authentication. Checks device capability on mount, manages enabled/prompted state in localStorage, provides `authenticate()` that calls `NativeBiometric.verifyIdentity`. The biometric lock activates after the app has been backgrounded for 30+ seconds.
|
||||||
|
|
||||||
|
**`use-photo-queue.ts`** -- the most complex hook. Combines camera capture with offline-resilient upload. Takes a photo, saves it to the device filesystem, adds metadata to the queue, and auto-uploads when connectivity returns. Listens for network state changes via `@capacitor/network`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const takeAndQueuePhoto = useCallback(
|
||||||
|
async (projectId: string): Promise<CapturedPhoto | null> => {
|
||||||
|
const photo = await takePhoto()
|
||||||
|
if (!photo) return null
|
||||||
|
const id = nanoid()
|
||||||
|
const fileName = `${id}.${photo.format}`
|
||||||
|
const localPath = await savePhotoToDevice(photo.uri, fileName)
|
||||||
|
await addToQueue({
|
||||||
|
id, projectId, localPath, fileName,
|
||||||
|
lat: photo.exifData.lat, lng: photo.exifData.lng,
|
||||||
|
capturedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
await refresh()
|
||||||
|
return photo
|
||||||
|
},
|
||||||
|
[takePhoto, refresh],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
offline photo queue
|
||||||
|
---
|
||||||
|
|
||||||
|
`src/lib/native/photo-queue.ts` is the persistence layer for photos captured on jobsites with spotty connectivity. It uses Capacitor's `Preferences` plugin (key-value storage that survives app kill) to track queue metadata, and the `Filesystem` plugin to store actual photo files in the app's data directory.
|
||||||
|
|
||||||
|
The queue lifecycle:
|
||||||
|
|
||||||
|
1. **Capture**: Photo is taken, copied to `compass-photos/{id}.{format}` in the app's data directory
|
||||||
|
2. **Queue**: Metadata (project ID, GPS coords, timestamp, file path) added to the queue with `pending` status
|
||||||
|
3. **Upload**: When online, `processQueue()` iterates pending items, uses `@capgo/capacitor-uploader` to POST each file with metadata headers
|
||||||
|
4. **Cleanup**: Successfully uploaded photos are deleted from the filesystem and removed from the queue
|
||||||
|
5. **Retry**: Failed uploads get retried up to 3 times. After that, they stay in `failed` status until manually retried
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await Uploader.startUpload({
|
||||||
|
filePath: photo.localPath,
|
||||||
|
serverUrl: uploadUrl,
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"X-Project-Id": photo.projectId,
|
||||||
|
"X-Photo-Id": photo.id,
|
||||||
|
"X-Captured-At": photo.capturedAt,
|
||||||
|
...(photo.lat !== undefined && { "X-GPS-Lat": String(photo.lat) }),
|
||||||
|
...(photo.lng !== undefined && { "X-GPS-Lng": String(photo.lng) }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
GPS coordinates and timestamps are passed as headers rather than multipart form fields. This keeps the upload simple (single file body) while preserving all metadata.
|
||||||
|
|
||||||
|
|
||||||
|
native components
|
||||||
|
---
|
||||||
|
|
||||||
|
`src/components/native/` contains four components. All return `null` on web.
|
||||||
|
|
||||||
|
**`native-shell.tsx`** -- syncs the native status bar style with the current theme. When the app switches between light and dark mode, the status bar text color updates to match.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function NativeShell() {
|
||||||
|
const native = useNative()
|
||||||
|
const { resolvedTheme } = useTheme()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!native) return
|
||||||
|
async function syncStatusBar() {
|
||||||
|
const { StatusBar, Style } = await import("@capacitor/status-bar")
|
||||||
|
await StatusBar.setStyle({
|
||||||
|
style: resolvedTheme === "dark" ? Style.Dark : Style.Light,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
syncStatusBar()
|
||||||
|
}, [native, resolvedTheme])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`biometric-guard.tsx`** -- wraps the app with biometric lock screen functionality. Listens for app state changes (background/foreground). If the app was backgrounded for more than 30 seconds and biometrics are enabled, it shows a full-screen lock overlay. Auto-authenticates on appear, with a fallback "Use password" button that redirects to the login page.
|
||||||
|
|
||||||
|
Also handles first-login setup: after a 2-second delay on first native launch, prompts the user to enable biometric locking. The prompt state is tracked in localStorage so it's only shown once.
|
||||||
|
|
||||||
|
**`offline-banner.tsx`** -- shows a slim amber banner when the device is offline. Uses `@capacitor/network` on native, falls back to `navigator.onLine` events on web. This component actually works on both platforms -- the web fallback is useful for PWA-like behavior.
|
||||||
|
|
||||||
|
**`upload-queue-indicator.tsx`** -- shows pending photo upload count as a pill badge. Changes appearance based on status: neutral for pending, pulsing for uploading, red for errors with "tap to retry" text.
|
||||||
|
|
||||||
|
|
||||||
|
push notifications
|
||||||
|
---
|
||||||
|
|
||||||
|
`src/lib/push/send.ts` sends push notifications via FCM HTTP v1 API. It works from Cloudflare Workers without the Firebase SDK -- just a direct HTTP POST to `https://fcm.googleapis.com/v1/projects/-/messages:send`.
|
||||||
|
|
||||||
|
The sender:
|
||||||
|
|
||||||
|
1. Looks up all push tokens for the target user from the `push_tokens` table
|
||||||
|
2. Sends each token a notification with platform-specific config (high priority for Android, sound + badge for iOS)
|
||||||
|
3. Auto-cleans invalid tokens: 404 responses (unregistered device) trigger token deletion
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const message: FcmMessage = {
|
||||||
|
message: {
|
||||||
|
token: t.token,
|
||||||
|
notification: { title: payload.title, body: payload.body },
|
||||||
|
data: payload.data ? { ...payload.data } : undefined,
|
||||||
|
android: { priority: "high" },
|
||||||
|
apns: { payload: { aps: { sound: "default", badge: 1 } } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Device tokens are registered via `POST /api/push/register` (called by `use-native-push.ts` on app launch) and cleaned up via `DELETE /api/push/register`.
|
||||||
|
|
||||||
|
|
||||||
|
Capacitor configuration
|
||||||
|
---
|
||||||
|
|
||||||
|
`capacitor.config.ts` configures the native wrapper:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const config: CapacitorConfig = {
|
||||||
|
appId: "ltd.openrangeconstruction.compass",
|
||||||
|
appName: "Compass",
|
||||||
|
webDir: "public",
|
||||||
|
server: {
|
||||||
|
url: "https://compass.openrangeconstruction.ltd",
|
||||||
|
cleartext: false,
|
||||||
|
allowNavigation: [
|
||||||
|
"compass.openrangeconstruction.ltd",
|
||||||
|
"api.workos.com",
|
||||||
|
"authkit.workos.com",
|
||||||
|
"accounts.google.com",
|
||||||
|
"login.microsoftonline.com",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `server.url` points to the live production deployment. The app doesn't bundle a static export -- it loads the real web app in a WebView. This means native users always get the latest version without app store updates for UI changes.
|
||||||
|
|
||||||
|
`allowNavigation` lists domains the WebView is allowed to navigate to. This is needed for OAuth flows (WorkOS, Google, Microsoft) that redirect the user to external auth pages.
|
||||||
|
|
||||||
|
The `webDir: "public"` is mostly a formality for Capacitor CLI requirements. Since the app loads from a remote URL, local web assets are only used during the splash screen.
|
||||||
|
|
||||||
|
Plugins configured:
|
||||||
|
- `SplashScreen` -- white background, 2-second display, auto-hide
|
||||||
|
- `Keyboard` -- resize body on keyboard show (not viewport), dark style
|
||||||
|
- `PushNotifications` -- badge, sound, and alert presentation options
|
||||||
|
|
||||||
|
iOS-specific: `contentInset: "automatic"` for safe area handling, custom `compass` URL scheme.
|
||||||
|
Android-specific: mixed content disabled (HTTPS only), input capture enabled.
|
||||||
|
|
||||||
|
|
||||||
|
the dynamic import pattern
|
||||||
|
---
|
||||||
|
|
||||||
|
Every Capacitor plugin is loaded with dynamic `await import()` inside an async function, never at module scope. This is critical: Capacitor plugins only exist in the native runtime. A top-level import would crash the web app at module evaluation time.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// correct: dynamic import inside a native-gated effect
|
||||||
|
useEffect(() => {
|
||||||
|
if (!native) return
|
||||||
|
async function setup() {
|
||||||
|
const { PushNotifications } = await import("@capacitor/push-notifications")
|
||||||
|
await PushNotifications.requestPermissions()
|
||||||
|
}
|
||||||
|
setup()
|
||||||
|
}, [native])
|
||||||
|
```
|
||||||
|
|
||||||
|
This pattern repeats throughout the mobile module. The `native` check prevents the import from even being attempted on web, and the dynamic import ensures the module is only loaded when actually needed.
|
||||||
322
docs/modules/netsuite.md
Executable file
322
docs/modules/netsuite.md
Executable file
@ -0,0 +1,322 @@
|
|||||||
|
NetSuite Integration
|
||||||
|
===
|
||||||
|
|
||||||
|
The NetSuite module is a bidirectional REST API integration that syncs customers, vendors, projects, invoices, and vendor bills between Compass (D1/SQLite) and NetSuite (Oracle's ERP). It handles OAuth 2.0 authentication, encrypted token storage, rate limiting, delta sync with conflict resolution, and per-record error tracking.
|
||||||
|
|
||||||
|
The integration exists because construction companies using NetSuite need their financial and contact data accessible in Compass without manual re-entry. NetSuite's REST API is powerful but full of surprising behaviors that this module works hard to handle gracefully.
|
||||||
|
|
||||||
|
|
||||||
|
architecture overview
|
||||||
|
---
|
||||||
|
|
||||||
|
```
|
||||||
|
src/lib/netsuite/
|
||||||
|
config.ts # account config, URL builders
|
||||||
|
auth/
|
||||||
|
oauth-client.ts # OAuth 2.0 authorize/exchange/refresh
|
||||||
|
token-manager.ts # encrypted storage, auto-refresh at 80% lifetime
|
||||||
|
crypto.ts # delegates to shared AES-GCM crypto with netsuite salt
|
||||||
|
client/
|
||||||
|
base-client.ts # HTTP client with retry, circuit breaker
|
||||||
|
record-client.ts # CRUD for individual records
|
||||||
|
suiteql-client.ts # SuiteQL query execution
|
||||||
|
errors.ts # error classification (the hard part)
|
||||||
|
types.ts # API response types, record interfaces
|
||||||
|
rate-limiter/
|
||||||
|
concurrency-limiter.ts # semaphore with adaptive reduction
|
||||||
|
request-queue.ts # priority-based FIFO wrapper
|
||||||
|
sync/
|
||||||
|
sync-engine.ts # orchestrates pull and push operations
|
||||||
|
delta-sync.ts # pull remote changes since last sync
|
||||||
|
push.ts # push local changes to netsuite
|
||||||
|
conflict-resolver.ts # four strategies for handling conflicts
|
||||||
|
idempotency.ts # deterministic keys for safe retries
|
||||||
|
mappers/
|
||||||
|
base-mapper.ts # abstract bidirectional field mapping
|
||||||
|
customer-mapper.ts
|
||||||
|
vendor-mapper.ts
|
||||||
|
project-mapper.ts
|
||||||
|
invoice-mapper.ts
|
||||||
|
vendor-bill-mapper.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
configuration
|
||||||
|
---
|
||||||
|
|
||||||
|
`config.ts` reads environment variables and builds URLs. The URL construction handles NetSuite's inconsistent format -- sandbox accounts use different separators (`123456-sb1` in URLs vs `123456_SB1` in the account ID):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function getRestBaseUrl(accountId: string): string {
|
||||||
|
const urlId = accountId.toLowerCase().replace("_", "-")
|
||||||
|
return `https://${urlId}.suitetalk.api.netsuite.com`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Required environment variables:
|
||||||
|
|
||||||
|
- `NETSUITE_ACCOUNT_ID` -- the account identifier (e.g., `1234567` or `1234567_SB1` for sandbox)
|
||||||
|
- `NETSUITE_CLIENT_ID` / `NETSUITE_CLIENT_SECRET` -- OAuth 2.0 integration credentials
|
||||||
|
- `NETSUITE_REDIRECT_URI` -- callback URL for the OAuth flow
|
||||||
|
- `NETSUITE_TOKEN_ENCRYPTION_KEY` -- AES-GCM key for encrypting tokens at rest
|
||||||
|
- `NETSUITE_CONCURRENCY_LIMIT` -- optional, defaults to 15
|
||||||
|
|
||||||
|
|
||||||
|
OAuth 2.0 flow
|
||||||
|
---
|
||||||
|
|
||||||
|
`auth/oauth-client.ts` implements the standard authorization code flow. The user is redirected to NetSuite's authorize endpoint with scopes `rest_webservices` and `suite_analytics`, then the callback exchanges the code for tokens using HTTP Basic authentication (client credentials in the Authorization header).
|
||||||
|
|
||||||
|
`auth/token-manager.ts` manages token lifecycle. Tokens are encrypted with AES-GCM before storage in the `netsuite_auth` table. The manager refreshes proactively at 80% of the token's lifetime to avoid edge-case expiry during a request:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private shouldRefresh(tokens: OAuthTokens): boolean {
|
||||||
|
const elapsed = Date.now() - tokens.issuedAt
|
||||||
|
const threshold = tokens.expiresIn * 1000 * REFRESH_THRESHOLD
|
||||||
|
return elapsed >= threshold
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Tokens are cached in memory for the duration of a request to avoid redundant decryption.
|
||||||
|
|
||||||
|
|
||||||
|
HTTP client
|
||||||
|
---
|
||||||
|
|
||||||
|
`client/base-client.ts` wraps every NetSuite request with:
|
||||||
|
|
||||||
|
**Retry with exponential backoff.** Up to 3 retries with jittered delay (base 1s, max 30s). Only retryable errors trigger retries -- rate limits, timeouts, server errors, and expired auth (which triggers a token refresh).
|
||||||
|
|
||||||
|
**Circuit breaker.** After 5 consecutive failures, the client stops sending requests for 60 seconds. This prevents cascading failures when NetSuite is down or the account is locked out.
|
||||||
|
|
||||||
|
**Concurrency limiting.** Every request passes through the `ConcurrencyLimiter` before execution. More on this below.
|
||||||
|
|
||||||
|
The client delegates error handling to `errors.ts`, which is where most of the NetSuite-specific knowledge lives.
|
||||||
|
|
||||||
|
|
||||||
|
error classification
|
||||||
|
---
|
||||||
|
|
||||||
|
NetSuite's error responses are misleading. The `classifyError` function in `errors.ts` handles the known traps:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (status === 401) {
|
||||||
|
// netsuite sometimes returns 401 for timeouts
|
||||||
|
if (bodyStr.includes("timeout") || bodyStr.includes("ETIMEDOUT")) {
|
||||||
|
return {
|
||||||
|
category: "timeout",
|
||||||
|
message: "Request timed out (disguised as 401)",
|
||||||
|
retryAfter: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bodyStr.includes("Invalid Login Attempt")) {
|
||||||
|
return {
|
||||||
|
category: "rate_limited",
|
||||||
|
message: "Rate limited (disguised as auth error)",
|
||||||
|
retryAfter: 5000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key classifications:
|
||||||
|
|
||||||
|
- **401 + "timeout" in body** = actually a timeout, not an auth failure. Retryable.
|
||||||
|
- **401 + "Invalid Login Attempt"** = actually rate limiting on SOAP connections. Retryable with 5s backoff.
|
||||||
|
- **403 + "does not exist"** = permission denied, not a missing field. The REST API returns "field doesn't exist" when the integration role lacks permission to read that field.
|
||||||
|
- **429** = actual rate limit. Parses `Retry-After` header if present, defaults to 5s.
|
||||||
|
|
||||||
|
Error categories determine retryability: `rate_limited`, `timeout`, `server_error`, `network`, and `auth_expired` are retryable. Everything else (`permission_denied`, `validation`, `not_found`) is terminal.
|
||||||
|
|
||||||
|
|
||||||
|
rate limiter
|
||||||
|
---
|
||||||
|
|
||||||
|
NetSuite enforces a limit of 15 concurrent requests across ALL integrations on an account -- SOAP, REST, RESTlets, everything. If your REST integration sends 10 requests and a SOAP integration sends 6, you'll get 429s on the 16th.
|
||||||
|
|
||||||
|
`rate-limiter/concurrency-limiter.ts` implements a semaphore with priority queuing:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async execute<T>(fn: () => Promise<T>, priority = 0): Promise<T> {
|
||||||
|
await this.acquire(priority)
|
||||||
|
try {
|
||||||
|
return await fn()
|
||||||
|
} finally {
|
||||||
|
this.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The limiter also adapts: when a 429 response comes back, it reduces concurrency to 70% of the current limit. After successful requests, it gradually restores back to the original value. This handles the case where other integrations are consuming part of the shared pool.
|
||||||
|
|
||||||
|
`rate-limiter/request-queue.ts` adds named priorities on top: `critical` (30), `high` (20), `normal` (10), `low` (0). User-triggered actions get higher priority than background sync operations.
|
||||||
|
|
||||||
|
|
||||||
|
record client and SuiteQL client
|
||||||
|
---
|
||||||
|
|
||||||
|
Two client types sit on top of the base HTTP client:
|
||||||
|
|
||||||
|
`RecordClient` handles CRUD operations on individual NetSuite records. It supports field selection, query filtering, pagination (`listAll` auto-pages through results), and record transformations (e.g., converting a sales order to an invoice).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async create(
|
||||||
|
recordType: string,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
idempotencyKey?: string
|
||||||
|
): Promise<{ id: string }>
|
||||||
|
```
|
||||||
|
|
||||||
|
The `create` method accepts an optional idempotency key via the `X-NetSuite-Idempotency-Key` header. More on this in the sync section.
|
||||||
|
|
||||||
|
`SuiteQLClient` executes SQL-like queries against NetSuite's analytics engine. Delta sync uses SuiteQL to fetch only records modified since the last sync. The client auto-pages results and caps at 100,000 rows as a safety limit.
|
||||||
|
|
||||||
|
|
||||||
|
mappers
|
||||||
|
---
|
||||||
|
|
||||||
|
Mappers handle bidirectional field translation between Compass's flat D1 records and NetSuite's nested record structure. `BaseMapper` is an abstract class that provides:
|
||||||
|
|
||||||
|
- `toRemote(local)` -- convert a Compass record to NetSuite format
|
||||||
|
- `toLocal(remote)` -- convert a NetSuite record to Compass format
|
||||||
|
- `getNetSuiteRecordType()` -- the REST API record type string
|
||||||
|
- `getLocalTable()` -- the D1 table name
|
||||||
|
- `buildSelectQuery()` / `buildDeltaQuery()` -- SuiteQL generation
|
||||||
|
|
||||||
|
Here's `CustomerMapper.toLocal` as an example of the field mapping:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
toLocal(remote: NetSuiteCustomer): Partial<LocalCustomer> {
|
||||||
|
return {
|
||||||
|
name: remote.companyName,
|
||||||
|
email: remote.email ?? null,
|
||||||
|
phone: remote.phone ?? null,
|
||||||
|
netsuiteId: remote.id,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
There are currently five mappers: customer, vendor, project, invoice, and vendor-bill.
|
||||||
|
|
||||||
|
|
||||||
|
sync engine
|
||||||
|
---
|
||||||
|
|
||||||
|
`sync/sync-engine.ts` orchestrates sync operations. It wires together config, auth, clients, and limiters into a single `SyncEngine` class:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
constructor(
|
||||||
|
db: DrizzleD1Database,
|
||||||
|
env: Record<string, string | undefined>,
|
||||||
|
conflictStrategy: ConflictStrategy = "newest_wins"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The engine supports three operations:
|
||||||
|
|
||||||
|
- `pull(mapper, upsertLocal)` -- fetch changes from NetSuite, apply them locally
|
||||||
|
- `push(mapper, getLocalRecord)` -- send local changes to NetSuite
|
||||||
|
- `fullSync(mapper, upsertLocal, getLocalRecord)` -- pull then push
|
||||||
|
|
||||||
|
Every sync run is logged in the `netsuite_sync_log` table with timestamps, record counts, and error summaries.
|
||||||
|
|
||||||
|
|
||||||
|
delta sync (pull)
|
||||||
|
---
|
||||||
|
|
||||||
|
`sync/delta-sync.ts` implements the pull logic. On first sync, it runs the mapper's full `buildSelectQuery()`. On subsequent syncs, it uses `buildDeltaQuery(since)` to fetch only records modified after the last successful sync.
|
||||||
|
|
||||||
|
For each remote record, the function:
|
||||||
|
|
||||||
|
1. Looks up existing sync metadata by NetSuite internal ID
|
||||||
|
2. If the local record has `pending_push` status (local changes waiting to be sent), it runs conflict resolution
|
||||||
|
3. Otherwise, upserts the local record and updates sync metadata to `synced`
|
||||||
|
4. For new records, creates both the local record and the sync metadata entry
|
||||||
|
|
||||||
|
Conflict resolution uses one of four strategies:
|
||||||
|
|
||||||
|
- `newest_wins` -- compare timestamps, newer version wins (default)
|
||||||
|
- `remote_wins` -- always accept the remote version
|
||||||
|
- `local_wins` -- always keep the local version
|
||||||
|
- `manual` -- flag for human review in the conflict dialog UI
|
||||||
|
|
||||||
|
|
||||||
|
push
|
||||||
|
---
|
||||||
|
|
||||||
|
`sync/push.ts` sends local changes to NetSuite. It queries for all sync metadata records with `pending_push` status, then for each one:
|
||||||
|
|
||||||
|
1. Loads the current local record
|
||||||
|
2. Runs it through the mapper's `toRemote`
|
||||||
|
3. If the record has a NetSuite internal ID, sends a PATCH update
|
||||||
|
4. If it doesn't, sends a POST create with an idempotency key
|
||||||
|
|
||||||
|
The idempotency key is deterministic: `operation:recordType:localId:hourBucket`. This means retrying a failed create within the same hour reuses the key, preventing duplicate records in NetSuite. After an hour, a new key is generated so the operation can be retried if the previous attempt genuinely didn't go through.
|
||||||
|
|
||||||
|
Failed pushes are retried up to 3 times for retryable errors. Non-retryable errors mark the sync metadata as `error` with the failure message.
|
||||||
|
|
||||||
|
|
||||||
|
schema
|
||||||
|
---
|
||||||
|
|
||||||
|
The NetSuite module defines three sync-infrastructure tables and four financial tables in `src/db/schema-netsuite.ts`:
|
||||||
|
|
||||||
|
- `netsuite_auth` -- encrypted OAuth tokens per account
|
||||||
|
- `netsuite_sync_metadata` -- per-record sync status, conflict data, retry counts
|
||||||
|
- `netsuite_sync_log` -- sync run history with timing and error summaries
|
||||||
|
- `invoices` -- customer invoices (tied to customers and projects)
|
||||||
|
- `vendor_bills` -- vendor bills (tied to vendors and projects)
|
||||||
|
- `payments` -- incoming/outgoing payments
|
||||||
|
- `credit_memos` -- customer credit memos
|
||||||
|
|
||||||
|
All financial tables have a `netsuiteId` column for tracking the link to NetSuite's internal record ID.
|
||||||
|
|
||||||
|
The core schema also includes `netsuiteId` on `customers` and `vendors`, and `netsuiteJobId` on `projects`. These columns are only meaningful when the NetSuite module is active.
|
||||||
|
|
||||||
|
|
||||||
|
server actions
|
||||||
|
---
|
||||||
|
|
||||||
|
`src/app/actions/netsuite-sync.ts` exposes the sync functionality to the UI:
|
||||||
|
|
||||||
|
- `getNetSuiteConnectionStatus()` -- checks if tokens exist
|
||||||
|
- `initiateNetSuiteOAuth()` -- generates the authorize URL with a random state parameter
|
||||||
|
- `disconnectNetSuite()` -- clears stored tokens
|
||||||
|
- `syncCustomers()` / `syncVendors()` -- trigger pull operations for each entity type
|
||||||
|
- `getSyncHistory()` -- returns recent sync log entries
|
||||||
|
- `getConflicts()` -- returns records in conflict state
|
||||||
|
- `resolveConflict(metaId, resolution)` -- applies "use_local" or "use_remote" resolution
|
||||||
|
|
||||||
|
Every action checks RBAC permissions (`finance:read` for queries, `organization:update` for connection management).
|
||||||
|
|
||||||
|
|
||||||
|
UI components
|
||||||
|
---
|
||||||
|
|
||||||
|
Four components in `src/components/netsuite/`:
|
||||||
|
|
||||||
|
- `connection-status.tsx` -- shows whether NetSuite is configured and connected, with the account ID
|
||||||
|
- `sync-controls.tsx` -- trigger sync buttons for customers and vendors, shows sync history
|
||||||
|
- `conflict-dialog.tsx` -- modal for reviewing and resolving sync conflicts
|
||||||
|
- `sync-status-badge.tsx` -- inline badge showing sync state (synced, pending, error, conflict)
|
||||||
|
|
||||||
|
|
||||||
|
gotchas
|
||||||
|
---
|
||||||
|
|
||||||
|
If you're working on the NetSuite integration, know these:
|
||||||
|
|
||||||
|
1. **401 can mean timeout.** NetSuite sometimes returns 401 when a request times out. The error classifier checks the response body for "timeout" or "ETIMEDOUT" to detect this.
|
||||||
|
|
||||||
|
2. **"Field doesn't exist" usually means permission denied.** When the integration role can't access a field, the REST API says the field doesn't exist instead of returning 403.
|
||||||
|
|
||||||
|
3. **15 concurrent requests, shared globally.** The limit applies across ALL integrations on the account. Your REST integration competes with SOAP integrations, RESTlets, and scheduled scripts.
|
||||||
|
|
||||||
|
4. **No batch create/update via REST.** Every record must be created or updated individually. The SuiteQL client can batch-read, but writes are always one-at-a-time.
|
||||||
|
|
||||||
|
5. **Sandbox URLs use different separators.** Account ID `123456_SB1` becomes `123456-sb1` in REST URLs. The config handles this with `.toLowerCase().replace("_", "-")`.
|
||||||
|
|
||||||
|
6. **Omitting the "line" parameter on line items adds a new line.** If you PATCH an invoice and include line items without the `line` field, NetSuite creates new line items instead of updating existing ones. This is by design in the REST API.
|
||||||
|
|
||||||
|
7. **"Invalid Login Attempt" on 401 is often rate limiting.** SOAP connections that exceed the concurrent limit get this error. The classifier treats it as rate limiting with a 5-second backoff.
|
||||||
73
docs/modules/overview.md
Executable file
73
docs/modules/overview.md
Executable file
@ -0,0 +1,73 @@
|
|||||||
|
HPS Compass Modules
|
||||||
|
===
|
||||||
|
|
||||||
|
Compass is a platform. HPS Compass is a product built on that platform.
|
||||||
|
|
||||||
|
The distinction matters. Compass Core provides authentication, an AI agent, theming, a plugin system, and dashboards. These are generic capabilities useful to any organization. HPS Compass adds construction-specific modules on top: scheduling with Gantt charts and critical path analysis, financial tracking tied to NetSuite, Google Drive integration for project documents, and a Capacitor mobile app for field workers taking photos on jobsites.
|
||||||
|
|
||||||
|
This separation isn't just organizational tidiness. It's the foundation for making Compass reusable. A mechanical engineering firm could rip out the construction modules and replace them with their own domain package. The scheduling module doesn't know about the AI agent. The NetSuite module doesn't know about theming. They integrate through Compass Core's extension points: schema tables, server actions, components, and optionally agent tools.
|
||||||
|
|
||||||
|
|
||||||
|
what is a module
|
||||||
|
---
|
||||||
|
|
||||||
|
A module in Compass is a bundle of related functionality that touches four layers:
|
||||||
|
|
||||||
|
**Schema tables** in `src/db/`. Each module can define its own schema file (e.g., `schema-netsuite.ts`, `schema-google.ts`) and register it in `drizzle.config.ts`. Tables use the same conventions as core: text UUIDs for IDs, ISO 8601 text dates, drizzle ORM with the D1 SQLite dialect.
|
||||||
|
|
||||||
|
**Server actions** in `src/app/actions/`. These are the data mutation layer. Every module exposes its logic through server actions that follow the standard pattern: authenticate the user, check RBAC permissions, do the work, revalidate affected paths, return `{ success: true }` or `{ success: false; error: string }`.
|
||||||
|
|
||||||
|
**Components** in `src/components/`. Module UI lives in its own subdirectory -- `components/netsuite/`, `components/files/`, `components/schedule/`, `components/financials/`, `components/native/`. These use the same shadcn/ui primitives and Tailwind conventions as everything else.
|
||||||
|
|
||||||
|
**Agent tools** (optional). Modules can register tools with the AI agent so users can interact with module functionality through natural language. The NetSuite and Google Drive modules don't currently have dedicated agent tools, but the plugin system provides a clear path for adding them.
|
||||||
|
|
||||||
|
|
||||||
|
the boundary between core and modules
|
||||||
|
---
|
||||||
|
|
||||||
|
Core is the part that stays when you swap out the domain package:
|
||||||
|
|
||||||
|
- Authentication (WorkOS SSO, middleware, session management)
|
||||||
|
- AI agent harness (provider, tools, system prompt, chat UI)
|
||||||
|
- Visual theme system (presets, custom themes, oklch color maps)
|
||||||
|
- Plugin/skills system (install, registry, loader)
|
||||||
|
- Custom dashboards (agent-built, saved views)
|
||||||
|
- Users, organizations, teams, groups
|
||||||
|
- RBAC permissions
|
||||||
|
- Feedback collection
|
||||||
|
|
||||||
|
Modules are the parts specific to HPS's construction business:
|
||||||
|
|
||||||
|
| module | what it does | key directories |
|
||||||
|
|--------|-------------|-----------------|
|
||||||
|
| NetSuite | bidirectional ERP sync | `lib/netsuite/`, `actions/netsuite-sync.ts`, `components/netsuite/` |
|
||||||
|
| Google Drive | document management via workspace delegation | `lib/google/`, `actions/google-drive.ts`, `components/files/` |
|
||||||
|
| Scheduling | Gantt charts, CPM, baseline tracking | `lib/schedule/`, `actions/schedule.ts`, `components/schedule/` |
|
||||||
|
| Financials | invoices, bills, payments, credit memos | `actions/invoices.ts`, `actions/vendor-bills.ts`, `components/financials/` |
|
||||||
|
| Mobile | Capacitor native wrapper, offline photos, push | `lib/native/`, `lib/push/`, `hooks/use-native*.ts`, `components/native/` |
|
||||||
|
|
||||||
|
Some tables blur the line. The `customers` and `vendors` tables in `schema.ts` are core entities used by multiple modules, but they have `netsuiteId` columns that only matter when the NetSuite module is active. The `projects` table has a `netsuiteJobId` column for the same reason. The `pushTokens` table lives in the core schema but is only meaningful to the mobile module. These are pragmatic compromises: splitting them into separate schemas would add complexity without real benefit at the current scale.
|
||||||
|
|
||||||
|
|
||||||
|
how modules integrate
|
||||||
|
---
|
||||||
|
|
||||||
|
Each module integrates with core through a consistent set of patterns. Here's the Google Drive module as an example:
|
||||||
|
|
||||||
|
Schema: `src/db/schema-google.ts` defines `googleAuth` (encrypted service account key, workspace domain, shared drive selection) and `googleStarredFiles` (per-user file starring stored locally since Google's star API is per-account). It also extends the core `users` table with a `googleEmail` column for impersonation overrides.
|
||||||
|
|
||||||
|
Actions: `src/app/actions/google-drive.ts` exports 17 server actions covering connection management, file CRUD, search, starred files, and storage quota. Every action calls `requireAuth()` and `requirePermission()` before touching data. Two-layer permissions: Compass RBAC gates whether you can even attempt the operation, then Google Workspace permissions determine what the impersonated user can actually see.
|
||||||
|
|
||||||
|
Components: `src/components/files/` contains the full file browser UI -- grid/list views, breadcrumb navigation, context menus, drag-drop upload, folder creation, rename/move dialogs, and a storage quota indicator. All built on shadcn/ui primitives.
|
||||||
|
|
||||||
|
The same pattern holds for every module. NetSuite has its own schema file, sync actions, and connection UI. Scheduling has its own type system, computation library, and multiple view components. The boundary is always: schema + actions + components, connected through Compass Core's auth and data conventions.
|
||||||
|
|
||||||
|
|
||||||
|
the path toward true modularity
|
||||||
|
---
|
||||||
|
|
||||||
|
Today, modules are organized by convention but not enforced by tooling. You could delete the entire `lib/netsuite/` directory and the schedule page would still work, but you'd also need to remove the schema file from drizzle config and clean up any imports.
|
||||||
|
|
||||||
|
The plugin/skills system (documented separately in the architecture section) is the mechanism for getting to genuine plug-and-play modules. Skills already work this way: a SKILL.md file on GitHub gets fetched, parsed, and injected into the agent's system prompt. Full plugin modules can provide tools, components, query types, and action handlers through a typed `PluginManifest`.
|
||||||
|
|
||||||
|
The eventual goal is that an HPS Compass module is just a plugin package: it declares its schema migrations, server actions, components, and agent tools in a manifest, and Compass Core loads them at runtime. We're not there yet. The current modules are still statically imported and wired together at build time. But the architecture is designed to make that transition possible without rewriting the modules themselves.
|
||||||
163
docs/modules/scheduling.md
Executable file
163
docs/modules/scheduling.md
Executable file
@ -0,0 +1,163 @@
|
|||||||
|
Scheduling Module
|
||||||
|
===
|
||||||
|
|
||||||
|
The scheduling module is a construction-specific project scheduling system with Gantt charts, critical path analysis, dependency management, workday exception calendars, and baseline tracking. It's the most computation-heavy module in Compass -- most of the logic lives in pure functions in `src/lib/schedule/` rather than in the server actions.
|
||||||
|
|
||||||
|
|
||||||
|
data model
|
||||||
|
---
|
||||||
|
|
||||||
|
The scheduling data lives in four tables defined in the core schema (`src/db/schema.ts`):
|
||||||
|
|
||||||
|
**`schedule_tasks`** -- individual tasks within a project. Each task has a title, start date, workday count (not calendar days), a calculated end date, a construction phase, status, completion percentage, and sort order. The `isCriticalPath` flag is recomputed by the system, not set manually.
|
||||||
|
|
||||||
|
**`task_dependencies`** -- relationships between tasks. Each dependency has a predecessor, successor, type (FS/SS/FF/SF), and lag in days. Dependencies drive both date propagation and critical path analysis.
|
||||||
|
|
||||||
|
**`workday_exceptions`** -- non-working days per project. Holidays, vacation days, weather days. These are excluded from business-day calculations. Exceptions can be one-time or yearly recurring, and are categorized (national holiday, state holiday, vacation, company holiday, weather day).
|
||||||
|
|
||||||
|
**`schedule_baselines`** -- named snapshots of the schedule at a point in time. Stores a JSON blob of all tasks and dependencies, used for tracking schedule drift.
|
||||||
|
|
||||||
|
The type system (`src/lib/schedule/types.ts`) models construction phases explicitly:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type ConstructionPhase =
|
||||||
|
| "preconstruction" | "sitework" | "foundation" | "framing"
|
||||||
|
| "roofing" | "electrical" | "plumbing" | "hvac"
|
||||||
|
| "insulation" | "drywall" | "finish" | "landscaping"
|
||||||
|
| "closeout"
|
||||||
|
```
|
||||||
|
|
||||||
|
This is construction-specific by design. A different industry module would define its own phase vocabulary.
|
||||||
|
|
||||||
|
|
||||||
|
business day calculations
|
||||||
|
---
|
||||||
|
|
||||||
|
`src/lib/schedule/business-days.ts` handles the mapping between workdays and calendar dates. The core function is `calculateEndDate`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function calculateEndDate(
|
||||||
|
startDate: string,
|
||||||
|
workdays: number,
|
||||||
|
exceptions: WorkdayExceptionData[] = []
|
||||||
|
): string
|
||||||
|
```
|
||||||
|
|
||||||
|
It walks forward from the start date, counting only days that aren't weekends or exception days. This means a 10-workday task starting on a Friday will end more than two calendar weeks later if there are holidays in between.
|
||||||
|
|
||||||
|
The module also exports `countBusinessDays` (how many workdays between two dates) and `addBusinessDays` (move a date forward or backward by N business days). All three functions respect the project's workday exception calendar.
|
||||||
|
|
||||||
|
|
||||||
|
dependency validation
|
||||||
|
---
|
||||||
|
|
||||||
|
`src/lib/schedule/dependency-validation.ts` prevents circular dependencies using DFS. When a user tries to add a new dependency, `wouldCreateCycle` builds the adjacency graph from existing dependencies, adds the proposed edge, and checks if the successor can reach the predecessor through the graph:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function wouldCreateCycle(
|
||||||
|
existingDeps: TaskDependencyData[],
|
||||||
|
newPredecessorId: string,
|
||||||
|
newSuccessorId: string
|
||||||
|
): boolean
|
||||||
|
```
|
||||||
|
|
||||||
|
Self-references are caught immediately (`predecessorId === successorId`). For everything else, it runs a DFS traversal from the successor node. If the traversal reaches the predecessor, the dependency would create a cycle and is rejected.
|
||||||
|
|
||||||
|
|
||||||
|
date propagation
|
||||||
|
---
|
||||||
|
|
||||||
|
`src/lib/schedule/propagate-dates.ts` handles cascading date changes through the dependency graph. When a task's dates change, all downstream successors need their dates recalculated.
|
||||||
|
|
||||||
|
The algorithm uses BFS from the changed task through finish-to-start (FS) dependencies. Only FS dependencies propagate dates -- other dependency types (SS, FF, SF) are tracked but don't currently trigger automatic date shifts. This is a deliberate simplification; full multi-type propagation introduces significant complexity for a feature that construction schedulers rarely use.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// successor starts after predecessor ends + lag
|
||||||
|
const newStart = addBusinessDays(
|
||||||
|
current.endDateCalculated,
|
||||||
|
1 + dep.lagDays,
|
||||||
|
exceptions
|
||||||
|
)
|
||||||
|
const newEnd = calculateEndDate(newStart, successor.workdays, exceptions)
|
||||||
|
```
|
||||||
|
|
||||||
|
The propagation respects workday exceptions, so if pushing a successor forward lands it on a holiday week, the dates adjust accordingly.
|
||||||
|
|
||||||
|
|
||||||
|
critical path analysis
|
||||||
|
---
|
||||||
|
|
||||||
|
`src/lib/schedule/critical-path.ts` implements the Critical Path Method (CPM), the standard algorithm for identifying which tasks directly affect the project completion date.
|
||||||
|
|
||||||
|
The implementation:
|
||||||
|
|
||||||
|
1. Topological sort of all tasks (returns `null` if there's a cycle)
|
||||||
|
2. Forward pass: compute earliest start and finish for each task
|
||||||
|
3. Backward pass: compute latest start and finish from the project end date
|
||||||
|
4. Total float = late start - early start
|
||||||
|
5. Tasks with zero float (within floating-point tolerance of 0.001) are on the critical path
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const critical = new Set<string>()
|
||||||
|
for (const [id, node] of nodes) {
|
||||||
|
if (Math.abs(node.totalFloat) < 0.001) {
|
||||||
|
critical.add(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Only FS dependencies are used for CPM calculation. The critical path is recalculated automatically after any task creation, update, deletion, or dependency change.
|
||||||
|
|
||||||
|
|
||||||
|
server actions
|
||||||
|
---
|
||||||
|
|
||||||
|
`src/app/actions/schedule.ts` provides:
|
||||||
|
|
||||||
|
- `getSchedule(projectId)` -- returns all tasks, dependencies, and exceptions for a project
|
||||||
|
- `createTask(projectId, data)` -- creates a task with calculated end date, recalculates critical path
|
||||||
|
- `updateTask(taskId, data)` -- updates a task, propagates dates to downstream successors, recalculates critical path
|
||||||
|
- `deleteTask(taskId)` -- deletes a task, recalculates critical path
|
||||||
|
- `reorderTasks(projectId, items)` -- reorder tasks (drag-and-drop in the UI)
|
||||||
|
- `createDependency(data)` -- creates a dependency with cycle validation, propagates dates
|
||||||
|
- `deleteDependency(depId, projectId)` -- deletes a dependency, recalculates critical path
|
||||||
|
- `updateTaskStatus(taskId, status)` -- change task status (PENDING, IN_PROGRESS, COMPLETE, BLOCKED)
|
||||||
|
|
||||||
|
`src/app/actions/baselines.ts` provides:
|
||||||
|
|
||||||
|
- `getBaselines(projectId)` -- list all baselines for a project
|
||||||
|
- `createBaseline(projectId, name)` -- snapshot current tasks and dependencies as JSON
|
||||||
|
- `deleteBaseline(baselineId)` -- delete a baseline
|
||||||
|
|
||||||
|
`src/app/actions/workday-exceptions.ts` provides:
|
||||||
|
|
||||||
|
- `getWorkdayExceptions(projectId)` -- list exceptions for a project
|
||||||
|
- `createWorkdayException(projectId, data)` -- create an exception
|
||||||
|
- `updateWorkdayException(exceptionId, data)` -- update an exception
|
||||||
|
- `deleteWorkdayException(exceptionId)` -- delete an exception
|
||||||
|
|
||||||
|
|
||||||
|
UI components
|
||||||
|
---
|
||||||
|
|
||||||
|
`src/components/schedule/` contains 13 components:
|
||||||
|
|
||||||
|
- `schedule-view.tsx` -- main container that manages which sub-view is active
|
||||||
|
- `schedule-gantt-view.tsx` -- Gantt chart view with the frappe-gantt integration
|
||||||
|
- `gantt-chart.tsx` -- wrapper component for the Gantt rendering
|
||||||
|
- `gantt.css` -- custom styles for the Gantt chart
|
||||||
|
- `schedule-list-view.tsx` -- table/list view of all tasks
|
||||||
|
- `schedule-calendar-view.tsx` -- calendar visualization of task dates
|
||||||
|
- `schedule-baseline-view.tsx` -- baseline comparison view
|
||||||
|
- `schedule-mobile-view.tsx` -- simplified view for mobile devices
|
||||||
|
- `schedule-toolbar.tsx` -- view switcher, filters, add task button
|
||||||
|
- `task-form-dialog.tsx` -- create/edit task form with phase selection, date picker, dependency config
|
||||||
|
- `dependency-dialog.tsx` -- add/remove dependency dialog
|
||||||
|
- `workday-exceptions-view.tsx` -- exception calendar management
|
||||||
|
- `workday-exception-form-dialog.tsx` -- create/edit exception form
|
||||||
|
|
||||||
|
|
||||||
|
known issues
|
||||||
|
---
|
||||||
|
|
||||||
|
**Gantt chart vertical panning.** Horizontal zoom and pan work correctly. Vertical panning (scrolling through tasks) conflicts with frappe-gantt's container sizing model. The chart renders at a fixed height based on task count, and the container handles overflow. A proper fix would require a transform-based rendering approach with a fixed header, which is a non-trivial change to the third-party library integration.
|
||||||
@ -1,195 +0,0 @@
|
|||||||
Theme System
|
|
||||||
===
|
|
||||||
|
|
||||||
Compass ships a runtime theming engine that lets users switch between preset palettes, create custom themes through the AI agent, and edit those themes incrementally. Every theme defines light and dark color maps, typography, spacing tokens, and shadow scales. Switching themes triggers an animated circle-reveal transition from the click origin.
|
|
||||||
|
|
||||||
This document explains how the pieces fit together, what problems the architecture solves, and where to look when something breaks.
|
|
||||||
|
|
||||||
|
|
||||||
How themes work
|
|
||||||
---
|
|
||||||
|
|
||||||
A theme is a `ThemeDefinition` object (defined in `src/lib/theme/types.ts`) containing:
|
|
||||||
|
|
||||||
- **32 color keys** for both light and dark modes (background, foreground, primary, sidebar variants, chart colors, etc.) - all in oklch() format
|
|
||||||
- **fonts** (sans, serif, mono) as CSS font-family strings, plus an optional list of Google Font names to load at runtime
|
|
||||||
- **tokens** for border radius, spacing, letter tracking, and shadow geometry
|
|
||||||
- **shadow scales** (2xs through 2xl) for both light and dark, since some themes use colored or offset shadows
|
|
||||||
- **preview colors** (primary, background, foreground) used by the theme card swatches in settings
|
|
||||||
|
|
||||||
When a theme is applied, `applyTheme()` in `src/lib/theme/apply.ts` builds a `<style>` block containing `:root { ... }` and `.dark { ... }` CSS variable declarations, then injects it into `<head>`. Because the style element has higher specificity than the default variables in `globals.css`, the theme overrides take effect immediately. Removing the style element reverts to the default "Native Compass" palette.
|
|
||||||
|
|
||||||
Google Fonts are loaded lazily. `loadGoogleFonts()` in `src/lib/theme/fonts.ts` tracks which fonts have already been injected and only adds new `<link>` elements for fonts not yet present. Fonts load with `display=swap` to avoid blocking rendering.
|
|
||||||
|
|
||||||
|
|
||||||
Presets vs custom themes
|
|
||||||
---
|
|
||||||
|
|
||||||
Preset themes are hardcoded in `src/lib/theme/presets.ts` and ship with the app. They're identified by slug IDs like `corpo`, `doom-64`, `violet-bloom`. The `findPreset()` function does a simple array lookup.
|
|
||||||
|
|
||||||
Custom themes live in the `custom_themes` D1 table (schema in `src/db/schema-theme.ts`). Each row stores the full `ThemeDefinition` as a JSON string in `theme_data`, scoped to a user via `user_id`. The user's active theme preference is stored separately in `user_theme_preference`.
|
|
||||||
|
|
||||||
This separation matters because preset resolution is synchronous (array lookup, no IO) while custom theme resolution requires a database fetch. The theme provider exploits this difference to eliminate flash-of-unstyled-content on page load.
|
|
||||||
|
|
||||||
|
|
||||||
Theme provider architecture
|
|
||||||
---
|
|
||||||
|
|
||||||
`ThemeProvider` in `src/components/theme-provider.tsx` wraps the entire app and manages theme state. It solves a specific problem: the user's chosen theme needs to be visible on the very first paint, before any server action can return data from D1.
|
|
||||||
|
|
||||||
The solution uses two localStorage keys:
|
|
||||||
|
|
||||||
- `compass-active-theme` stores the theme ID
|
|
||||||
- `compass-theme-data` stores the full theme JSON (only for non-default themes)
|
|
||||||
|
|
||||||
On mount, a `useLayoutEffect` reads both keys synchronously. For preset themes, it resolves the definition from the in-memory array. For custom themes, it parses the cached JSON. Either way, `applyTheme()` runs before the browser paints, so the user sees their chosen theme immediately.
|
|
||||||
|
|
||||||
A separate `useEffect` then fetches the user's actual preference from D1 and their custom themes list. If the database disagrees with what the cache applied (because the user changed themes on another device, say), it re-applies the correct theme. If they agree, it just refreshes the cached data to stay current.
|
|
||||||
|
|
||||||
This two-phase approach - instant from cache, then validate against the database - means theme application is never blocked on network IO.
|
|
||||||
|
|
||||||
The provider exposes these methods through `useCompassTheme()`:
|
|
||||||
|
|
||||||
- `setVisualTheme(themeId, origin?)` - commits a theme change. Triggers the circle-reveal animation, persists the preference to D1, and updates the cache.
|
|
||||||
- `previewTheme(theme)` - applies a theme instantly (no animation) without persisting. Used for hover previews and AI-generated theme previews.
|
|
||||||
- `cancelPreview()` - reverts to the committed theme. Also instant, no animation.
|
|
||||||
- `refreshCustomThemes()` - re-fetches the custom themes list from D1. Called after the agent creates or edits a theme.
|
|
||||||
|
|
||||||
The distinction between animated and instant application is intentional. The circle-reveal is satisfying when you deliberately choose a theme, but disorienting during previews or initial page loads. Only `setVisualTheme` animates.
|
|
||||||
|
|
||||||
|
|
||||||
Circle-reveal animation
|
|
||||||
---
|
|
||||||
|
|
||||||
`applyThemeAnimated()` in `src/lib/theme/transition.ts` wraps theme application in the View Transition API. The animation works like this:
|
|
||||||
|
|
||||||
1. `document.startViewTransition()` captures a screenshot of the current page
|
|
||||||
2. Inside the callback, CSS variables are mutated via `applyTheme()` or `removeThemeOverride()`
|
|
||||||
3. Once the new state is ready, we animate `::view-transition-new(root)` with an expanding `clip-path: circle()` from the click origin
|
|
||||||
4. The circle expands to cover the full viewport (radius calculated via `Math.hypot` from the origin to the farthest corner)
|
|
||||||
|
|
||||||
The animation runs for 400ms with `ease-in-out` easing. Two lines in `globals.css` disable the View Transition API's default crossfade so our clip-path animation is the only visual effect:
|
|
||||||
|
|
||||||
```css
|
|
||||||
::view-transition-old(root),
|
|
||||||
::view-transition-new(root) {
|
|
||||||
animation: none;
|
|
||||||
mix-blend-mode: normal;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Fallback behavior: if the browser doesn't support the View Transition API (Firefox, older Safari) or the user has `prefers-reduced-motion: reduce` enabled, `applyThemeAnimated` skips the transition wrapper entirely and applies the theme instantly. No degraded experience, just no animation.
|
|
||||||
|
|
||||||
When themes are switched from the settings panel, the click coordinates come from the `MouseEvent` on the theme card. When the AI agent switches themes, no origin is provided, so the animation radiates from the viewport center.
|
|
||||||
|
|
||||||
|
|
||||||
AI agent integration
|
|
||||||
---
|
|
||||||
|
|
||||||
The agent has four theme-related tools defined in `src/lib/agent/tools.ts`:
|
|
||||||
|
|
||||||
**listThemes** returns all available themes (presets + user's custom themes) with IDs, names, and descriptions. The agent calls this when asked "what themes are available?" or needs to look up a theme by name.
|
|
||||||
|
|
||||||
**setTheme** activates a theme by ID. It persists the preference via `setUserThemePreference()`, then returns `{ action: "apply_theme", themeId }`. The chat adapter dispatches this as an `agent-apply-theme` CustomEvent, which the theme provider listens for and handles with `setVisualTheme()`.
|
|
||||||
|
|
||||||
**generateTheme** creates a new custom theme from scratch. The agent provides all 32 color keys for both light and dark modes, font stacks, optional Google Font names, and design tokens. The tool builds a full `ThemeDefinition`, saves it to D1 via `saveCustomTheme()`, and returns `{ action: "preview_theme", themeId, themeData }`. The chat adapter dispatches this as an `agent-preview-theme` CustomEvent, which triggers `refreshCustomThemes()` followed by `previewTheme()`.
|
|
||||||
|
|
||||||
**editTheme** modifies an existing custom theme. This is the incremental editing tool - the agent only provides the fields it wants to change. The tool fetches the existing theme from D1 via `getCustomThemeById()`, deep-merges the changes (spreading existing values under the new ones for colors, fonts, and tokens), rebuilds preview colors, saves the merged result back with the same ID, and returns the same `preview_theme` action shape.
|
|
||||||
|
|
||||||
The deep merge is straightforward: `{ ...existingLight, ...inputLight }` for color maps, individual key fallbacks for fonts (`input.fonts.sans ?? prev.fonts.sans`), and spread with conditional overrides for tokens. Only the keys the agent specifies are touched; everything else passes through unchanged.
|
|
||||||
|
|
||||||
The system prompt in `src/lib/agent/system-prompt.ts` includes guidance for when to use each tool:
|
|
||||||
|
|
||||||
- "change to corpo" -> setTheme
|
|
||||||
- "make me a sunset theme" -> generateTheme
|
|
||||||
- "make the primary darker" -> editTheme (when a custom theme is active)
|
|
||||||
|
|
||||||
The editTheme tool only works on custom themes, not presets. This is enforced by `getCustomThemeById()` which queries the `custom_themes` table scoped to the current user. If someone asks to tweak a preset, the agent should generate a new custom theme based on the preset's colors instead.
|
|
||||||
|
|
||||||
|
|
||||||
Server actions
|
|
||||||
---
|
|
||||||
|
|
||||||
Theme persistence is handled by server actions in `src/app/actions/themes.ts`:
|
|
||||||
|
|
||||||
- `getUserThemePreference()` - returns the user's active theme ID (defaults to "native-compass")
|
|
||||||
- `setUserThemePreference(themeId)` - validates the ID exists (as preset or custom theme belonging to user), then upserts into `user_theme_preference`
|
|
||||||
- `getCustomThemes()` - returns all custom themes for the current user, ordered by most recently updated
|
|
||||||
- `getCustomThemeById(themeId)` - fetches a single custom theme by ID, scoped to current user
|
|
||||||
- `saveCustomTheme(name, description, themeData, existingId?)` - creates or updates a custom theme. When `existingId` is provided, it updates the existing row instead of inserting
|
|
||||||
- `deleteCustomTheme(themeId)` - removes a custom theme and resets the user's preference to "native-compass" if it was the active theme
|
|
||||||
|
|
||||||
All actions follow the standard Compass pattern: authenticate via `getCurrentUser()`, return discriminated union results (`{ success: true, data }` or `{ success: false, error }`), and call `revalidatePath("/", "layout")` after mutations.
|
|
||||||
|
|
||||||
|
|
||||||
Database schema
|
|
||||||
---
|
|
||||||
|
|
||||||
Two tables in `src/db/schema-theme.ts`:
|
|
||||||
|
|
||||||
```
|
|
||||||
custom_themes
|
|
||||||
├── id text (PK, UUID)
|
|
||||||
├── user_id text (FK -> users.id, cascade delete)
|
|
||||||
├── name text
|
|
||||||
├── description text (default "")
|
|
||||||
├── theme_data text (JSON-serialized ThemeDefinition)
|
|
||||||
├── created_at text (ISO 8601)
|
|
||||||
└── updated_at text (ISO 8601)
|
|
||||||
|
|
||||||
user_theme_preference
|
|
||||||
├── user_id text (PK, FK -> users.id, cascade delete)
|
|
||||||
├── active_theme_id text
|
|
||||||
└── updated_at text (ISO 8601)
|
|
||||||
```
|
|
||||||
|
|
||||||
The `theme_data` column stores the complete `ThemeDefinition` as JSON. This means custom themes are self-contained - reading a single row gives you everything needed to apply the theme without any joins or additional queries.
|
|
||||||
|
|
||||||
|
|
||||||
File map
|
|
||||||
---
|
|
||||||
|
|
||||||
```
|
|
||||||
src/lib/theme/
|
|
||||||
├── types.ts ThemeDefinition, ColorMap, and related types
|
|
||||||
├── presets.ts THEME_PRESETS array + findPreset() + DEFAULT_THEME_ID
|
|
||||||
├── apply.ts applyTheme() and removeThemeOverride() - CSS injection
|
|
||||||
├── transition.ts applyThemeAnimated() - View Transition API wrapper
|
|
||||||
├── fonts.ts loadGoogleFonts() - lazy Google Fonts injection
|
|
||||||
└── index.ts barrel exports
|
|
||||||
|
|
||||||
src/components/
|
|
||||||
├── theme-provider.tsx ThemeProvider + useCompassTheme hook
|
|
||||||
└── settings/appearance-tab.tsx Theme cards UI + click-origin forwarding
|
|
||||||
|
|
||||||
src/app/actions/themes.ts Server actions for D1 persistence
|
|
||||||
src/db/schema-theme.ts Drizzle schema for theme tables
|
|
||||||
src/lib/agent/tools.ts AI agent theme tools (lines 434-721)
|
|
||||||
src/lib/agent/system-prompt.ts Theming guidance in buildThemingRules()
|
|
||||||
src/app/globals.css Default theme vars + view-transition CSS
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
Adding a new preset
|
|
||||||
---
|
|
||||||
|
|
||||||
Add a `ThemeDefinition` object to the `THEME_PRESETS` array in `src/lib/theme/presets.ts`. The object needs all 32 color keys for both light and dark, plus fonts, tokens, shadows, and preview colors. Set `isPreset: true`.
|
|
||||||
|
|
||||||
Then update three references:
|
|
||||||
1. `setTheme` tool description in `tools.ts` - add the new ID to the preset list
|
|
||||||
2. `TOOL_REGISTRY` in `system-prompt.ts` - update the setTheme summary
|
|
||||||
3. `buildThemingRules()` in `system-prompt.ts` - add the new preset with a short description
|
|
||||||
|
|
||||||
All color values must be oklch() format. Light backgrounds should have lightness >= 0.90, dark backgrounds <= 0.25. Ensure sufficient contrast between foreground and background pairs.
|
|
||||||
|
|
||||||
|
|
||||||
Debugging
|
|
||||||
---
|
|
||||||
|
|
||||||
**Theme not applying on page load**: Check localStorage for `compass-active-theme` and `compass-theme-data`. If the ID points to a custom theme but the data key is missing or corrupted, the `useLayoutEffect` won't be able to apply it instantly. The DB fetch will eventually correct it, but there will be a flash.
|
|
||||||
|
|
||||||
**Circle animation not working**: The View Transition API requires Chromium 111+. Check `document.startViewTransition` exists. Also check that `prefers-reduced-motion` isn't set to `reduce` in OS settings or dev tools.
|
|
||||||
|
|
||||||
**Agent creates theme but it doesn't preview**: The tool should return `{ action: "preview_theme", themeId, themeData }`. The chat adapter dispatches this as a `CustomEvent` named `agent-preview-theme`. The theme provider listens for this event and calls `refreshCustomThemes()` then `previewTheme()`. Check the browser console for the event dispatch and verify the theme provider's event listener is registered.
|
|
||||||
|
|
||||||
**editTheme returns "theme not found"**: The tool only works on custom themes, not presets. `getCustomThemeById()` queries the `custom_themes` table scoped to the current user. If the theme ID is a preset slug or belongs to a different user, it will fail.
|
|
||||||
@ -1,187 +0,0 @@
|
|||||||
user drawer & invite dialog - implementation complete
|
|
||||||
===
|
|
||||||
|
|
||||||
## components created
|
|
||||||
|
|
||||||
### 1. user drawer (`src/components/people/user-drawer.tsx`)
|
|
||||||
|
|
||||||
full-featured user detail and edit drawer with:
|
|
||||||
|
|
||||||
**features:**
|
|
||||||
- sheet/drawer component (mobile-friendly bottom sheet)
|
|
||||||
- three tabs: profile, access, activity
|
|
||||||
- real-time role updates with save confirmation
|
|
||||||
- displays user avatar, name, email
|
|
||||||
- shows all user relationships (teams, groups, projects, organizations)
|
|
||||||
- read-only profile fields (managed by workos)
|
|
||||||
- activity tracking (last login, created, updated dates)
|
|
||||||
- status badge (active/inactive)
|
|
||||||
|
|
||||||
**profile tab:**
|
|
||||||
- first name, last name (read-only)
|
|
||||||
- email (read-only)
|
|
||||||
- display name (read-only)
|
|
||||||
- note explaining workos manages profile data
|
|
||||||
|
|
||||||
**access tab:**
|
|
||||||
- role selector with save button
|
|
||||||
- teams list (badges)
|
|
||||||
- groups list (badges)
|
|
||||||
- project count
|
|
||||||
- organization count
|
|
||||||
- real-time updates when role changes
|
|
||||||
|
|
||||||
**activity tab:**
|
|
||||||
- account status badge
|
|
||||||
- last login timestamp
|
|
||||||
- account created date
|
|
||||||
- last updated date
|
|
||||||
|
|
||||||
**mobile optimizations:**
|
|
||||||
- responsive sheet (side drawer on desktop, bottom sheet on mobile)
|
|
||||||
- scrollable content
|
|
||||||
- proper touch targets
|
|
||||||
|
|
||||||
### 2. invite dialog (`src/components/people/invite-dialog.tsx`)
|
|
||||||
|
|
||||||
clean invite flow for new users:
|
|
||||||
|
|
||||||
**features:**
|
|
||||||
- dialog component
|
|
||||||
- email validation
|
|
||||||
- role selection with descriptions
|
|
||||||
- optional organization assignment
|
|
||||||
- loading states
|
|
||||||
- error handling
|
|
||||||
- form reset on success
|
|
||||||
|
|
||||||
**form fields:**
|
|
||||||
- email (required, validated)
|
|
||||||
- role (required) with helpful descriptions:
|
|
||||||
- admin: full access to all features
|
|
||||||
- office: manage projects, schedules, documents
|
|
||||||
- field: update schedules, create documents
|
|
||||||
- client: read-only access to assigned projects
|
|
||||||
- organization (optional dropdown)
|
|
||||||
|
|
||||||
**ux details:**
|
|
||||||
- loads organizations on open
|
|
||||||
- shows loading spinner while fetching orgs
|
|
||||||
- validates email format before submit
|
|
||||||
- disabled state during submission
|
|
||||||
- toast notifications for success/error
|
|
||||||
- auto-closes and reloads data on success
|
|
||||||
|
|
||||||
### 3. updated people page
|
|
||||||
|
|
||||||
integrated both components:
|
|
||||||
|
|
||||||
**state management:**
|
|
||||||
- selectedUser state for drawer
|
|
||||||
- drawerOpen boolean
|
|
||||||
- inviteDialogOpen boolean
|
|
||||||
- automatic data refresh after updates
|
|
||||||
|
|
||||||
**user interactions:**
|
|
||||||
- click user row → opens drawer
|
|
||||||
- click "invite user" button → opens dialog
|
|
||||||
- drawer save → refreshes user list
|
|
||||||
- dialog invite → refreshes user list and closes
|
|
||||||
- deactivate user → confirms and refreshes list
|
|
||||||
|
|
||||||
**handlers:**
|
|
||||||
- handleEditUser() - opens drawer with selected user
|
|
||||||
- handleDeactivateUser() - deactivates and refreshes
|
|
||||||
- handleUserUpdated() - callback to refresh data
|
|
||||||
- handleUserInvited() - callback to refresh data
|
|
||||||
|
|
||||||
## code quality
|
|
||||||
|
|
||||||
**typescript:**
|
|
||||||
- no type errors
|
|
||||||
- proper typing throughout
|
|
||||||
- uses existing types from actions
|
|
||||||
|
|
||||||
**patterns:**
|
|
||||||
- follows existing codebase patterns
|
|
||||||
- uses shadcn/ui components consistently
|
|
||||||
- proper error handling with try/catch
|
|
||||||
- toast notifications for user feedback
|
|
||||||
- loading states for async operations
|
|
||||||
|
|
||||||
**mobile responsive:**
|
|
||||||
- all components work on mobile
|
|
||||||
- proper touch targets
|
|
||||||
- scrollable content
|
|
||||||
- responsive layouts
|
|
||||||
|
|
||||||
## testing steps
|
|
||||||
|
|
||||||
### test user drawer:
|
|
||||||
|
|
||||||
1. navigate to `/dashboard/people`
|
|
||||||
2. click any user row in the table
|
|
||||||
3. drawer opens from right (desktop) or bottom (mobile)
|
|
||||||
4. verify all tabs work (profile, access, activity)
|
|
||||||
5. change role dropdown
|
|
||||||
6. click "save role"
|
|
||||||
7. verify toast confirmation
|
|
||||||
8. verify table updates with new role badge
|
|
||||||
|
|
||||||
### test invite dialog:
|
|
||||||
|
|
||||||
1. navigate to `/dashboard/people`
|
|
||||||
2. click "invite user" button
|
|
||||||
3. dialog opens centered
|
|
||||||
4. enter email (test validation with invalid email)
|
|
||||||
5. select role (see descriptions change)
|
|
||||||
6. optionally select organization
|
|
||||||
7. click "send invitation"
|
|
||||||
8. verify toast confirmation
|
|
||||||
9. verify dialog closes
|
|
||||||
10. verify new user appears in table
|
|
||||||
|
|
||||||
### test error handling:
|
|
||||||
|
|
||||||
1. try inviting existing email (should error)
|
|
||||||
2. try inviting without email (should error)
|
|
||||||
3. try saving role without changes (should info)
|
|
||||||
4. disconnect network and try actions (should error gracefully)
|
|
||||||
|
|
||||||
## integration with workos
|
|
||||||
|
|
||||||
when workos is configured:
|
|
||||||
|
|
||||||
**invite flow:**
|
|
||||||
- creates user record in database immediately
|
|
||||||
- user receives workos invitation email
|
|
||||||
- user sets up account via workos
|
|
||||||
- on first login, profile syncs from workos
|
|
||||||
- user id matches between workos and database
|
|
||||||
|
|
||||||
**profile updates:**
|
|
||||||
- profile fields (name, email) come from workos
|
|
||||||
- can't be edited in drawer (read-only)
|
|
||||||
- role/access can be managed in compass
|
|
||||||
- changes sync on next login
|
|
||||||
|
|
||||||
## next steps
|
|
||||||
|
|
||||||
once workos is configured:
|
|
||||||
|
|
||||||
1. test full invite flow end-to-end
|
|
||||||
2. verify email invitations are sent
|
|
||||||
3. test user login after invitation
|
|
||||||
4. verify profile sync from workos
|
|
||||||
5. test role changes persist across sessions
|
|
||||||
|
|
||||||
## files created/modified
|
|
||||||
|
|
||||||
**created:**
|
|
||||||
- `src/components/people/user-drawer.tsx` (240 lines)
|
|
||||||
- `src/components/people/invite-dialog.tsx` (180 lines)
|
|
||||||
|
|
||||||
**modified:**
|
|
||||||
- `src/app/dashboard/people/page.tsx` (added drawer and dialog integration)
|
|
||||||
|
|
||||||
all components are production-ready and mobile-optimized.
|
|
||||||
@ -140,7 +140,7 @@ export async function connectGoogleDrive(
|
|||||||
.insert(googleAuth)
|
.insert(googleAuth)
|
||||||
.values({
|
.values({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
organizationId: "default",
|
organizationId: "org-1",
|
||||||
serviceAccountKeyEncrypted: encryptedKey,
|
serviceAccountKeyEncrypted: encryptedKey,
|
||||||
workspaceDomain,
|
workspaceDomain,
|
||||||
connectedBy: user.id,
|
connectedBy: user.id,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user