Skip to content

Commit f2aec3e

Browse files
committed
more general practices
1 parent 4a7568b commit f2aec3e

8 files changed

Lines changed: 2372 additions & 183 deletions

File tree

.claude/skills/general-practices/backend-endpoints/SKILL.md

Lines changed: 54 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,6 @@
11
---
22
name: backend-endpoints
3-
description: >-
4-
Guide for creating backend API endpoints in FinishLine following the
5-
Route → Controller → Service pattern with multi-tenant security.
6-
Use when creating new endpoints, adding API routes, implementing
7-
controllers or services, building backend request handlers, or
8-
when asked how the backend works.
3+
description: Guide for creating backend API endpoints in FinishLine following the Route → Controller → Service pattern with multi-tenant security. Use when creating new endpoints, adding API routes, implementing controllers or services, building backend request handlers, or when asked how the backend works.
94
---
105

116
# Backend Endpoints
@@ -63,15 +58,15 @@ If a service throws an exception, it bubbles up through the controller's `next(e
6358

6459
## File Locations
6560

66-
| Layer | Path | Naming |
67-
|-------|------|--------|
68-
| Entry point | `src/backend/index.ts` ||
69-
| Routes | `src/backend/src/routes/{feature}.routes.ts` | `{feature}Router` |
70-
| Controllers | `src/backend/src/controllers/{feature}.controllers.ts` | `{Feature}Controller` class |
71-
| Services | `src/backend/src/services/{feature}.services.ts` | `{Feature}Service` class |
72-
| Validation | `src/backend/src/utils/validation.utils.ts` | Shared validators |
73-
| Errors | `src/backend/src/utils/errors.utils.ts` | `HttpException` subclasses |
74-
| Express types | `src/backend/custom.d.ts` | `currentUser` and `organization` on `Request` |
61+
| Layer | Path | Naming |
62+
| ------------- | ------------------------------------------------------ | --------------------------------------------- |
63+
| Entry point | `src/backend/index.ts` | |
64+
| Routes | `src/backend/src/routes/{feature}.routes.ts` | `{feature}Router` |
65+
| Controllers | `src/backend/src/controllers/{feature}.controllers.ts` | `{Feature}Controller` class |
66+
| Services | `src/backend/src/services/{feature}.services.ts` | `{Feature}Service` class |
67+
| Validation | `src/backend/src/utils/validation.utils.ts` | Shared validators |
68+
| Errors | `src/backend/src/utils/errors.utils.ts` | `HttpException` subclasses |
69+
| Express types | `src/backend/custom.d.ts` | `currentUser` and `organization` on `Request` |
7570

7671
For query args and transformers, see the [query-args-and-transformers](../query-args-and-transformers/SKILL.md) skill.
7772

@@ -80,11 +75,13 @@ For query args and transformers, see the [query-args-and-transformers](../query-
8075
The full URL path for any endpoint is the **combination** of the base path registered in `src/backend/index.ts` and the route path in the router file. This is a very common source of confusion.
8176

8277
For example, if `index.ts` registers:
78+
8379
```typescript
8480
app.use('/calendar', calendarRouter);
8581
```
8682

8783
And the router defines:
84+
8885
```typescript
8986
calendarRouter.post('/shop/create', ...);
9087
```
@@ -103,13 +100,8 @@ Add validation rules using `express-validator` and the helpers from `validation.
103100
// src/backend/src/routes/calendar.routes.ts
104101
import express from 'express';
105102
import { body } from 'express-validator';
106-
import {
107-
nonEmptyString,
108-
isDate,
109-
validateInputs
110-
} from '../utils/validation.utils.js';
111-
import CalendarController
112-
from '../controllers/calendar.controllers.js';
103+
import { nonEmptyString, isDate, validateInputs } from '../utils/validation.utils.js';
104+
import CalendarController from '../controllers/calendar.controllers.js';
113105

114106
const calendarRouter = express.Router();
115107

@@ -135,6 +127,7 @@ export default calendarRouter;
135127
- URL params use `param()`, query strings use `query()`, body fields use `body()`.
136128

137129
**When to abstract validators:** Keep validation inline in the route by default. Only extract validators into `validation.utils.ts` when:
130+
138131
- The request body contains a **nested object** that is itself a known entity (e.g., a work package embedded inside a project create payload). Create a named validator array like `workPackageProposedChangesValidators`.
139132
- The **same set of validations** is repeated across multiple routes (e.g., `descriptionBulletsValidators` used in both work package and project routes).
140133

@@ -146,8 +139,7 @@ If creating a brand new feature router, register it in `src/backend/index.ts`:
146139

147140
```typescript
148141
// src/backend/index.ts
149-
import calendarRouter
150-
from './src/routes/calendar.routes.js';
142+
import calendarRouter from './src/routes/calendar.routes.js';
151143

152144
// ... after getUserAndOrganization middleware ...
153145
app.use('/calendar', calendarRouter);
@@ -162,30 +154,18 @@ Controllers follow a rigid structure: try/catch, extract request data, call serv
162154
```typescript
163155
// src/backend/src/controllers/calendar.controllers.ts
164156
import { NextFunction, Request, Response } from 'express';
165-
import CalendarService
166-
from '../services/calendar.services.js';
157+
import CalendarService from '../services/calendar.services.js';
167158

168159
export default class CalendarController {
169-
static async createShop(
170-
req: Request,
171-
res: Response,
172-
next: NextFunction
173-
) {
160+
static async createShop(req: Request, res: Response, next: NextFunction) {
174161
try {
175-
const { name, description, dateEstablished }
176-
= req.body;
162+
const { name, description, dateEstablished } = req.body;
177163

178164
// Parse date strings to Date objects
179165
// before passing to the service
180166
const parsedDate = new Date(dateEstablished);
181167

182-
const shop = await CalendarService.createShop(
183-
req.currentUser,
184-
name,
185-
description,
186-
parsedDate,
187-
req.organization
188-
);
168+
const shop = await CalendarService.createShop(req.currentUser, name, description, parsedDate, req.organization);
189169

190170
res.status(200).json(shop);
191171
} catch (error: unknown) {
@@ -214,16 +194,10 @@ Services contain all business logic.
214194
// src/backend/src/services/calendar.services.ts
215195
import { User, Shop, notGuest } from 'shared';
216196
import prisma from '../prisma/prisma.js';
217-
import {
218-
AccessDeniedGuestException,
219-
HttpException
220-
} from '../utils/errors.utils.js';
221-
import { shopTransformer }
222-
from '../transformers/calendar.transformer.js';
223-
import { getShopQueryArgs }
224-
from '../prisma-query-args/shop.query-args.js';
225-
import { userHasPermission }
226-
from '../utils/users.utils.js';
197+
import { AccessDeniedGuestException, HttpException } from '../utils/errors.utils.js';
198+
import { shopTransformer } from '../transformers/calendar.transformer.js';
199+
import { getShopQueryArgs } from '../prisma-query-args/shop.query-args.js';
200+
import { userHasPermission } from '../utils/users.utils.js';
227201
import { Organization } from '@prisma/client';
228202

229203
export default class CalendarService {
@@ -249,16 +223,8 @@ export default class CalendarService {
249223
organization: Organization
250224
): Promise<Shop> {
251225
// 1. Permission check
252-
if (
253-
!(await userHasPermission(
254-
submitter.userId,
255-
organization.organizationId,
256-
notGuest
257-
))
258-
) {
259-
throw new AccessDeniedGuestException(
260-
'create shops'
261-
);
226+
if (!(await userHasPermission(submitter.userId, organization.organizationId, notGuest))) {
227+
throw new AccessDeniedGuestException('create shops');
262228
}
263229

264230
// 2. Business rule validation (inline select)
@@ -272,10 +238,7 @@ export default class CalendarService {
272238
});
273239

274240
if (duplicate) {
275-
throw new HttpException(
276-
400,
277-
'A shop with that name already exists'
278-
);
241+
throw new HttpException(400, 'A shop with that name already exists');
279242
}
280243

281244
// 3. Database write (query args for response)
@@ -317,22 +280,20 @@ Every write endpoint (and some sensitive reads) needs a permission check at the
317280

318281
```typescript
319282
import {
320-
notGuest, // members and above
321-
isLeadership, // leads and above
322-
isHead, // heads and above
323-
isAdmin // admins and app-admins only
283+
notGuest, // members and above
284+
isLeadership, // leads and above
285+
isHead, // heads and above
286+
isAdmin // admins and app-admins only
324287
} from 'shared';
325288

326289
if (
327290
!(await userHasPermission(
328291
submitter.userId,
329292
organization.organizationId,
330-
isHead // choose the right level
293+
isHead // choose the right level
331294
))
332295
) {
333-
throw new AccessDeniedAdminOnlyException(
334-
'create event types'
335-
);
296+
throw new AccessDeniedAdminOnlyException('create event types');
336297
}
337298
```
338299

@@ -351,34 +312,34 @@ Match the exception class to the level: `AccessDeniedGuestException` for `notGue
351312

352313
Services throw exceptions from `src/backend/src/utils/errors.utils.ts`. The global `errorHandler` middleware catches them.
353314

354-
| Exception | Status | When to Use |
355-
|-----------|--------|-------------|
356-
| `HttpException(status, msg)` | any | General-purpose with custom status |
357-
| `NotFoundException(name, id)` | 404 | Entity not found |
358-
| `DeletedException(name, id)` | 404 | Entity is soft-deleted |
359-
| `AccessDeniedException(msg?)` | 403 | Generic permission failure |
360-
| `AccessDeniedAdminOnlyException(action)` | 403 | Non-admin attempting admin action |
361-
| `AccessDeniedMemberException(action)` | 403 | Guest/member attempting restricted action |
362-
| `AccessDeniedGuestException(action)` | 403 | Guest attempting non-guest action |
363-
| `InvalidOrganizationException(item)` | 400 | Entity not in current org |
315+
| Exception | Status | When to Use |
316+
| ---------------------------------------- | ------ | ----------------------------------------- |
317+
| `HttpException(status, msg)` | any | General-purpose with custom status |
318+
| `NotFoundException(name, id)` | 404 | Entity not found |
319+
| `DeletedException(name, id)` | 404 | Entity is soft-deleted |
320+
| `AccessDeniedException(msg?)` | 403 | Generic permission failure |
321+
| `AccessDeniedAdminOnlyException(action)` | 403 | Non-admin attempting admin action |
322+
| `AccessDeniedMemberException(action)` | 403 | Guest/member attempting restricted action |
323+
| `AccessDeniedGuestException(action)` | 403 | Guest attempting non-guest action |
324+
| `InvalidOrganizationException(item)` | 400 | Entity not in current org |
364325

365326
The `name` parameter for `NotFoundException` and `DeletedException` MUST be one of the values in the `ExceptionObjectNames` type union in `errors.utils.ts`. Add your entity to that type if it's not listed.
366327

367328
## Validation Helpers
368329

369330
`src/backend/src/utils/validation.utils.ts` provides reusable validation chains:
370331

371-
| Helper | Validates |
372-
|--------|-----------|
373-
| `nonEmptyString(body('x'))` | Non-empty string |
374-
| `intMinZero(body('x'))` | Integer ≥ 0, not a string |
375-
| `decimalMinZero(body('x'))` | Decimal ≥ 0 |
376-
| `isDate(body('x'))` | Parseable date string |
377-
| `isOptionalDate(body('x'))` | Optional parseable date |
378-
| `isRole(body('x'))` | Valid `RoleEnum` value |
379-
| `isStatus(body('x'))` | Valid `WbsElementStatus` |
380-
| `isEventStatus(body('x'))` | Valid `Event_Status` |
381-
| `validateInputs` | Runs validation, returns 400 |
332+
| Helper | Validates |
333+
| --------------------------- | ---------------------------- |
334+
| `nonEmptyString(body('x'))` | Non-empty string |
335+
| `intMinZero(body('x'))` | Integer ≥ 0, not a string |
336+
| `decimalMinZero(body('x'))` | Decimal ≥ 0 |
337+
| `isDate(body('x'))` | Parseable date string |
338+
| `isOptionalDate(body('x'))` | Optional parseable date |
339+
| `isRole(body('x'))` | Valid `RoleEnum` value |
340+
| `isStatus(body('x'))` | Valid `WbsElementStatus` |
341+
| `isEventStatus(body('x'))` | Valid `Event_Status` |
342+
| `validateInputs` | Runs validation, returns 400 |
382343

383344
For complex reusable validators, spread them: `...descriptionBulletsValidators`.
384345

0 commit comments

Comments
 (0)