@clipboard-health/ai-rules 1.6.44 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/backend/AGENTS.md CHANGED
@@ -1,275 +1,676 @@
1
1
  <!-- Generated by Ruler -->
2
2
 
3
- <!-- Source: .ruler/backend/nestJsApis.md -->
3
+ <!-- Source: .ruler/backend/architecture.md -->
4
+
5
+ # Architecture
6
+
7
+ ## Three-Tier Architecture
8
+
9
+ All NestJS microservices follow a three-tier layered architecture:
10
+
11
+ ```text
12
+ ┌─────────────────────────────────────────────────────────────────┐
13
+ │ Entrypoints (Controllers, message consumers) │
14
+ │ - HTTP request/response, JSON:API DTO translation, auth │
15
+ ├─────────────────────────────────────────────────────────────────┤
16
+ │ Logic (NestJS services, message publishers, background jobs) │
17
+ │ - ALL business logic; works with DOs only │
18
+ │ - Knows nothing about HTTP or database specifics │
19
+ ├─────────────────────────────────────────────────────────────────┤
20
+ │ Data (Data repositories, gateways) │
21
+ │ - Database via ORM (Prisma/Mongoose), DAO ↔ DO translation │
22
+ │ - External service integrations (Gateways) │
23
+ └─────────────────────────────────────────────────────────────────┘
24
+ ```
25
+
26
+ **Module Structure:**
27
+
28
+ ```text
29
+ modules/
30
+ └── example/
31
+ ├── data/
32
+ │ ├── example.dao.mapper.ts
33
+ │ ├── example.repo.ts
34
+ │ └── notification.gateway.ts
35
+ ├── entrypoints/
36
+ │ ├── example.controller.ts
37
+ │ ├── example.consumer.ts
38
+ │ └── example.dto.mapper.ts
39
+ ├── logic/
40
+ │ ├── jobs/
41
+ │ │ └── exampleCreated.job.ts
42
+ │ ├── example.do.ts
43
+ │ └── example.service.ts
44
+ └── example.module.ts
45
+ ```
46
+
47
+ **File Patterns:**
48
+
49
+ ```text
50
+ *.controller.ts - HTTP controllers (entrypoints)
51
+ *.consumer.ts - Message consumers (entrypoints)
52
+ *.service.ts - Business logic (logic)
53
+ *.job.ts - Background jobs (logic)
54
+ *.repo.ts - Database access (data)
55
+ *.gateway.ts - External services (data)
56
+ *.do.ts - Domain objects
57
+ *.dto.mapper.ts - DTO transformation
58
+ *.dao.mapper.ts - DAO transformation
59
+ ```
60
+
61
+ **Tier Rules:**
62
+
63
+ - Controllers → Services (never repos directly)
64
+ - Services → Repos/Gateways within module (never controllers)
65
+ - Repos → Database only (never services/repos/controllers)
66
+ - Entry points are thin layers calling services
67
+ - Enforce with `dependency-cruiser`
68
+
69
+ **Microservices Principles:**
70
+
71
+ - One domain = one module (bounded contexts)
72
+ - Specific modules know about generic, not vice versa
73
+ - Don't block Node.js thread—use background jobs for expensive operations
74
+
75
+ <!-- Source: .ruler/backend/asyncMessagingBackgroundJobs.md -->
76
+
77
+ # Async Messaging & Background Jobs
78
+
79
+ ## When to Use
80
+
81
+ | Scenario | Solution |
82
+ | -------------------------------- | ----------------- |
83
+ | Same service producer/consumer | Background Jobs |
84
+ | Cross-service communication | EventBridge + SQS |
85
+ | Deferred work from API path | Background Jobs |
86
+ | Replacing `void` fire-and-forget | Background Jobs |
87
+ | Scaling CRON jobs | Background Jobs |
88
+
89
+ ## Background Jobs
90
+
91
+ **Creation with Transaction:**
92
+
93
+ ```typescript
94
+ async function createLicense() {
95
+ await db.transaction(async (tx) => {
96
+ const license = await tx.license.create({ data });
97
+ await jobs.enqueue(VerificationJob, { licenseId: license.id }, { transaction: tx });
98
+ });
99
+ }
100
+ ```
101
+
102
+ **Handler Pattern:**
103
+
104
+ ```typescript
105
+ class ShiftReminderJob implements Handler<ShiftReminderPayload> {
106
+ static queueName = "shift.reminder";
107
+
108
+ async perform(payload: ShiftReminderPayload, job: Job): Promise<string> {
109
+ const { shiftId } = payload;
110
+
111
+ // Fetch fresh data—don't trust stale payload
112
+ const shift = await this.shiftRepo.findById({ id: shiftId });
113
+
114
+ if (!shift || shift.isCancelled) {
115
+ return `Skipping: shift ${shiftId} not found or cancelled`;
116
+ }
117
+
118
+ try {
119
+ await this.notificationService.performSideEffect(shift);
120
+ return `Reminder sent for shift ${shiftId}`;
121
+ } catch (error) {
122
+ if (error instanceof KnownRecoverableError) throw error; // Retry
123
+ return `Skipping: ${error.message}`; // No retry
124
+ }
125
+ }
126
+ }
127
+ ```
128
+
129
+ **Key Practices:**
130
+
131
+ - Pass minimal arguments (IDs, not objects)
132
+ - Fetch fresh data in handler
133
+ - Implement idempotency
134
+ - Check state before action
135
+ - Use Expand/Contract for job code updates
136
+
137
+ **Avoid Circular Dependencies:**
138
+
139
+ ```typescript
140
+ // Shared types file
141
+ export const NOTIFICATION_JOB = "shift-notification";
142
+ export interface NotificationJobPayload {
143
+ shiftId: string;
144
+ }
145
+
146
+ // Enqueue by string name
147
+ await jobs.enqueue<NotificationJobPayload>(NOTIFICATION_JOB, { shiftId });
148
+ ```
149
+
150
+ ## SQS/EventBridge
151
+
152
+ **Producer:** Single producer per message type, publish atomically (use jobs as outbox), deterministic message IDs, don't rely on strict ordering.
4
153
 
5
- # NestJS APIs
154
+ **Consumer:** Own queue per consumer, must be idempotent, separate process from API, don't auto-consume DLQs.
6
155
 
7
- - Use a three-tier architecture:
8
- - Controllers in the entrypoints tier translate from data transfer objects (DTOs) to domain objects (DOs) and call the logic tier.
9
- - Logic tier services call other services in the logic tier and repos and gateways at the data tier. The logic tier operates only on DOs.
10
- - Data tier repos translate from DOs to data access objects (DAOs), call the database using either Prisma for Postgres or Mongoose for MongoDB, and then translate from DAOs to DOs before returning to the logic tier.
11
- - Use ts-rest to define contracts using Zod schemas, one contract per resource.
12
- - A controller implements each ts-rest contract.
13
- - Requests and responses follow the JSON:API specification, including pagination for listings.
14
- - Use TypeDoc to document public functions, classes, methods, and complex code blocks.
156
+ <!-- Source: .ruler/backend/databasePatterns.md -->
157
+
158
+ # Database Patterns
159
+
160
+ ## MongoDB/Mongoose
161
+
162
+ **ObjectId:**
163
+
164
+ ```typescript
165
+ import mongoose, { Types, Schema } from "mongoose";
166
+
167
+ const id = new Types.ObjectId();
168
+
169
+ // In schemas
170
+ const schema = new Schema({
171
+ authorId: { type: Schema.Types.ObjectId, ref: "User" },
172
+ });
173
+
174
+ // In interfaces
175
+ interface Post {
176
+ authorId: Types.ObjectId;
177
+ }
178
+
179
+ // Validation
180
+ if (mongoose.isObjectIdOrHexString(value)) {
181
+ }
182
+ ```
183
+
184
+ **Aggregations:**
185
+
186
+ ```typescript
187
+ const result = await Users.aggregate<ResultType>()
188
+ .match({ active: true })
189
+ .group({ _id: "$department", count: { $sum: 1 } })
190
+ .project({ department: "$_id", count: 1 });
191
+ ```
192
+
193
+ **Indexes:**
194
+
195
+ - Add only when needed (slower writes tradeoff)
196
+ - Apply via code only
197
+ - Separate `indexes.ts` from `schema.ts`
198
+ - Index definitions only in owning service
199
+ - Set `autoIndex: false`
200
+ - Design covering indexes for high-traffic queries
201
+
202
+ ```text
203
+ models/User/
204
+ ├── schema.ts # Schema only
205
+ ├── indexes.ts # Indexes only
206
+ └── index.ts # Model export
207
+ ```
208
+
209
+ **Verify query plans:**
210
+
211
+ ```typescript
212
+ const explanation = await ShiftModel.find(query).explain("executionStats");
213
+ // Check: totalDocsExamined ≈ totalDocsReturned
214
+ // Good: stage 'IXSCAN'; Bad: stage 'COLLSCAN'
215
+ ```
216
+
217
+ **Transactions:**
218
+
219
+ ```typescript
220
+ const session = await mongoose.startSession();
221
+ try {
222
+ session.startTransaction();
223
+ await Model.create([data], { session });
224
+ await session.commitTransaction();
225
+ } catch (error) {
226
+ await session.abortTransaction();
227
+ throw error;
228
+ } finally {
229
+ await session.endSession();
230
+ }
231
+ ```
232
+
233
+ ## Repository Pattern
234
+
235
+ ```typescript
236
+ class UserRepo {
237
+ // Named methods over generic CRUD
238
+ async findById(request: { id: UserId }): Promise<UserDo> {}
239
+ async findByEmail(request: { email: string }): Promise<UserDo> {}
240
+ async updateEmail(request: { id: UserId; email: string }): Promise<UserDo> {}
241
+ }
242
+ ```
243
+
244
+ <!-- Source: .ruler/backend/errorHandling.md -->
245
+
246
+ # Error Handling
247
+
248
+ ## Service Errors
249
+
250
+ ```typescript
251
+ import { ServiceError } from "@clipboard-health/util-ts";
252
+
253
+ throw new ServiceError({
254
+ code: "SHIFT_NOT_FOUND",
255
+ message: `Shift ${shiftId} not found`,
256
+ httpStatus: 404,
257
+ });
258
+ ```
259
+
260
+ - Favor `Either` type for expected errors over `try/catch`
261
+ - Guard clauses for preconditions
262
+ - Early returns for error conditions
263
+ - Happy path last
264
+
265
+ ## Controller Translation
266
+
267
+ ```typescript
268
+ @UseFilters(HttpExceptionFilter)
269
+ @Controller("shifts")
270
+ export class ShiftController {}
271
+ ```
272
+
273
+ ## Background Job Errors
274
+
275
+ ```typescript
276
+ async perform(payload: JobPayload): Promise<string> {
277
+ try {
278
+ // Main logic
279
+ } catch (error) {
280
+ if (error instanceof RecoverableError) throw error; // Retry
281
+ return `Skipping: ${error.message}`; // No retry
282
+ }
283
+ }
284
+ ```
15
285
 
16
286
  <!-- Source: .ruler/backend/notifications.md -->
17
287
 
18
288
  # Notifications
19
289
 
20
- Send notifications through [Knock](https://docs.knock.app) using the `@clipboard-health/notifications` NPM library.
21
-
22
- ## Usage
23
-
24
- <embedex source="packages/notifications/examples/usage.md">
25
-
26
- ### `triggerChunked`
27
-
28
- `triggerChunked` stores the full, immutable trigger request at job enqueue time, eliminating issues with stale data, chunking requests to stay under provider limits, and idempotency key conflicts that can occur if the request is updated at job execution time.
29
-
30
- 1. Search your service for `triggerNotification.constants.ts`, `triggerNotification.job.ts` and `notifications.service.ts`. If they don't exist, create them:
31
-
32
- ```ts
33
- // triggerNotification.constants.ts
34
- export const TRIGGER_NOTIFICATION_JOB_NAME = "TriggerNotificationJob";
35
- ```
36
-
37
- ```ts
38
- // triggerNotification.job.ts
39
- import { type BaseHandler } from "@clipboard-health/background-jobs-adapter";
40
- import {
41
- type SerializableTriggerChunkedRequest,
42
- toTriggerChunkedRequest,
43
- } from "@clipboard-health/notifications";
44
- import { isFailure } from "@clipboard-health/util-ts";
45
-
46
- import { type NotificationsService } from "./notifications.service";
47
- import { CBHLogger } from "./setup";
48
- import { TRIGGER_NOTIFICATION_JOB_NAME } from "./triggerNotification.constants";
49
-
50
- /**
51
- * For @clipboard-health/mongo-jobs:
52
- * 1. Implement `HandlerInterface<SerializableTriggerChunkedRequest>`.
53
- * 2. The 10 default `maxAttempts` with exponential backoff of `2^attemptsCount` means ~17 minutes
54
- * of cumulative delay. If your notification could be stale before this, set
55
- * `SerializableTriggerChunkedRequest.expiresAt` when enqueueing.
56
- *
57
- * For @clipboard-health/background-jobs-postgres:
58
- * 1. Implement `Handler<SerializableTriggerChunkedRequest>`.
59
- * 2. The 20 default `maxRetryAttempts` with exponential backoff of `10s * 2^(attempt - 1)` means
60
- * ~121 days of cumulative delay. If your notification could be stale before this, set
61
- * `maxRetryAttempts` (and `SerializableTriggerChunkedRequest.expiresAt`) when enqueueing.
62
- */
63
- export class TriggerNotificationJob implements BaseHandler<SerializableTriggerChunkedRequest> {
64
- // For background-jobs-postgres, use `public static queueName = TRIGGER_NOTIFICATION_JOB_NAME;`
65
- public name = TRIGGER_NOTIFICATION_JOB_NAME;
66
- private readonly logger = new CBHLogger({
67
- defaultMeta: {
68
- logContext: TRIGGER_NOTIFICATION_JOB_NAME,
69
- },
70
- });
71
-
72
- public constructor(private readonly service: NotificationsService) {}
73
-
74
- public async perform(
75
- data: SerializableTriggerChunkedRequest,
76
- /**
77
- * For mongo-jobs, implement `BackgroundJobType<SerializableTriggerChunkedRequest>`, which has
78
- * `_id`, `attemptsCount`, and `uniqueKey`.
79
- *
80
- * For background-jobs-postgres, implement `Job<SerializableTriggerChunkedRequest>`, which has
81
- * `id`, `retryAttempts`, and `idempotencyKey`.
82
- */
83
- job: { _id: string; attemptsCount: number; uniqueKey?: string },
84
- ) {
85
- const metadata = {
86
- // For background-jobs-postgres, this is called `retryAttempts`.
87
- attempt: job.attemptsCount + 1,
88
- jobId: job._id,
89
- recipientCount: data.body.recipients.length,
90
- workflowKey: data.workflowKey,
91
- };
92
- this.logger.info("TriggerNotificationJob processing", metadata);
93
-
94
- try {
95
- const request = toTriggerChunkedRequest(data, {
96
- attempt: metadata.attempt,
97
- idempotencyKey: job.uniqueKey ?? metadata.jobId,
98
- });
99
- const result = await this.service.triggerChunked(request);
100
-
101
- if (isFailure(result)) {
102
- throw result.error;
103
- }
104
-
105
- const success = "TriggerNotificationJob success";
106
- this.logger.info(success, { ...metadata, response: result.value });
107
- // For background-jobs-postgres, return the `success` string result.
108
- } catch (error) {
109
- this.logger.error("TriggerNotificationJob failure", { ...metadata, error });
110
- throw error;
111
- }
112
- }
113
- }
114
- ```
115
-
116
- ```ts
117
- // notifications.service.ts
118
- import { NotificationClient } from "@clipboard-health/notifications";
119
-
120
- import { CBHLogger, toLogger, tracer } from "./setup";
121
-
122
- export class NotificationsService {
123
- private readonly client: NotificationClient;
124
-
125
- constructor() {
126
- this.client = new NotificationClient({
127
- apiKey: "YOUR_KNOCK_API_KEY",
128
- logger: toLogger(new CBHLogger()),
129
- tracer,
130
- });
131
- }
132
-
133
- async triggerChunked(
134
- params: Parameters<NotificationClient["triggerChunked"]>[0],
135
- ): ReturnType<NotificationClient["triggerChunked"]> {
136
- return await this.client.triggerChunked(params);
137
- }
138
- }
139
- ```
140
-
141
- 2. Search the service for a constant that stores workflow keys. If there isn't one, create it:
142
-
143
- ```ts
144
- /* eslint sort-keys: "error" */
145
-
146
- /**
147
- * Alphabetical list of workflow keys.
148
- */
149
- export const WORKFLOW_KEYS = {
150
- eventStartingReminder: "event-starting-reminder",
151
- } as const;
152
- ```
153
-
154
- 3. Build your `SerializableTriggerChunkedRequest` and enqueue your job. Think of queuing `TriggerNotificationJob` as a function call to send notifications in a best practices way. You should NOT call `triggerChunked` directly. If, for example, your notification is delayed, create a background job that runs in the future, does any necessary checks to ensure you should notify, and then queue `TriggerNotificationJob`.
155
-
156
- ```ts
157
- import { type BackgroundJobsAdapter } from "@clipboard-health/background-jobs-adapter";
158
- import { type SerializableTriggerChunkedRequest } from "@clipboard-health/notifications";
159
-
160
- import { BackgroundJobsService } from "./setup";
161
- import { TRIGGER_NOTIFICATION_JOB_NAME } from "./triggerNotification.constants";
162
- import { WORKFLOW_KEYS } from "./workflowKeys";
163
-
164
- /**
165
- * Enqueue a notification job in the same database transaction as the changes it's notifying about.
166
- * The `session` option is called `transaction` in `background-jobs-postgres`.
167
- */
168
- async function enqueueTriggerNotificationJob(adapter: BackgroundJobsAdapter) {
169
- // Assume this comes from a database and are used as template variables...
170
- const notificationData = {
171
- favoriteColor: "blue",
172
- // Use @clipboard-health/date-time's formatShortDateTime in your service for consistency.
173
- favoriteAt: new Date().toISOString(),
174
- secret: "2",
175
- };
176
-
177
- const jobData: SerializableTriggerChunkedRequest = {
178
- // Important: Read the TypeDoc documentation for additional context.
179
- body: {
180
- recipients: ["userId-1", "userId-2"],
181
- data: notificationData,
182
- },
183
- // Helpful when controlling notifications with feature flags.
184
- dryRun: false,
185
- // Set expiresAt at enqueue-time so it remains stable across job retries. Use date-fns in your
186
- // service instead of this manual calculation.
187
- expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(),
188
- // Keys to redact from logs
189
- keysToRedact: ["secret"],
190
- workflowKey: WORKFLOW_KEYS.eventStartingReminder,
191
- };
192
-
193
- // Option 1 (default): Automatically use background job ID as idempotency key.
194
- await adapter.enqueue(TRIGGER_NOTIFICATION_JOB_NAME, jobData, { session: "..." });
195
-
196
- // Option 2 (advanced): Provide custom idempotency key to job and notification libraries for more
197
- // control. You'd use this to provide enqueue-time deduplication. For example, if you enqueue when
198
- // a user clicks a button and only want them to receive one notification.
199
- await adapter.enqueue(TRIGGER_NOTIFICATION_JOB_NAME, jobData, {
200
- // Called `idempotencyKey` in `background-jobs-postgres`.
201
- unique: `meeting-123-reminder`,
202
- session: "...",
203
- });
204
- }
205
-
206
- // eslint-disable-next-line unicorn/prefer-top-level-await
207
- void enqueueTriggerNotificationJob(
208
- // Use your instance of `@clipboard-health/mongo-jobs` or `@clipboard-health/background-jobs-postgres` here.
209
- new BackgroundJobsService(),
210
- );
211
- ```
212
-
213
- </embedex>
214
-
215
- <!-- Source: .ruler/common/codeStyleAndStructure.md -->
216
-
217
- # Code style and structure
218
-
219
- - Write concise, technical TypeScript code with accurate examples.
220
- - Use functional and declarative programming patterns.
221
- - Prefer iteration and modularization over code duplication.
222
- - Use descriptive variable names with auxiliary verbs (e.g., isLoading, hasError).
223
- - Structure files: constants, types, exported functions, non-exported functions.
224
- - Avoid magic strings and numbers; define constants.
225
- - Use camelCase for files and directories (e.g., modules/shiftOffers.ts).
226
- - When declaring functions, use the `function` keyword, not `const`.
227
- - Files should read from top to bottom: `export`ed items live on top and the internal functions and methods they call go below them.
228
- - Prefer data immutability.
229
-
230
- # Commit messages
231
-
232
- - Follow the Conventional Commits 1.0 spec for commit messages and in pull request titles.
233
-
234
- <!-- Source: .ruler/common/errorHandlingAndValidation.md -->
235
-
236
- # Error handling and validation
237
-
238
- - Sanitize user input.
239
- - Handle errors and edge cases at the beginning of functions.
240
- - Use early returns for error conditions to avoid deeply nested if statements.
241
- - Place the happy path last in the function for improved readability.
242
- - Avoid unnecessary else statements; use the if-return pattern instead.
243
- - Use guard clauses to handle preconditions and invalid states early.
244
- - Implement proper error logging and user-friendly error messages.
245
- - Favor `@clipboard-health/util-ts`'s `Either` type for expected errors instead of `try`/`catch`.
290
+ Send via [Knock](https://docs.knock.app) using `@clipboard-health/notifications`.
291
+
292
+ Use `triggerChunked` to store full trigger request at job enqueue time. See package documentation for setup of `triggerNotification.job.ts`, `notifications.service.ts`, and workflow keys.
293
+
294
+ **Job enqueue pattern:**
295
+
296
+ ```typescript
297
+ const jobData: SerializableTriggerChunkedRequest = {
298
+ body: { recipients: ["userId-1"], data: notificationData },
299
+ expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(),
300
+ keysToRedact: ["secret"],
301
+ workflowKey: WORKFLOW_KEYS.eventStartingReminder,
302
+ };
303
+
304
+ await adapter.enqueue(TRIGGER_NOTIFICATION_JOB_NAME, jobData, { session });
305
+ ```
306
+
307
+ <!-- Source: .ruler/backend/restApiDesign.md -->
308
+
309
+ # REST API Design
310
+
311
+ ## JSON:API Specification
312
+
313
+ Follow [JSON:API spec](https://jsonapi.org/).
314
+
315
+ ```json
316
+ {
317
+ "data": [
318
+ {
319
+ "id": "1",
320
+ "type": "worker",
321
+ "attributes": { "firstName": "Alex", "lastName": "Smith" }
322
+ }
323
+ ]
324
+ }
325
+ ```
326
+
327
+ - Singular `type` values: `"worker"` not `"workers"`
328
+ - Links optional (use only for pagination)
329
+ - Use `include` for related resources
330
+ - Avoid `meta` unless necessary
331
+
332
+ ## URLs
333
+
334
+ ```text
335
+ GET /urgent-shifts # lowercase kebab-case, plural nouns
336
+ GET /workers/:workerId/shifts
337
+ POST /workers/:workerId/referral-codes
338
+ ```
339
+
340
+ ## HTTP Methods
341
+
342
+ | Method | Usage |
343
+ | ------ | ---------------------------------- |
344
+ | GET | Retrieve (idempotent) |
345
+ | POST | Create single resource, return DTO |
346
+ | PATCH | Update, return updated resource |
347
+ | DELETE | Remove |
348
+ | PUT | Not supported |
349
+
350
+ ## HTTP Status Codes
351
+
352
+ | Code | Meaning |
353
+ | ---- | ------------------------------------------------------- |
354
+ | 200 | OK (GET, PATCH, DELETE) |
355
+ | 201 | Created (POST) |
356
+ | 202 | Accepted (async started) |
357
+ | 400 | Bad Request (syntax error) |
358
+ | 401 | Unauthorized (auth failed) |
359
+ | 403 | Forbidden (authz failed) |
360
+ | 404 | Not Found |
361
+ | 409 | Conflict (already exists) |
362
+ | 422 | Unprocessable (semantic error, unsupported filter/sort) |
363
+ | 429 | Rate limited |
364
+ | 500 | Server error |
365
+
366
+ ## Filtering, Sorting, Pagination
367
+
368
+ ```text
369
+ GET /shifts?filter[verified]=true&sort=startDate,-urgency&page[cursor]=abc&page[size]=50
370
+ ```
371
+
372
+ - Cursor-based pagination only (not offset)
373
+ - Avoid count totals (performance)
374
+ - Only implement filters/sorts clients need
375
+
376
+ ## Contracts
377
+
378
+ - Add contracts to `contract-<repo-name>` package
379
+ - Use `ts-rest` with composable Zod schemas
380
+
381
+ <!-- Source: .ruler/backend/serviceTests.md -->
382
+
383
+ # Service Tests (Primary Testing Approach)
384
+
385
+ Test the public contract (REST endpoints, events) with real local dependencies (Postgres, Mongo, Redis). Fake slow/external services (Zendesk, Stripe).
386
+
387
+ ```typescript
388
+ describe("Documents", () => {
389
+ let tc: TestContext;
390
+
391
+ describe("GET /documents", () => {
392
+ it("returns existing documents for authenticated user", async () => {
393
+ // Arrange
394
+ const authToken = await tc.auth.createUser({ role: "employee" });
395
+ await tc.fixtures.createDocument({ name: "doc-1" });
396
+
397
+ // Act
398
+ const response = await tc.http.get("/documents", {
399
+ headers: { authorization: authToken },
400
+ });
401
+
402
+ // Assert
403
+ expect(response.statusCode).toBe(200);
404
+ expect(response.parsedBody.data).toHaveLength(1);
405
+ });
406
+ });
407
+ });
408
+ ```
409
+
410
+ **Qualities:** One behavior per test, no shared setup, no mocking, <1 second, parallelizable.
411
+
412
+ <!-- Source: .ruler/common/commitMessages.md -->
413
+
414
+ # Commit Messages
415
+
416
+ Follow Conventional Commits 1.0 spec for commit messages and PR titles.
417
+
418
+ <!-- Source: .ruler/common/configuration.md -->
419
+
420
+ # Configuration
421
+
422
+ ```text
423
+ Contains secrets?
424
+ └── Yes SSM Parameter Store
425
+ └── No → Engineers-only, tolerate 1hr propagation?
426
+ └── Yes → Hardcode with @clipboard-health/config
427
+ └── No 1:1 with DB entity OR needs custom UI?
428
+ └── Yes Database
429
+ └── No → LaunchDarkly feature flag
430
+ ```
431
+
432
+ <!-- Source: .ruler/common/featureFlags.md -->
433
+
434
+ # Feature Flags
435
+
436
+ **Naming:** `YYYY-MM-[kind]-[subject]` (e.g., `2024-03-release-new-booking-flow`)
437
+
438
+ | Kind | Purpose |
439
+ | ------------ | ------------------------ |
440
+ | `release` | Gradual rollout to 100% |
441
+ | `enable` | Kill switch |
442
+ | `experiment` | Trial for small audience |
443
+ | `configure` | Runtime config |
444
+
445
+ **Rules:**
446
+
447
+ - "Off" = default/safer value
448
+ - No permanent flags (except `configure`)
449
+ - Create archival ticket when creating flag
450
+ - Validate staging before production
451
+ - Always provide default values in code
452
+ - Clean up after full launch
453
+
454
+ <!-- Source: .ruler/common/loggingObservability.md -->
455
+
456
+ # Logging & Observability
457
+
458
+ ## Log Levels
459
+
460
+ | Level | When |
461
+ | ----- | ------------------------------------------ |
462
+ | ERROR | Required functionality broken (2am pager?) |
463
+ | WARN | Optional broken OR recovered from failure |
464
+ | INFO | Informative, ignorable during normal ops |
465
+ | DEBUG | Local only, not production |
466
+
467
+ ## Best Practices
468
+
469
+ ```typescript
470
+ // Bad
471
+ logger.error("Operation failed");
472
+ logger.error(`Operation failed for workplace ${workplaceId}`);
473
+
474
+ // Good—structured context
475
+ logger.error("Exporting urgent shifts to CSV failed", {
476
+ workplaceId,
477
+ startDate,
478
+ endDate,
479
+ });
480
+ ```
481
+
482
+ **Never log:** PII, PHI, tokens, secrets, SSN, account numbers, entire request/response/headers.
483
+
484
+ Use metrics for counting:
485
+
486
+ ```typescript
487
+ datadogMetrics.increment("negotiation.errors", { state: "New York" });
488
+ ```
489
+
490
+ <!-- Source: .ruler/common/pullRequests.md -->
491
+
492
+ # Pull Requests
493
+
494
+ **Requirements:**
495
+
496
+ 1. Clear title: change summary + ticket; Conventional Commits 1.0
497
+ 2. Thorough description: why, not just what
498
+ 3. Small & focused: single concept
499
+ 4. Tested: service tests + validation proof
500
+ 5. Passing CI
501
+
502
+ **Description:** Link ticket, context, reasoning, areas of concern.
503
+
504
+ <!-- Source: .ruler/common/security.md -->
505
+
506
+ # Security
507
+
508
+ **Secrets:**
509
+
510
+ - `.env` locally (gitignored)
511
+ - Production: AWS SSM Parameter Store
512
+ - Prefer short-lived tokens
513
+
514
+ **Naming:** `[ENV]_[VENDOR]_[TYPE]_usedBy_[CLIENT]_[SCOPE]_[CREATED_AT]_[OWNER]`
515
+
516
+ <!-- Source: .ruler/common/structuredConcurrency.md -->
517
+
518
+ # Structured Concurrency
519
+
520
+ ```typescript
521
+ // Cancellation propagation
522
+ async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response> {
523
+ const controller = new AbortController();
524
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
525
+ try {
526
+ return await fetch(url, { signal: controller.signal });
527
+ } finally {
528
+ clearTimeout(timeoutId);
529
+ }
530
+ }
531
+
532
+ // All results regardless of failures
533
+ const results = await Promise.allSettled(operations);
534
+ const succeeded = results.filter((r) => r.status === "fulfilled");
535
+ const failed = results.filter((r) => r.status === "rejected");
536
+ ```
246
537
 
247
538
  <!-- Source: .ruler/common/testing.md -->
248
539
 
249
540
  # Testing
250
541
 
251
- - Follow the Arrange-Act-Assert convention for tests with newlines between each section.
252
- - Name test variables using the `mockX`, `input`, `expected`, `actual` convention.
253
- - Aim for high test coverage, writing both positive and negative test cases.
254
- - Prefer `it.each` for multiple test cases.
255
- - Avoid conditional logic in tests.
542
+ ## Unit Tests
543
+
544
+ Use when: error handling hard to trigger black-box, concurrency scenarios, >5 variations, pure function logic.
545
+
546
+ ## Conventions
547
+
548
+ - Use `it` not `test`; `describe` for grouping
549
+ - Arrange-Act-Assert with newlines between
550
+ - Variables: `mockX`, `input`, `expected`, `actual`
551
+ - Prefer `it.each` for multiple cases
552
+ - No conditional logic in tests
256
553
 
257
554
  <!-- Source: .ruler/common/typeScript.md -->
258
555
 
259
- # TypeScript usage
260
-
261
- - Use strict-mode TypeScript for all code; prefer interfaces over types.
262
- - Avoid enums; use const maps instead.
263
- - Strive for precise types. Look for type definitions in the codebase and create your own if none exist.
264
- - Avoid using type assertions like `as` or `!` unless absolutely necessary.
265
- - Use the `unknown` type instead of `any` when the type is truly unknown.
266
- - Use an object to pass multiple function params and to return results.
267
- - Leverage union types, intersection types, and conditional types for complex type definitions.
268
- - Use mapped types and utility types (e.g., `Partial<T>`, `Pick<T>`, `Omit<T>`) to transform existing types.
269
- - Implement generic types to create reusable, flexible type definitions.
270
- - Utilize the `keyof` operator and index access types for dynamic property access.
271
- - Implement discriminated unions for type-safe handling of different object shapes where appropriate.
272
- - Use the `infer` keyword in conditional types for type inference.
273
- - Leverage `readonly` properties for function parameter immutability.
274
- - Prefer narrow types whenever possible with `as const` assertions, `typeof`, `instanceof`, `satisfies`, and custom type guards.
275
- - Implement exhaustiveness checking using `never`.
556
+ # TypeScript
557
+
558
+ ## Naming Conventions
559
+
560
+ | Element | Convention | Example |
561
+ | --------------------- | --------------------- | ---------------------------- |
562
+ | File-scope constants | UPPER_SNAKE_CASE | `MAX_RETRY_COUNT` |
563
+ | Acronyms in camelCase | Lowercase after first | `httpRequest`, `gpsPosition` |
564
+ | Files | Singular, dotted | `user.service.ts` |
565
+
566
+ ## Core Rules
567
+
568
+ - Strict-mode TypeScript; prefer interfaces over types
569
+ - Avoid enums—use const maps
570
+ - NEVER use `any`—use `unknown` or generics
571
+ - Avoid type assertions (`as`, `!`) unless absolutely necessary
572
+ - Use `function` keyword for declarations, not `const`
573
+ - Prefer `undefined` over `null`
574
+ - Explicit return types on functions
575
+ - Files read top-to-bottom: exports first, internal helpers below
576
+ - Boolean props: `is*`, `has*`, `should*`, `can*`
577
+ - Use const assertions for constants: `as const`
578
+
579
+ ## Types
580
+
581
+ ```typescript
582
+ // Strong typing
583
+ function process(arg: unknown) {} // Better than any
584
+ function process<T>(arg: T) {} // Best
585
+
586
+ // Nullable checks
587
+ if (foo == null) {
588
+ } // Clear intent
589
+ if (isDefined(foo)) {
590
+ } // Better with utility
591
+
592
+ // Quantity values—always unambiguous
593
+ const money = { amountInMinorUnits: 500, currencyCode: "USD" };
594
+ const durationMinutes = 30;
595
+ ```
596
+
597
+ **Type Techniques:**
598
+
599
+ - Union, intersection, conditional types for complex definitions
600
+ - Mapped types: `Partial<T>`, `Pick<T>`, `Omit<T>`
601
+ - `keyof`, index access types, discriminated unions
602
+ - `as const`, `typeof`, `instanceof`, `satisfies`, type guards
603
+ - Exhaustiveness checking with `never`
604
+ - `readonly` for parameter immutability
605
+
606
+ ## Functions
607
+
608
+ ```typescript
609
+ // Object arguments with interfaces
610
+ interface CreateUserRequest {
611
+ email: string;
612
+ name?: string;
613
+ }
614
+
615
+ function createUser(request: CreateUserRequest): User {
616
+ const { email, name } = request; // Destructure inside
617
+ // ...
618
+ }
619
+
620
+ // Guard clauses for early returns
621
+ function processOrder(order: Order): Result {
622
+ if (!order.isValid) {
623
+ return { error: "Invalid order" };
624
+ }
625
+ // Main logic
626
+ }
627
+ ```
628
+
629
+ ## Objects & Arrays
630
+
631
+ ```typescript
632
+ // Spread over Object.assign
633
+ const updated = { ...original, name: "New Name" };
634
+
635
+ // Array methods over loops (unless breaking early)
636
+ const doubled = items.map((item) => item * 2);
637
+ const sorted = items.toSorted((a, b) => a - b); // Immutable
638
+
639
+ // For early exit
640
+ for (const item of items) {
641
+ if (condition) break;
642
+ }
643
+ ```
644
+
645
+ ## Async
646
+
647
+ ```typescript
648
+ // async/await over .then()
649
+ async function fetchData(): Promise<Data> {
650
+ const response = await api.get("/data");
651
+ return response.data;
652
+ }
653
+
654
+ // Parallel
655
+ const results = await Promise.all(items.map(processItem));
656
+
657
+ // Sequential (when needed)
658
+ for (const item of items) {
659
+ // eslint-disable-next-line no-await-in-loop
660
+ await processItem(item);
661
+ }
662
+ ```
663
+
664
+ ## Classes
665
+
666
+ ```typescript
667
+ class UserService {
668
+ public async findById(request: FindByIdRequest): Promise<User> {}
669
+ private validateUser(user: User): boolean {}
670
+ }
671
+
672
+ // Extract pure functions outside classes
673
+ function formatUserName(first: string, last: string): string {
674
+ return `${first} ${last}`;
675
+ }
676
+ ```