Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 27 additions & 9 deletions .claude/agents/full-stack-engineer.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,12 @@ apps/
└── routes/ # API endpoints

libs/
└── postgres-prisma
├── prisma
├── schema # Prisma schemas
└── [feature]/
├── package.json # Module metadata and scripts
Comment thread
coderabbitai[bot] marked this conversation as resolved.
├── tsconfig.json # TypeScript configuration
├── prisma/ # Prisma schema (optional)
└── src/
├── config.ts # Module configuration exports
├── index.ts # Business logic exports
Expand Down Expand Up @@ -145,7 +147,7 @@ The web and API applications use explicit imports to register modules, enabling

To avoid circular dependencies during Prisma client generation, module configuration MUST be separated from business logic exports:

- **`src/config.ts`** - Module configuration exports (pageRoutes, apiRoutes, prismaSchemas, assets)
- **`src/config.ts`** - Module configuration exports (pageRoutes, apiRoutes, assets)
- **`src/index.ts`** - Business logic exports only (services, utilities, types)

**Module configuration structure:**
Expand All @@ -165,9 +167,6 @@ export const moduleRoot = __dirname; // Commonly needed for path resolution
// Optional - only if module has API routes
export const apiRoutes = { path: path.join(__dirname, "routes") };

// Optional - only if module has Prisma schema
export const prismaSchemas = path.join(__dirname, "../prisma");

// Optional - custom exports for specific needs (e.g., file upload routes)
export const fileUploadRoutes = ["/manual-upload", "/non-strategic-upload"];
```
Expand Down Expand Up @@ -215,14 +214,33 @@ const baseConfig = createBaseViteConfig([
// apps/api/src/app.ts
import { apiRoutes as myFeatureRoutes } from "@hmcts/my-feature/config";
app.use(await createSimpleRouter(myFeatureRoutes));

// apps/postgres/src/schema-discovery.ts
import { prismaSchemas as myFeatureSchemas } from "@hmcts/my-feature/config";
const schemaPaths = [myFeatureSchemas, /* other schemas */];
```

**NOTE**: By default all pages and routes are mounted at root level. To namespace routes, create subdirectories under `pages/`. E.g. `pages/admin/` for `/admin/*` routes.

### Database Schema Management

All Prisma schemas are centralized in `libs/postgres-prisma/prisma/schema/` with one file per feature domain.

**Schema Organization:**
```
libs/postgres-prisma/
├── prisma.config.ts # Points to prisma/schema directory
└── prisma/
└── schema/ # All .prisma files live here
├── base.prisma # Datasource and generator config
├── audit-log.prisma # Audit log models
├── location.prisma # Location models
└── ... # One file per domain
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

**Adding a New Schema:**
1. Create `libs/postgres-prisma/prisma/schema/{feature-name}.prisma` (kebab-case)
2. Run `yarn db:generate` to update the Prisma client
3. All models are available via `import { prisma } from "@hmcts/postgres-prisma"`

**Never create `prisma/` directories in feature modules** - all schemas are centralized in `@hmcts/postgres-prisma`.

### Implementation Patterns

#### Full-Stack Feature Pattern
Expand Down
236 changes: 219 additions & 17 deletions .claude/rules/backend.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
paths: libs/**/src/routes/**/*.ts, apps/api/**/*.ts, libs/**/prisma/*.prisma
paths: [libs/**/src/**/*.ts, apps/api/**/*.ts, libs/postgres-prisma/prisma/schema/*.prisma]
---

# Backend Development Rules
Expand Down Expand Up @@ -134,7 +134,9 @@ export async function findUserWithCases(userId: string) {
}
```

### Prisma Schema Conventions
### Prisma Best Practices

#### 1. Schema conventions

```prisma
model User {
Expand All @@ -158,31 +160,233 @@ model User {
- Field names: camelCase in code, snake_case in DB via `@map`
- Add `@@index` for frequently queried fields

### Query Optimization
#### 2. Use `select` Instead of `include`

`select` is more efficient and explicit about what data you need:

```typescript
// Select only required fields
const users = await prisma.user.findMany({
// ❌ BAD: include fetches ALL fields from related models
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
cases: true,
subscriptions: true
}
});

// ✅ GOOD: select only the fields you need
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
firstName: true
firstName: true,
cases: {
select: {
id: true,
title: true,
status: true
}
},
subscriptions: {
select: {
id: true,
searchType: true
}
}
}
});
```

#### 3. Filter, Sort, and Search at the Database Level

**NEVER** fetch all records and filter in JavaScript. Use Prisma's `where`, `orderBy`, and search operators:

```typescript
// ❌ BAD: Filtering in JavaScript after fetching all records
const allUsers = await prisma.user.findMany();
const activeUsers = allUsers.filter(u => u.status === "ACTIVE");
const sorted = activeUsers.sort((a, b) => a.name.localeCompare(b.name));

// Pagination
const cases = await prisma.case.findMany({
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { createdAt: "desc" }
// ✅ GOOD: Filter and sort at database level
const users = await prisma.user.findMany({
where: {
status: "ACTIVE",
deletedAt: null,
name: {
contains: searchTerm,
mode: "insensitive"
}
},
orderBy: {
name: "asc"
}
});
```

#### 4. Avoid N+1 Queries

```typescript
// ❌ BAD: N+1 query - one query for locations, then one per location for regions
const locations = await prisma.location.findMany();
for (const location of locations) {
location.regions = await prisma.locationRegion.findMany({
where: { locationId: location.locationId }
});
}

// Avoid N+1 with includes
const usersWithCases = await prisma.user.findMany({
include: { cases: true }
// ✅ GOOD: Single query with nested select
const locations = await prisma.location.findMany({
select: {
locationId: true,
name: true,
welshName: true,
locationRegions: {
select: {
region: {
select: {
regionId: true,
name: true,
welshName: true
}
}
}
}
}
});
```

#### 5. Pagination Pattern

Use for list endpoints that return collections to the client:

```typescript
export async function getLocationsPaginated(page: number, pageSize: number) {
const [locations, total] = await prisma.$transaction([
prisma.location.findMany({
skip: (page - 1) * pageSize,
take: pageSize,
where: { deletedAt: null },
orderBy: { name: "asc" },
select: {
locationId: true,
name: true,
welshName: true
}
}),
prisma.location.count({
where: { deletedAt: null }
})
]);

return {
data: locations,
pagination: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize)
}
};
}
```

#### 6. Use Enums in Prisma Schema

Define enums for fields with fixed set of values:

```prisma
// ✅ GOOD: Using enum for searchType
enum SearchType {
CASE_ID
CASE_URN
LOCATION_ID
}

model Subscription {
id String @id @default(cuid())
searchType SearchType @map("search_type")
searchValue String @map("search_value")

@@map("subscription")
}
```

```typescript
// TypeScript usage with enum
import { SearchType } from "@prisma/client";

const subscription = await prisma.subscription.create({
data: {
searchType: SearchType.LOCATION_ID,
searchValue: locationId.toString()
}
});
```

**Don't use string literals for fields that should be enums:**

```typescript
// ❌ BAD: Magic strings
searchType: "LOCATION_ID"

// ✅ GOOD: Enum
searchType: SearchType.LOCATION_ID
```

#### 7. Combine Filtering with Conditional Logic

Build dynamic `where` clauses for optional filters:

```typescript
export async function searchLocations(options: {
search?: string;
language: "en" | "cy";
regions?: number[];
subJurisdictions?: number[];
}) {
const searchField = options.language === "cy" ? "welshName" : "name";

return prisma.location.findMany({
where: {
deletedAt: null,
...(options.search && {
[searchField]: {
contains: options.search,
mode: "insensitive"
}
}),
...(options.regions && options.regions.length > 0 && {
locationRegions: {
some: {
regionId: {
in: options.regions
}
}
}
}),
...(options.subJurisdictions && options.subJurisdictions.length > 0 && {
locationSubJurisdictions: {
some: {
subJurisdictionId: {
in: options.subJurisdictions
}
}
}
})
},
orderBy: {
[searchField]: "asc"
},
select: {
locationId: true,
name: true,
welshName: true
}
});
}
```

## Transaction Management

Use transactions for operations that must succeed or fail together:
Expand Down Expand Up @@ -415,8 +619,6 @@ res.status(400).json({

```
libs/my-module/
├── prisma/
│ └── schema.prisma # Database schema
└── src/
├── config.ts # Module configuration (apiRoutes, etc.)
├── index.ts # Business logic exports
Expand Down Expand Up @@ -495,7 +697,7 @@ describe("POST /api/users", () => {

### Database Anti-Patterns

- ❌ N+1 queries (use `include` or batch queries)
- ❌ N+1 queries (use nested `select` or batch queries)
- ❌ Selecting all fields when only a few needed
- ❌ Missing indexes on frequently queried fields
- ❌ Raw SQL when Prisma can handle it
Expand Down
5 changes: 0 additions & 5 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,6 @@ jobs:
- name: Wait for PostgreSQL service to be ready
run: timeout 30 bash -c 'until pg_isready -h localhost -p 5432 -U hmcts; do sleep 1; done'

- name: Collate Prisma schema
run: |
echo "Collating Prisma schema from all modules..."
yarn tsx libs/postgres-prisma/src/collate-schema.ts

- name: Generate Prisma client
run: yarn db:generate

Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ lcov.info
.mcp.env
.claude/claude.env
.claude/analytics
**/.claude/hooks/run.log

/storage/temp
/apps/postgres/.claude
Expand All @@ -68,4 +69,4 @@ lcov.info
**/Chart.lock
**/charts/*.tgz
values.preview.yaml
libs/postgres-prisma/generated/
libs/postgres-prisma/prisma/generated/
Loading
Loading