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>
180 lines
7.8 KiB
Markdown
Executable File
180 lines
7.8 KiB
Markdown
Executable File
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).
|