@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 +659 -258
- package/common/AGENTS.md +250 -46
- package/frontend/AGENTS.md +481 -791
- package/fullstack/AGENTS.md +838 -951
- package/package.json +1 -1
package/backend/AGENTS.md
CHANGED
|
@@ -1,275 +1,676 @@
|
|
|
1
1
|
<!-- Generated by Ruler -->
|
|
2
2
|
|
|
3
|
-
<!-- Source: .ruler/backend/
|
|
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
|
-
|
|
154
|
+
**Consumer:** Own queue per consumer, must be idempotent, separate process from API, don't auto-consume DLQs.
|
|
6
155
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
<!-- Source: .ruler/common/
|
|
235
|
-
|
|
236
|
-
#
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
-
|
|
241
|
-
-
|
|
242
|
-
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
-
|
|
272
|
-
-
|
|
273
|
-
-
|
|
274
|
-
-
|
|
275
|
-
-
|
|
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
|
+
```
|