Instruction file imported from bayesimpact/bayes-platform (
.cursor/rules/api.mdc). Copyright stays with the author.
Cursor Agent Rules for CaseAI Connect - API
NestJS Dependency Injection
Import Type Rule
When NestJS requires runtime access to classes for dependency injection (services, controllers, guards, etc.), you MUST use regular imports, not type-only imports.
Rule: If you get a NestJS DI error about a class being undefined at runtime, it means you used import type instead of import.
Solution:
- Change
import type { MyService } from './my.service'toimport { MyService } from './my.service' - Add a biome-ignore comment:
// biome-ignore lint/style/useImportType: Required at runtime for NestJS DI
When to use:
- Services injected via
@InjectRepository(),@Inject(), or constructor injection - Controllers, Guards, Interceptors, Pipes
- Any class that needs to be available at runtime for NestJS DI
When type-only imports are OK:
- DTOs, interfaces, types
- Return types, parameter types
- Anything not used for DI
Example:
// ❌ Wrong - will cause DI error
import type { UsersService } from "@/users/users.service"
// ✅ Correct
// biome-ignore lint/style/useImportType: Required at runtime for NestJS DI
import { UsersService } from "@/users/users.service"
DTO Organization
Domain-Based DTO Location
Rule: DTOs (Data Transfer Objects) MUST be co-located with their domain logic in packages/api-contracts/src/{domain}/ as a single consolidated file per domain.
Structure:
- All DTOs for a domain MUST be consolidated into a single file:
packages/api-contracts/src/{domain}/{domain}.dto.ts(e.g.,packages/api-contracts/src/projects/projects.dto.ts,packages/api-contracts/src/me/me.dto.ts) - DO NOT create separate files for each DTO (e.g.,
create-project.dto.ts,list-projects.dto.ts, etc.) - All DTOs are exported from
packages/api-contracts/src/index.tsfor consumption by both API and frontend - Controllers and routes import DTOs from
@caseai-connect/api-contracts
Why this pattern?
- Reduced file proliferation: Avoids having many small DTO files that are hard to navigate
- Better organization: All related DTOs for a domain are in one place
- Easier maintenance: Related types are easier to find and update together
- Cohesion: DTOs are tightly coupled to domain logic and API contracts
- Single source of truth: DTOs live in a shared package accessible to all consumers
- No circular dependencies: API-contracts is a separate package that API and frontend both depend on
- Frontend integration: Frontend imports directly from
@caseai-connect/api-contracts
Import Rules:
- Within API: Controllers and routes import DTOs from
@caseai-connect/api-contracts - For frontend: Frontend imports from
@caseai-connect/api-contracts
Example - Creating DTOs:
- Create consolidated DTO file (
packages/api-contracts/src/projects/projects.dto.ts):
// Project entity DTO
export type ProjectDto = {
id: string
name: string
organizationId: string
createdAt: number
updatedAt: number
}
// Create project DTOs
export type CreateProjectRequestDto = {
name: string
organizationId: string
}
export type CreateProjectResponseDto = {
id: string
name: string
organizationId: string
}
// List projects DTOs
export type ListProjectsResponseDto = {
projects: ProjectDto[]
}
// Update project DTOs
export type UpdateProjectRequestDto = {
name: string
}
export type UpdateProjectResponseDto = {
id: string
name: string
organizationId: string
}
- Export from central index (
packages/api-contracts/src/index.ts):
export type {
CreateProjectRequestDto,
CreateProjectResponseDto,
ListProjectsResponseDto,
ProjectDto,
UpdateProjectRequestDto,
UpdateProjectResponseDto,
} from "./projects/projects.dto"
- Import in routes (
packages/api-contracts/src/projects/projects.routes.ts):
import type { CreateProjectRequestDto, CreateProjectResponseDto, ListProjectsResponseDto, UpdateProjectRequestDto, UpdateProjectResponseDto } from "./projects.dto"
import type { RequestPayload, ResponseData } from "../generic"
- Import in controllers (
apps/api/src/projects/projects.controller.ts):
import type { CreateProjectRequestDto } from "@caseai-connect/api-contracts"
Controller Guidelines
Route Definition Strategy
All NestJS controllers MUST use the defineRoute strategy for type-safe route definitions. This ensures consistency and enables type inference across the codebase.
Route key naming (CRUD)
Rule: Standard CRUD keys on *Routes objects MUST use these exact names (same as frontend SPI methods):
| Operation | Route key | HTTP |
|---|---|---|
| List collection | getAll |
GET |
| Get single resource | getOne |
GET |
| Create | createOne |
POST |
| Update | updateOne |
PATCH |
| Delete | deleteOne |
DELETE |
Forbidden for CRUD: domain-prefixed keys like listOrganizations, createOrganization, updateProject. Prefer OrganizationsRoutes.getAll / createOne, not listOrganizations / createOrganization.
Non-CRUD / singleton routes may use descriptive keys (getMe, acceptTerms, listPendingMine). When in doubt for resource CRUD, use the table above. Pattern: packages/api-contracts/src/agents/agents.routes.ts, projects/projects.routes.ts.
Step 1: Create a Routes File
Create a *.routes.ts file in your domain directory (e.g., organizations.routes.ts, projects.routes.ts).
For list / get-one / delete:
import type { ResponseData, SuccessResponseDTO } from "../generic"
import { defineRoute } from "../helpers"
import type { MyDto } from "./my.dto"
export const MyRoutes = {
getAll: defineRoute<ResponseData<MyDto[]>>({
method: "get",
path: "my/path", // No leading slash - it gets normalized automatically
}),
getOne: defineRoute<ResponseData<MyDto>>({
method: "get",
path: "my/path/:myId",
}),
deleteOne: defineRoute<ResponseData<SuccessResponseDTO>>({
method: "delete",
path: "my/path/:myId",
}),
}
For create / update:
import type { RequestPayload, ResponseData, SuccessResponseDTO } from "../generic"
import { defineRoute } from "../helpers"
import type { MyDto, UpdateMyRequestDto } from "./my.dto"
export const MyRoutes = {
createOne: defineRoute<ResponseData<MyDto>, RequestPayload<Pick<MyDto, "name">>>({
method: "post",
path: "my/path",
}),
updateOne: defineRoute<
ResponseData<SuccessResponseDTO>,
RequestPayload<UpdateMyRequestDto>
>({
method: "patch",
path: "my/path/:myId",
}),
}
Important:
- Path should NOT start with
/-defineRoutenormalizes it automatically - GET/DELETE routes only need
ResponseData<T>type parameter - POST/PUT/PATCH routes need both
ResponseData<TResponse>andRequestPayload<TRequest>type parameters
Step 2: Use Routes in Controller
Import the routes in your controller and use them in decorators:
import { Controller, Get } from "@nestjs/common"
import { MyRoutes } from "./my.routes"
@Controller() // No prefix - paths come from route definitions
export class MyController {
@Get(MyRoutes.getAll.path) // Use route path directly
async list(): Promise<typeof MyRoutes.getAll.response> {
// Always wrap response in { data: ... }
return {
data: {
// ... your response data
},
}
}
}
Controller Rules:
- Use
@Controller()with no prefix - paths are defined in routes files - Use
Routes.routeName.pathdirectly in method decorators (@Get,@Post, etc.) - Use
typeof Routes.routeName.responsefor return type annotation - Always wrap response objects in
{ data: ... }to matchResponseData<T>structure - For POST/PUT/PATCH, request bodies should match
RequestPayload<T>(wrapped in{ payload: ... }) - Controller method names may be descriptive (
listOrganizations); route keys staygetAll/createOne/ etc.
Context Resolver Architecture (Required)
Controllers and guards MUST follow the context resolver pattern for resource loading:
- Use
ResourceContextGuardto resolve request context (organization,project,projectMembership,agent, etc.) - Declare required context at controller or method level with
@RequireContext(...) - Add route-specific context with
@AddContext(...)when only some handlers need extra resources - Keep domain guards (e.g.
ProjectsGuard,ProjectMembershipsGuard,AgentGuard) focused on policy evaluation only - Do not use cascading resource-loading guards like
UserGuard -> OrganizationGuard -> ProjectsGuardto populate request resources - Do not fetch domain resources in policy guards when a context resolver exists
Module wiring rules:
- Register
ResourceContextGuardin module providers - Register each resolver used by the module (e.g.,
OrganizationContextResolver,ProjectContextResolver,ProjectMembershipContextResolver) - Keep
JwtAuthGuardandUserGuardfor authentication/user hydration; use resolvers for resource hydration
Example pattern:
@UseGuards(JwtAuthGuard, UserGuard, ResourceContextGuard, ProjectsGuard)
@RequireContext("organization", "project")
@Controller()
export class ExampleController {
@Delete("organizations/:organizationId/projects/:projectId/memberships/:membershipId")
@AddContext("projectMembership")
@CheckPolicy((policy) => policy.canDelete())
async remove(@Req() request: EndpointRequestWithProjectMembership) {
return { data: { success: true } }
}
}
Step 3: Export Routes
Export your routes in packages/api-contracts/src/api-routes/index.ts:
import { MyRoutes } from "../my/my.routes"
import { OtherRoutes } from "../other/other.routes"
export default {
MyRoutes,
OtherRoutes,
// ... other routes
}
Then export from the main index (packages/api-contracts/src/index.ts):
export { default as ApiRoutes } from "./api-routes/index"
export { MyRoutes } from "./my/my.routes"
export { OtherRoutes } from "./other/other.routes"
Why this pattern?
- Type safety: Route paths and response types are centralized and type-checked
- Single source of truth: Routes are defined once and reused
- Client generation: Routes can be used for generating type-safe API clients
- Consistency: All controllers follow the same pattern
Example - Complete Flow:
- Create routes file (
packages/api-contracts/src/me/me.routes.ts):
import type { MeResponseDto } from "./me.dto"
import type { ResponseData } from "../generic"
import { defineRoute } from "../helpers"
export const MeRoutes = {
getMe: defineRoute<ResponseData<MeResponseDto>>({
path: "me",
method: "get",
}),
}
- Use in controller (
apps/api/src/me/me.controller.ts):
import { Controller, Get, Req } from "@nestjs/common"
import { MeRoutes } from "@caseai-connect/api-contracts"
@Controller()
export class MeController {
@Get(MeRoutes.getMe.path)
async getMe(@Req() request): Promise<typeof MeRoutes.getMe.response> {
return {
data: {
user: { ... },
},
}
}
}
- Export in index (
packages/api-contracts/src/api-routes/index.ts):
import { MeRoutes } from "../me/me.routes"
export default {
MeRoutes,
}
And in the main index (packages/api-contracts/src/index.ts):
export { default as ApiRoutes } from "./api-routes/index"
export { MeRoutes } from "./me/me.routes"
Testing Requirements
E2E Tests for Controllers
Rule: Controller behavior MUST be tested via e2e tests that make real HTTP requests through a NestJS application. Do NOT test controllers by calling methods directly (e.g., controller.getAll(mockRequest)).
File Organization: E2e tests live in an e2e-tests/ subdirectory within the domain folder:
apps/api/src/domains/{domain}/
e2e-tests/
auth.spec.ts # Authorization tests for ALL routes in this domain
create-{resource}.spec.ts # Functional tests for create
list-{resources}.spec.ts # Functional tests for list
delete-{resource}.spec.ts # Functional tests for delete
update-{resource}.spec.ts # Functional tests for update
...
Two categories of e2e tests:
- Auth spec (
auth.spec.ts) — Tests authorization for every route: no token, no org ID, not a member, wrong role, allowed roles. UsescreateContextForRole(role)that accepts a role parameter. - Functional specs (one file per action) — Tests happy path and business logic only. Assumes the user is an owner (default). Uses
createContext()with no role parameter.
E2E Test Infrastructure
Every e2e test file follows this structure:
import { DomainRoutes } from "@caseai-connect/api-contracts"
import type { INestApplication } from "@nestjs/common"
import type { App } from "supertest/types"
import { clearTestDatabase } from "@/common/test/test-database"
import {
setupTransactionalTestDatabase,
teardownTestDatabase,
} from "@/common/test/test-transaction-manager"
import { removeNullish } from "@/common/utils/remove-nullish"
import { createOrganizationWith... } from "@/domains/organizations/organization.factory"
import { setupUserGuardForTesting } from "../../../../test/e2e.helpers"
import { expectResponse, type Requester, testRequester } from "../../../../test/request"
import { DomainModule } from "../domain.module"
describe("Domain - actionName", () => {
// 1. INFRASTRUCTURE VARIABLES
let app: INestApplication<App>
let request: Requester
let setup: Awaited<ReturnType<typeof setupTransactionalTestDatabase>>
let repositories: ReturnType<
Awaited<ReturnType<typeof setupTransactionalTestDatabase>>["getAllRepositories"]
>
// 2. MUTABLE STATE (set by createContext, read by subject)
let organizationId: string
let projectId: string
let accessToken: string | undefined = "token"
let auth0Id = "auth0|123"
// 3. LIFECYCLE HOOKS
beforeAll(async () => {
setup = await setupTransactionalTestDatabase({
additionalImports: [DomainModule],
applyOverrides: (moduleBuilder) => setupUserGuardForTesting(moduleBuilder, () => auth0Id),
})
repositories = setup.getAllRepositories()
app = setup.module.createNestApplication()
await app.init()
request = testRequester(app)
})
beforeEach(async () => {
await clearTestDatabase(setup.dataSource)
accessToken = "token"
auth0Id = "auth0|123"
})
afterAll(async () => {
await teardownTestDatabase(setup)
app.close()
})
// 4. CONTEXT HELPER — sets up test data and assigns mutable state
const createContext = async () => {
const { user, organization, project } = await createOrganizationWithProject(repositories)
organizationId = organization.id
projectId = project.id
auth0Id = user.auth0Id
return { organization, project }
}
// 5. SUBJECT — the HTTP request under test
const subject = async () =>
request({
route: DomainRoutes.someAction,
pathParams: removeNullish({ organizationId, projectId }),
token: accessToken,
})
// 6. TESTS
it("should do the expected thing", async () => {
await createContext()
const response = await subject()
expectResponse(response, 200)
// assert on response.body.data
})
})
Key Patterns
createContext()(functional specs): Sets up data with default owner role. No role parameter. Returns created entities for further use in the test.createContextForRole(role)(auth specs): Accepts a role parameter for testing different authorization levels.subject(): Wraps the HTTP request. UsesDomainRoutes.actionNamefor the route,removeNullish(...)for path params, andaccessTokenfor the bearer token. For POST/PATCH, accepts apayloadparameter.expectResponse(response, statusCode, errorMessage?): Asserts status code and optionally the error message.- Response assertions: Access data via
response.body.data(matchingResponseData<T>wrapper). - Database assertions: Use
repositories.{entity}Repository.findOne(...)to verify side effects (e.g., entity deleted, entity created). - Auth specs use nullable state: Variables like
organizationId,projectId,accessTokenare typed asstring | nulland reset inbeforeEachto default dummy values. This allows tests to set them tonullto trigger validation errors. - Functional specs use non-nullable state: Variables are typed as
stringsincecreateContext()always sets real values.
Service Tests
Rule: Every service MUST have a corresponding *.service.spec.ts file testing service methods directly (without HTTP layer).
Note: Use the existing test utilities (setupTransactionalTestDatabase) from @/common/test/test-transaction-manager for consistent test setup.
Connect Scope Pattern for Services
Rule: For connect-scoped entities, services MUST accept a connectScope: RequiredConnectScope argument and delegate scoping to ConnectRepository methods.
Required Pattern:
- Initialize a
ConnectRepository<Entity>in the service constructor - Accept
connectScope: RequiredConnectScopein service methods that read/write scoped entities - Pass
connectScopedirectly toConnectRepositorymethods (createAndSave,getMany,getOneById,deleteOneById,find) - Keep service logic focused on business behavior (validation, sorting, error mapping), not SQL scope plumbing
Forbidden Pattern:
- Do not manually re-implement connect scoping in service
whereclauses when aConnectRepositoryis available - Do not duplicate
organizationId/projectIdfiltering logic in each service method
Why this pattern?
- Centralizes scope enforcement in one abstraction
- Reduces copy-paste query conditions and security drift
- Keeps services readable and consistent across domains
Example:
import { ConnectRepository } from "@/common/entities/connect-repository"
import type { RequiredConnectScope } from "@/common/entities/connect-required-fields"
export class DocumentsService {
constructor(@InjectRepository(Document) documentRepository: Repository<Document>) {
this.documentConnectRepository = new ConnectRepository(documentRepository, "documents")
}
private readonly documentConnectRepository: ConnectRepository<Document>
async listDocuments(connectScope: RequiredConnectScope): Promise<Document[]> {
return this.documentConnectRepository.getMany(connectScope)
}
}
Always Use Factory Functions for Test Data
Rule: When writing tests, you MUST always use fishery factory functions to create test data instead of manually constructing objects or using new constructors.
Requirements:
- Use factory functions (e.g.,
userFactory,organizationFactory,projectFactory,userMembershipFactory) to create test entities - Never manually create entities using
new EntityName()or object literals - Use factory methods like
.owner(),.admin(),.member()when available for role-specific factories - Use
.transient()to pass required relationships (e.g.,projectFactory.transient({ organization })) - Use
.params()to override specific properties when needed
Why this rule?
- Consistency: Factories ensure all test data follows the same patterns
- Maintainability: Changes to entity structure only need to be updated in factories
- Realistic data: Factories generate more realistic test data with proper relationships
- Less boilerplate: Factories handle default values and required fields automatically
Examples:
// ❌ Wrong - Manual object creation
const user = new User()
user.id = randomUUID()
user.email = "test@example.com"
// ... many more lines
// ✅ Correct - Using factory
const user = userFactory.build({ email: "test@example.com" })
// ❌ Wrong - Manual membership creation
const membership = new UserMembership()
membership.role = "owner"
membership.userId = user.id
membership.organizationId = org.id
// ✅ Correct - Using factory with transient params
const membership = userMembershipFactory
.owner()
.transient({ user, organization })
.build()
// ❌ Wrong - Manual project creation
const project = {
id: randomUUID(),
name: "Test Project",
organizationId: org.id,
// ... missing required fields
}
// ✅ Correct - Using factory
const project = projectFactory.transient({ organization: org }).build()
Available Factories:
userFactory- For creating User entitiesorganizationFactory- For creating Organization entitiesprojectFactory- For creating Project entities (requires organization transient)userMembershipFactory- For creating UserMembership entities (requires user and organization transients, has.owner(),.admin(),.member()methods)agentFactory- For creating agent entitiesagentSessionFactory- For creating agentSession entities- Additional factories may exist for other entities
Use Organization Factory Helpers in Tests
Rule: When writing tests that need to create organizations with users and memberships, you MUST use the helper functions from organization.factory.ts instead of manually creating and saving entities.
Available Helpers:
createOrganizationWithOwner(repositories, params?)- Creates and saves an organization, user, and membership (with owner role) in one callbuildOrganizationWithOwner(params?)- Builds (but doesn't save) an organization, user, and membership- Additional helpers may exist for creating organizations with projects, Agents, etc.
Requirements:
- Use
createOrganizationWithOwnerwhen you need an organization with a user and membership already saved to the database - Pass repositories as the first parameter:
{ userRepository, organizationRepository, membershipRepository } - Optionally pass params to customize the entities:
{ user: { email: "..." }, organization: { name: "..." }, membership: { role: "..." } } - Extract the returned entities using destructuring:
const { user, organization, membership } = await createOrganizationWithOwner(...) - Create a
mainRepositoriesobject inbeforeEachto avoid repeating the repositories parameter in every test
Why this rule?
- Reduces boilerplate: Eliminates repetitive code for creating users, organizations, and memberships
- Consistency: Ensures all tests use the same pattern for creating test data
- Maintainability: Changes to entity relationships only need to be updated in the helper
- Readability: Tests are more concise and focus on what they're testing, not setup
Examples:
// ✅ Correct - Using helper with mainRepositories
let mainRepositories: {
membershipRepository: Repository<UserMembership>
organizationRepository: Repository<Organization>
userRepository: Repository<User>
}
beforeEach(async () => {
await setup.startTransaction()
membershipRepository = setup.getRepository(UserMembership)
organizationRepository = setup.getRepository(Organization)
userRepository = setup.getRepository(User)
mainRepositories = {
membershipRepository,
organizationRepository,
userRepository,
}
})
it("should return membership when user is a member", async () => {
const { user, organization } = await createOrganizationWithOwner(mainRepositories, {
user: { email: "member@example.com" },
organization: { name: "Test Org" },
})
// ... test code
})
// ❌ Wrong - Manually creating and saving entities
it("should return membership when user is a member", async () => {
const user = userFactory.build({ email: "member@example.com" })
const savedUser = await userRepository.save(user)
const organization = organizationFactory.build({ name: "Test Org" })
const savedOrganization = await organizationRepository.save(organization)
const membership = userMembershipFactory
.transient({ user: savedUser, organization: savedOrganization })
.build()
await membershipRepository.save(membership)
// ... test code
})
When to use helpers vs factories:
- Use helpers (
createOrganizationWithOwner) when you need entities saved to the database and want to reduce boilerplate - Use factories (
organizationFactory,userFactory) when you need more control over the creation process or when building entities that don't need to be saved immediately
Organization-Based Resource Authorization
Mandatory Authorization Checks
Rule: When a resource belongs to an organization (has an organizationId or relationship to Organization), you MUST verify that the user performing operations on it:
- Is a member of the organization
- Has the appropriate role for the operation
Implementation Pattern:
- Create verification methods in the service (e.g.,
verifyUserCanCreateProject,verifyUserCanUpdateProject) - Check membership first, then check role
- Throw
ForbiddenExceptionwith clear error messages if authorization fails - Use the
UserMembershiprepository to check membership and role
Role Requirements:
- If unsure about which role is required for an operation, ASK THE USER before implementing
- Common patterns:
owneroradmin: For create, update, delete operationsowner,admin, ormember: For read operations (may vary by resource)owneronly: For sensitive operations like deletion or role changes
Example Implementation:
async verifyUserCanCreateProject(userId: string, organizationId: string): Promise<void> {
const membership = await this.membershipRepository.findOne({
where: { userId, organizationId },
})
if (!membership) {
throw new ForbiddenException(`User does not have access to organization ${organizationId}`)
}
const allowedRoles: MembershipRole[] = ["owner", "admin"]
if (!allowedRoles.includes(membership.role)) {
throw new ForbiddenException(
`User must be an owner or admin of organization ${organizationId} to create projects`,
)
}
}
When to Apply:
- All CRUD operations on organization-scoped resources
- Any operation that modifies organization data
- Operations that affect other users in the organization
Important: Never skip authorization checks, even for "simple" operations. Security must be enforced at the service layer.
TypeORM Database Migrations
Synchronize Option - NEVER Enable in Production
Rule: The synchronize option in TypeORM MUST always be set to false in all environments (production, development, and test).
Why:
synchronize: trueautomatically syncs your entity schema to the database on every application start- This can cause data loss if entities are modified (e.g., removing a column drops the column and all its data)
- There is no version control, no rollback capability, and no way to review changes
- It's unsafe for production and should not be used in development either
Current Configuration:
synchronize: falseis correctly set inapps/api/src/config/typeorm.ts- This configuration MUST remain
false- never change it totrue
Migration Workflow
Rule: All database schema changes MUST be made through TypeORM migrations, never by manually editing the database or enabling synchronize.
Workflow for Schema Changes:
-
Modify Entity Files: Update your TypeORM entity classes (
*.entity.ts) with the desired schema changes -
Generate Migration: Use the migration generation command to create a migration file:
npm run migration:generate src/migrations/YourMigrationNameThis compares your entities to the current database schema and generates the necessary SQL changes.
-
Review Generated Migration: Always review the generated migration file to ensure:
- The changes are correct
- No unintended data loss will occur
- Indexes, foreign keys, and constraints are properly handled
- The
down()method correctly reverses the migration
-
Test Migration: Test the migration on a development database:
npm run migration:run -
Revert if Needed: If something goes wrong, you can revert:
npm run migration:revert
Available Migration Commands:
npm run migration:generate- Generate migration from entity changesnpm run migration:create- Create empty migration (use only when explicitly requested)npm run migration:run- Run pending migrationsnpm run migration:revert- Rollback last migrationnpm run migration:show- Show migration status
FORBIDDEN: Manual Migration Creation
CRITICAL RULE: You MUST NEVER manually create a migration file unless the user explicitly tells you to do so.
MANDATORY DEFAULT: For schema changes, always use npm run migration:generate.
What is Forbidden:
- ❌ Creating migration files manually (e.g.,
CreateTableX.ts) - ❌ Writing migration SQL code by hand
- ❌ Copying and modifying existing migration files
- ❌ Creating empty migrations with
migration:createwithout explicit user request
What You MUST Do Instead:
- ✅ Always use
migration:generateto create migrations from entity changes - ✅ Modify entity files first, then generate the migration
- ✅ Only create migrations manually if the user explicitly requests it (e.g., "create a manual migration for X")
- ✅ Do not use
migration:createfor normal schema work
Why This Rule Exists:
- Auto-generated migrations ensure consistency between entities and database schema
- Manual migrations can get out of sync with entity definitions
- Generated migrations are more reliable and less error-prone
- The migration generation process validates the changes before creating the migration
Exception: If the user explicitly says "create a manual migration" or "write a migration manually for X", then you may do so. Otherwise, always use migration:generate.
Migration Best Practices
- One Migration Per Feature: Create separate migrations for logically distinct schema changes
- Meaningful Names: Use descriptive migration names that explain what the migration does
- Always Implement
down(): Every migration must have a properdown()method for rollback - Test Rollback: Verify that
migration:revertworks correctly - Review Before Committing: Always review generated migrations before committing to version control
- Never Edit Existing Migrations: Once a migration has been run in production, never modify it. Create a new migration instead.
TypeORM Entity Guidelines
Column Naming Convention
Rule: All database column names MUST be in snake_case format.
Requirements:
- Multi-word column names MUST use snake_case (e.g.,
organization_id,created_at,updated_at,auth0_id) - Single-word column names are acceptable as-is (e.g.,
id,name,email,role) - When defining columns in entities, use the
nameoption in@Column()decorator to specify the snake_case column name
Implementation:
- Always specify the
nameproperty in@Column()decorator for multi-word properties - The TypeScript property name can be camelCase, but the database column name must be snake_case
- This applies to all column types:
@Column(),@CreateDateColumn(),@UpdateDateColumn(),@PrimaryGeneratedColumn(), etc.
Examples:
// ✅ Correct - snake_case column names
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string
@Column({ type: "varchar", unique: true, name: "auth0_id" })
auth0Id!: string
@CreateDateColumn({ name: "created_at" })
createdAt!: Date
@UpdateDateColumn({ name: "updated_at" })
updatedAt!: Date
// ✅ Correct - single word columns don't need explicit name
@Column({ type: "varchar" })
name!: string
@Column({ type: "varchar" })
email!: string
// ❌ Wrong - camelCase column names
@Column({ type: "uuid" })
organizationId!: string // This would create "organizationId" column in DB
@Column({ type: "varchar" })
auth0Id!: string // This would create "auth0Id" column in DB
Why this rule?
- Consistency with SQL naming conventions
- Better compatibility with database tools and SQL queries
- Aligns with PostgreSQL best practices
- Makes raw SQL queries more readable
Foreign Key Relationships in Entity Creation
Rule: When creating TypeORM entities with @ManyToOne or @OneToOne relationships, you MUST pass either the foreign key ID OR the entity object, but NOT both.
Why: TypeORM only needs one of these to establish the relationship:
- The foreign key ID (e.g.,
organizationId) - TypeORM sets the foreign key directly - The entity object (e.g.,
organization) - TypeORM extracts the ID from the entity
Passing both is redundant and unnecessary.
Examples:
// ✅ Correct - Pass only the foreign key ID
const project = this.projectRepository.create({
name: "My Project",
organizationId, // This is sufficient
})
// ✅ Correct - Pass only the entity object
const project = this.projectRepository.create({
name: "My Project",
organization, // TypeORM will extract the ID from this
})
// ❌ Wrong - Passing both is redundant
const project = this.projectRepository.create({
name: "My Project",
organizationId, // Redundant
organization, // Redundant - only one is needed
})
When to use each approach:
- Use the ID when you only have the ID (most common case)
- Use the entity when you've already fetched the entity for validation or other purposes, but prefer the ID for consistency
Best Practice: Prefer passing the foreign key ID for consistency and clarity, as it's the most common pattern in the codebase.
Boundary Check Baselines
apps/api/Makefile boundary checks rely on committed baseline files:
npm run check:circular↔apps/api/baselines/madge-circular.jsonnpm run check:deps↔apps/api/.dependency-cruiser-known-violations.json
When adding bidirectional TypeORM relations (@OneToMany ↔ @ManyToOne), new circular imports are expected and should be absorbed into the baselines:
cd apps/api
npm run check:circular:baseline
npm run check:deps:baseline
npm run check:boundaries
Commit both baseline files alongside the entity change.
Completion Criteria
Required Checks Before Marking Work as Completed
Before marking any work as completed, you MUST successfully run and verify the following commands:
-
Code Formatting and Linting:
npm run biome:check- Must pass without errors
- Fix any formatting or linting issues before completing
-
Type Checking:
npm run typecheck- Must pass without TypeScript errors
- Fix any type errors before completing
-
Tests:
npm run test- All tests must pass
- Fix any failing tests before completing
-
Boundary Checks (from
apps/api):npm run check:boundaries- Must pass without errors
- If new TypeORM bidirectional relations introduce circular imports, regenerate both baselines:
npm run check:circular:baseline(rewritesapps/api/baselines/madge-circular.json)npm run check:deps:baseline(rewritesapps/api/.dependency-cruiser-known-violations.json)
- Re-run
npm run check:boundariesand ensure it passes - Commit both baseline files alongside the entity change
Rule: Work is NOT considered complete until ALL four commands execute successfully with exit code 0. If any command fails, you must fix the issues and re-run the checks before marking the work as done.
Note: These checks ensure code quality, type safety, and that existing functionality continues to work correctly.