release workflow

This commit is contained in:
2026-02-24 18:30:45 -06:00
parent 06e021f8a1
commit db9b722929
15 changed files with 398 additions and 77 deletions
+195 -63
View File
@@ -1,89 +1,221 @@
# Copilot / AI Agent Instructions for ttscm-api
# Copilot / AI Agent Instructions for optima-api
Purpose: make AI coding agents immediately productive in this repository by describing architecture, conventions, workflows, and helpful code pointers.
-- **Big picture**: This is a TypeScript API service (runs on Bun) using the Hono framework. The HTTP surface is implemented in `src/api` where small router files are mounted on a versioned router in `src/api/server.ts` (see the `/v1` mount). Typical request flow is:
---
- `router` (in `src/api/routers/*`) → `controller` (in `src/controllers/*`) → `manager` (in `src/managers/*`) → `module` / `generated/prisma` for persistence and external integrations.
- Keep each layer focused: routers handle routing & middleware, controllers handle request validation and high-level orchestration, managers encapsulate domain/persistence logic, modules provide shared utilities (external API clients, helpers).
## Big picture
- **Runtime / tooling**: The project runs on Bun. Dev command: `npm run dev` (runs `bun --watch src/index.ts`). DB tooling uses Prisma; generated client lives under `generated/prisma` (do NOT edit generated files). Key scripts in `package.json`:
- `dev` — start the server with Bun in watch mode
- `db:gen``prisma generate`
- `db:push``prisma migrate dev --skip-generate`
This is a TypeScript API service (runs on **Bun**) using the **Hono** framework. The HTTP surface is implemented in `src/api` where small route-handler files are mounted on a versioned router in `src/api/server.ts` (see the `/v1` mount). The typical request flow is:
- **Data layer**: Prisma schema is at `prisma/schema.prisma`. The app imports the generated Prisma client from `generated/prisma/client.ts` (or `generated/prisma/browser.ts` in browser contexts). Always run the `db:gen` script after updating `schema.prisma`.
```
server.ts → routers/<domain>Router.ts → api/<domain>/*.ts (route handlers) → managers/<domain>.ts → controllers (domain models) / modules / generated/prisma
```
- **Routing & controllers**: Example flow: a request hits a `router` in `src/api/routers/*` → router delegates to a `controller` in `src/controllers/*` → controller calls a `manager` in `src/managers/*` for domain/persistence logic. Important concrete patterns:
- `src/api/server.ts` mounts `v1` and uses `v1.route("/auth", require("./routers/authRouter").default)` style requires; preserve this shape when adding routes.
- Router files export a default Hono router object (CommonJS `module.exports`/`export default` mixture is used across the codebase).
- Controllers are single-export modules named like `CompanyController.ts` with named methods per action (e.g., `fetch`, `update`). Prefer small methods that call into `managers/*`.
- Managers are thin domain layers (e.g., `managers/companies.ts`) that wrap `generated/prisma` calls and other I/O. Keep side effects here, keep controllers pure orchestration.
- Use `src/modules/api-utils/apiResponse.ts` for every HTTP response shape (successful/created/error/internalError/zodError).
- Use Zod schemas in controllers for request validation; server-level `app.onError` maps `ZodError` to `apiResponse.zodError`.
Keep each layer focused:
**API layout & conventions (how to add a new endpoint)**
- **Route handler files** (`src/api/<domain>/*.ts`) — define individual endpoints using the `createRoute()` utility, handle request validation with Zod, call managers, and return `apiResponse.*` results.
- **Routers** (`src/api/routers/*`) — aggregate route handler files from a domain's `index.ts` and re-mount them under a single prefix. Mounted in `src/api/server.ts`.
- **Managers** (`src/managers/*`) — thin domain/persistence layers that wrap `generated/prisma` calls and other I/O. Managers instantiate and return **controller** instances as domain objects.
- **Controllers** (`src/controllers/*`) — **domain model classes** (e.g., `CompanyController`, `CredentialController`, `UserController`) that encapsulate entity state and domain methods. They are NOT request handlers. Managers create and return controller instances.
- **Modules** (`src/modules/*`) — shared utilities, external API clients (ConnectWise, UniFi, Microsoft), credential helpers, permission utilities, and tools.
- **Add router**: create `src/api/routers/<thing>Router.ts` exporting a Hono router and mount it in `src/api/server.ts` under the `v1` router.
- **Add controller**: create `src/controllers/<Thing>Controller.ts` exporting functions for each action. Controllers should validate input with Zod, call managers, and return `apiResponse.*` results.
- **Add manager**: create `src/managers/<things>.ts` for persistence/domain logic. Use the generated Prisma client (`generated/prisma/client.ts`) here; do not import Prisma directly in controllers.
- **Add modules/types**: if needed, add helpers to `src/modules/*` and runtime types to `src/types/*`.
- **Middleware & auth**: use `src/api/middleware/authorization.ts` for protecting routes; follow existing token/session patterns from `src/controllers/SessionController.ts` and `src/Errors/*`.
- **Error handling**: throw repository-specific errors from `src/Errors/*` (include `status`, `name`, `message`, optional `cause`) and let `src/api/server.ts` map them via `apiResponse.error`.
- **Naming**: prefer plural manager filenames (`companies.ts`) and singular controller names (`CompanyController.ts`) — follow existing files.
---
**Examples & notable files**
## Runtime / tooling
The project runs on **Bun**. DB tooling uses **Prisma**; the generated client lives under `generated/prisma` (do NOT edit generated files). Key scripts in `package.json`:
- `dev``NODE_ENV=development bun --watch src/index.ts` (start dev server with hot reload)
- `db:gen``prisma generate`
- `db:push``prisma migrate dev --skip-generate`
- `utils:dev``docker compose -f .docker/docker-compose.yml up --build`
- `utils:gen_private_keys``bun ./utils/genPrivateKeys`
- `utils:create_admin_role``bun ./utils/createAdminRole`
- `utils:assign_user_role``bun ./utils/assignUserRole`
## Data layer
Prisma schema is at `prisma/schema.prisma`. The app imports the generated Prisma client from `generated/prisma/client.ts` (or `generated/prisma/browser.ts` for browser type contexts). The shared `prisma` instance is exported from `src/constants.ts`. Always run `npm run db:gen` after updating `schema.prisma`.
## Shared constants (`src/constants.ts`)
This file exports critical shared instances used across the codebase:
- `prisma` — the PrismaClient instance (via `@prisma/adapter-pg`)
- `PORT`, session/token durations, private/public keys for JWT signing
- `msalClient` — Microsoft MSAL client for OAuth
- `connectWiseApi` — Axios instance for ConnectWise API
- `unifi``UnifiClient` instance for UniFi controller interaction
---
## Route handler pattern (`createRoute`)
Every route handler file uses the `createRoute()` utility from `src/modules/api-utils/createRoute.ts`. It creates a self-contained Hono sub-app for a single endpoint:
```ts
export default createRoute(
"get", // HTTP method
["/companies"], // path(s)
async (c) => {
/* handler */
}, // request handler
authMiddleware({ permissions: ["company.fetch.many"] }), // middleware (spread)
);
```
Route files live in `src/api/<domain>/*.ts`. Each domain folder has an `index.ts` that re-exports all route modules. Router files (`src/api/routers/<domain>Router.ts`) import from the domain's index and auto-mount all routes:
```ts
import * as companyRoutes from "../companies";
const companyRouter = new Hono();
Object.values(companyRoutes).map((r) => companyRouter.route("/", r));
export default companyRouter;
```
`src/api/server.ts` then mounts each router under `/v1`:
```ts
v1.route("/company", require("./routers/companyRouter").default);
```
## Routing & domain organization
The `server.ts` file mounts these routers under `/v1`:
- `/teapot` — health check
- `/auth` — Microsoft OAuth flow (`src/api/auth/*`)
- `/user` — user routes (`src/api/user/*`) including `@me` sub-routes
- `/company` — company routes (`src/api/companies/*`)
- `/credential` — credential routes (`src/api/credentials/*`)
- `/credential-type` — credential type routes (`src/api/credential-types/*`)
- `/role` — role management (`src/api/roles/*`)
- `/permissions` — permission node queries (`src/api/permissions/*`)
- `/unifi` — UniFi integration (`src/api/unifi/*` with `sites/` and `site/` sub-folders)
---
## API layout & conventions (how to add a new endpoint)
1. **Add route handler files**: Create `src/api/<domain>/<action>.ts` files, each exporting a default `createRoute(...)` call. Validate input with Zod inside the handler, call managers for business logic, and return responses via `apiResponse.*`.
2. **Add domain index**: Create `src/api/<domain>/index.ts` that re-exports all route modules from the folder.
3. **Add router**: Create `src/api/routers/<domain>Router.ts` that imports all routes from the domain's index and mounts them. Mount it in `src/api/server.ts` under `v1`.
4. **Add manager**: Create `src/managers/<domains>.ts` (plural filename) for persistence/domain logic. Use the `prisma` instance from `src/constants.ts`; do not import Prisma directly in route handlers. Managers should instantiate and return controller instances when the domain warrants it.
5. **Add controller** (if needed): Create `src/controllers/<Domain>Controller.ts` (singular filename) as a **class** that encapsulates entity state, domain methods, and a `toJson()` serializer. Controllers are domain model objects — they do NOT handle HTTP requests.
6. **Add modules/types**: If needed, add helpers to `src/modules/*` and runtime types to `src/types/*`.
7. **Middleware & auth**: Use `authMiddleware()` from `src/api/middleware/authorization.ts` as the last argument to `createRoute()`. It accepts `{ permissions?: string[], scopes?: string[], forbiddenAuthTypes?: string[] }`. Follow existing token/session patterns from `src/controllers/SessionController.ts` and `src/Errors/*`.
8. **Error handling**: Throw repository-specific errors from `src/Errors/*` (include `status`, `name`, `message`, optional `cause`) and let `src/api/server.ts` map them via `apiResponse.error`.
9. **Naming conventions**: plural manager filenames (`companies.ts`), singular controller class names (`CompanyController.ts`), descriptive route handler filenames (`fetchAll.ts`, `create.ts`, `update.ts`).
---
## Examples & notable files
- `src/api/server.ts` — mounts `v1`, registers `cors`, central `onError` handler and `notFound` response.
- `src/api/routers/companyRouter.ts` and `src/controllers/CompanyController.ts` — canonical example for adding company endpoints.
- `src/api/user/@me/*` — nested route example (use Hono sub-routers for subpaths).
- `src/modules/cw-utils/*` — external API integrations; keep interfaces stable and return domain objects consumed by managers.
- `src/api/companies/fetchAll.ts` — canonical example of a route handler file using `createRoute()`, `authMiddleware()`, managers, and `apiResponse`.
- `src/api/credentials/create.ts` — example of Zod validation inside a route handler.
- `src/api/routers/companyRouter.ts` — canonical router that auto-mounts all route modules from a domain folder.
- `src/api/user/@me/*` — nested sub-route example (user's own profile endpoints).
- `src/controllers/CompanyController.ts` — example domain model class with methods like `refreshFromCW()`, `fetchCwData()`, and `toJson()`.
- `src/controllers/CredentialController.ts` — example of a richer domain model class with field validation, secure value handling, and sub-credential support.
- `src/managers/companies.ts` — example manager calling Prisma and returning `CompanyController` instances.
- `src/modules/api-utils/createRoute.ts` — the `createRoute()` utility used by every route handler.
- `src/modules/cw-utils/*` — external ConnectWise API integrations; keep interfaces stable and return domain objects consumed by managers.
- `src/modules/unifi-api/UnifiClient.ts` — UniFi controller API client class with methods for sites, WLANs, devices, networks, etc.
- `src/modules/credentials/*` — credential field validation, secure value encryption/decryption, and type definitions.
- `src/constants.ts` — shared instances (`prisma`, API clients, keys, durations).
- **Validation & errors**: Zod is used for input validation; Zod errors are handled centrally in `src/api/server.ts` via `apiResponse.zodError`. Application errors use custom error classes in `src/Errors/*` (e.g., `AuthenticationError.ts`, `GenericError.ts`). When creating errors, follow the shape used in existing errors (include `status`, `name`, `message`, and optional `cause`).
---
- **Response pattern**: Use the `apiResponse` helpers in `src/modules/api-utils/apiResponse.ts` for formatting responses and status codes (successful, created, error, internalError, zodError).
## Validation & errors
- **Auth & external integrations**: Microsoft OAuth flow is under `src/api/auth/*` and `src/modules/fetchMicrosoftUser.ts`. If touching authentication, follow existing redirect/refresh patterns in `src/api/auth` and preserve token-refresh semantics.
Zod is used for input validation **inside route handler files** (not controllers). Zod errors are handled centrally in `src/api/server.ts` via `apiResponse.zodError`. Application errors use custom error classes in `src/Errors/*` (e.g., `AuthenticationError.ts`, `GenericError.ts`, `AuthorizationError.ts`, `InsufficientPermission.ts`). When creating errors, follow the shape used in existing errors (include `status`, `name`, `message`, and optional `cause`).
- **ConnectWise integration**: Utilities for ConnectWise interactions live in `src/modules/cw-utils/*` (e.g., `fetchCompanyConfigurations.ts`). These modules often call external APIs and return domain data; preserve the module contracts (input types and returned shapes) when refactoring.
## Response pattern
- **Generated files and CI**: `generated/` is a build artifact. Do not modify. When updating Prisma models, run `npm run db:gen` and commit changes to `generated/prisma` only if that's the established workflow.
Use the `apiResponse` helpers in `src/modules/api-utils/apiResponse.ts` for formatting all HTTP responses:
- **Files to inspect for context / examples**:
- `src/api/server.ts` — central error handling, router mounting, CORS and not-found handling.
- `src/modules/api-utils/apiResponse.ts` — response shaping used across controllers.
- `src/controllers/CompanyController.ts` — example controller calling managers.
- `src/modules/cw-utils/fetchCompanyConfigurations.ts` — example external integration utility.
- `generated/prisma/client.ts` — generated Prisma client imports; avoid editing.
- `apiResponse.successful(message, data?, meta?)` — 200
- `apiResponse.created(message, data?)` — 201
- `apiResponse.error(err)` — reads `status` from the error
- `apiResponse.internalError()` — 500
- `apiResponse.zodError(err)` — 400
- **Coding conventions & patterns specific to this repo**:
- Prefer the existing layered architecture: routers → controllers → managers → modules.
- Use the `apiResponse` helpers for all HTTP responses.
- Throw or propagate repository-specific custom errors (from `src/Errors/*`) rather than returning bare objects.
- Keep TypeScript types in `src/types/*` and use Zod for runtime checks.
- **Avoid `else` statements** — prefer ternary operators, early returns, or other control flow patterns. Only use `else` if there is absolutely no other way.
## Auth & external integrations
- **Local dev / quick checks**:
- Start dev server: `npm run dev`
- Regenerate Prisma client: `npm run db:gen`
- Apply DB migrations locally: `npm run db:push`
Microsoft OAuth flow is under `src/api/auth/*` and `src/modules/fetchMicrosoftUser.ts`. If touching authentication, follow existing redirect/refresh patterns in `src/api/auth` and preserve token-refresh semantics.
- **When editing generated or infra files**: if you need to change `generated/prisma/*` (rare), explain why in the PR and show commands used to regenerate.
## ConnectWise integration
- **Documentation sync (IMPORTANT)**: Whenever you add, remove, or modify API routes or permission nodes, you **must** update all three of the following files to keep them in sync:
1. `src/types/PermissionNodes.ts` — the single source of truth for all permission node definitions, categories, descriptions, and `usedIn` references.
2. `PERMISSIONS.md` — human-readable documentation of all permission nodes; must strictly reflect the data in `PermissionNodes.ts`.
3. `API_ROUTES.md` — comprehensive documentation of all API routes, including method, path, auth requirements, permissions, request/response examples.
Always verify that new routes have their required permissions listed in `PermissionNodes.ts`, that `PERMISSIONS.md` tables match the TS file exactly, and that `API_ROUTES.md` includes full documentation for every mounted route. Run through all three files at the end of any route or permission change to catch discrepancies.
Utilities for ConnectWise interactions live in `src/modules/cw-utils/*` (e.g., `configurations/fetchCompanyConfigurations.ts`, `fetchCompany.ts`, `fetchAllCompanies.ts`). These modules call external APIs via the `connectWiseApi` Axios instance from `src/constants.ts` and return domain data; preserve the module contracts (input types and returned shapes) when refactoring.
- **Field-level permission gating (processObjectValuePerms)**: Some routes use `processObjectValuePerms` from `src/modules/permission-utils/processObjectPermissions.ts` to filter response objects on a per-field basis. When this pattern is used, every key of the response object becomes a permission node in the form `<scope>.<field>` (e.g., `unifi.site.wifi.read.passphrase`). Only fields whose corresponding permission the user holds are included in the response.
## UniFi integration
**When documenting a route that uses field-level gating, you must:**
1. Note in `API_ROUTES.md` that the route uses field-level gating, explain the behaviour, and list every `<scope>.<field>` permission node in a collapsible table.
2. Add a `unifi.site.wifi.read`-style parent permission node in `PermissionNodes.ts` with a `fieldLevelPermissions` array listing every `<scope>.<field>` node.
3. Add matching rows/notes to `PERMISSIONS.md` including the full list of field-level nodes.
The `UnifiClient` class in `src/modules/unifi-api/UnifiClient.ts` wraps all UniFi controller API interactions (login, sites, WLANs, devices, networks, AP groups, WLAN groups, speed profiles, PPSKs). The shared instance is exported from `src/constants.ts` as `unifi`. UniFi route handlers live in `src/api/unifi/` with `sites/` (multi-site operations) and `site/` (single-site operations) sub-folders.
**Current routes using field-level gating:**
- `GET /v1/unifi/site/:id/wifi` — scope `unifi.site.wifi.read`, gates every field on the `WlanConf` object.
## Generated files and CI
If anything here is unclear or you'd like more examples (e.g., a walk-through editing a controller + manager + test run), tell me which area to expand and I'll iterate.
`generated/` is a build artifact. Do not modify. When updating Prisma models, run `npm run db:gen` and commit changes to `generated/prisma` only if that's the established workflow.
---
## Coding conventions & patterns specific to this repo
- Prefer the existing layered architecture: route handlers → managers → controllers (domain models) / modules.
- Use the `createRoute()` utility for all route handler files.
- Use the `apiResponse` helpers for all HTTP responses.
- Throw or propagate repository-specific custom errors (from `src/Errors/*`) rather than returning bare objects.
- Keep TypeScript types in `src/types/*` and use Zod for runtime checks inside route handlers.
- **Avoid `else` statements** — prefer ternary operators, early returns, or other control flow patterns. Only use `else` if there is absolutely no other way.
- ES module syntax (`export default`, `import`) is used throughout. The `require()` calls in `server.ts` are for lazy loading but all modules use `export default`.
---
## Local dev / quick checks
- Start dev server: `npm run dev`
- Regenerate Prisma client: `npm run db:gen`
- Apply DB migrations locally: `npm run db:push`
- Docker dev utilities: `npm run utils:dev`
- Generate private keys: `npm run utils:gen_private_keys`
- Create admin role: `npm run utils:create_admin_role`
- Assign user role: `npm run utils:assign_user_role`
## When editing generated or infra files
If you need to change `generated/prisma/*` (rare), explain why in the PR and show commands used to regenerate.
---
## Documentation sync (IMPORTANT)
Whenever you add, remove, or modify API routes or permission nodes, you **must** update all three of the following files to keep them in sync:
1. `src/types/PermissionNodes.ts` — the single source of truth for all permission node definitions, categories, descriptions, and `usedIn` references.
2. `PERMISSIONS.md` — human-readable documentation of all permission nodes; must strictly reflect the data in `PermissionNodes.ts`.
3. `API_ROUTES.md` — comprehensive documentation of all API routes, including method, path, auth requirements, permissions, request/response examples.
Always verify that new routes have their required permissions listed in `PermissionNodes.ts`, that `PERMISSIONS.md` tables match the TS file exactly, and that `API_ROUTES.md` includes full documentation for every mounted route. Run through all three files at the end of any route or permission change to catch discrepancies.
---
## Field-level permission gating (`processObjectValuePerms`)
Some routes use `processObjectValuePerms` from `src/modules/permission-utils/processObjectPermissions.ts` to filter response objects on a per-field basis. When this pattern is used, every key of the response object becomes a permission node in the form `<scope>.<field>` (e.g., `unifi.site.wifi.read.passphrase`). Only fields whose corresponding permission the user holds are included in the response.
There is also `processObjectPermMap` in the same file, which returns a `Record<key, boolean>` indicating which fields the user has permission for (useful for UI gating).
**When documenting a route that uses field-level gating, you must:**
1. Note in `API_ROUTES.md` that the route uses field-level gating, explain the behaviour, and list every `<scope>.<field>` permission node in a collapsible table.
2. Add a `unifi.site.wifi.read`-style parent permission node in `PermissionNodes.ts` with a `fieldLevelPermissions` array listing every `<scope>.<field>` node.
3. Add matching rows/notes to `PERMISSIONS.md` including the full list of field-level nodes.
**Current routes using field-level gating:**
- `GET /v1/unifi/site/:id/wifi` — scope `unifi.site.wifi.read`, gates every field on the `WlanConf` object.
---
If anything here is unclear or you'd like more examples (e.g., a walk-through adding a route handler + manager + controller), tell me which area to expand and I'll iterate.
+63
View File
@@ -0,0 +1,63 @@
name: Build and Publish
on:
release:
types: [created]
jobs:
build:
name: Build
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push the Docker image
uses: docker/build-push-action@v6
with:
push: true
tags: |
ghcr.io/project-optima/ttscm-api:latest
ghcr.io/project-optima/ttscm-api:${{ github.event.release.tag_name }}
deploy:
name: Deploy
needs: [build]
runs-on: ubuntu-latest
steps:
- name: Set the Kubernetes context
uses: azure/k8s-set-context@v2
with:
method: kubeconfig
kubeconfig: ${{ secrets.KUBECONFIG }}
- name: Checkout source code
uses: actions/checkout@v4
- name: Lint Kubernetes manifests
uses: azure/k8s-lint@v1
with:
lintType: dryrun
manifests: |
kubernetes/deployment.yaml
namespace: optima
- name: Deploy to the Kubernetes cluster
uses: azure/k8s-deploy@v5
with:
namespace: optima
force: true
skip-tls-verify: true
manifests: |
kubernetes/deployment.yaml
images: |
ghcr.io/project-optima/ttscm-api:${{ github.event.release.tag_name }}