@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/fullstack/AGENTS.md
CHANGED
|
@@ -1,1251 +1,1138 @@
|
|
|
1
1
|
<!-- Generated by Ruler -->
|
|
2
2
|
|
|
3
|
-
<!-- Source: .ruler/backend/
|
|
3
|
+
<!-- Source: .ruler/backend/architecture.md -->
|
|
4
4
|
|
|
5
|
-
#
|
|
5
|
+
# Architecture
|
|
6
6
|
|
|
7
|
-
|
|
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.
|
|
7
|
+
## Three-Tier Architecture
|
|
15
8
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
# Notifications
|
|
19
|
-
|
|
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`.
|
|
9
|
+
All NestJS microservices follow a three-tier layered architecture:
|
|
246
10
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
-
|
|
254
|
-
-
|
|
255
|
-
|
|
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
|
+
```
|
|
256
25
|
|
|
257
|
-
|
|
26
|
+
**Module Structure:**
|
|
258
27
|
|
|
259
|
-
|
|
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
|
+
```
|
|
260
46
|
|
|
261
|
-
|
|
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`.
|
|
47
|
+
**File Patterns:**
|
|
276
48
|
|
|
277
|
-
|
|
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
|
+
```
|
|
278
60
|
|
|
279
|
-
|
|
61
|
+
**Tier Rules:**
|
|
280
62
|
|
|
281
|
-
|
|
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`
|
|
282
68
|
|
|
283
|
-
|
|
284
|
-
export function useFeature(params: Params, options: UseFeatureOptions = {}) {
|
|
285
|
-
const query = useQuery(...);
|
|
286
|
-
const computed = useMemo(() => transformData(query.data), [query.data]);
|
|
287
|
-
const handleAction = useCallback(async () => { /* ... */ }, []);
|
|
69
|
+
**Microservices Principles:**
|
|
288
70
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
292
74
|
|
|
293
|
-
|
|
75
|
+
<!-- Source: .ruler/backend/asyncMessagingBackgroundJobs.md -->
|
|
294
76
|
|
|
295
|
-
|
|
296
|
-
- Boolean: Use `useIs*`, `useHas*`, or `useCan*` (e.g., `useIsEnabled`, `useHasPermission`, `useCanEdit`)
|
|
297
|
-
- Data: `useGetUser`, `useFeatureData`
|
|
298
|
-
- Actions: `useSubmitForm`
|
|
77
|
+
# Async Messaging & Background Jobs
|
|
299
78
|
|
|
300
|
-
##
|
|
79
|
+
## When to Use
|
|
301
80
|
|
|
302
|
-
|
|
303
|
-
|
|
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 |
|
|
304
88
|
|
|
305
|
-
##
|
|
89
|
+
## Background Jobs
|
|
306
90
|
|
|
307
|
-
|
|
91
|
+
**Creation with Transaction:**
|
|
308
92
|
|
|
309
93
|
```typescript
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
+
});
|
|
315
99
|
}
|
|
316
|
-
|
|
317
|
-
export const [FiltersProvider, useFiltersContext] = constate(useFilters);
|
|
318
100
|
```
|
|
319
101
|
|
|
320
|
-
**
|
|
321
|
-
**When NOT:** Server state (use React Query), simple parent-child (use props)
|
|
322
|
-
|
|
323
|
-
## Hook Patterns
|
|
102
|
+
**Handler Pattern:**
|
|
324
103
|
|
|
325
104
|
```typescript
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
+
}
|
|
329
126
|
}
|
|
127
|
+
```
|
|
330
128
|
|
|
331
|
-
|
|
332
|
-
export function useTransformedData() {
|
|
333
|
-
const { data, isLoading } = useGetRawData();
|
|
334
|
-
const transformed = useMemo(() => data?.map(format), [data]);
|
|
335
|
-
return { data: transformed, isLoading };
|
|
336
|
-
}
|
|
129
|
+
**Key Practices:**
|
|
337
130
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
342
136
|
|
|
343
|
-
|
|
344
|
-
() => [...(shifts.data ?? []), ...(invites.data ?? [])],
|
|
345
|
-
[shifts.data, invites.data],
|
|
346
|
-
);
|
|
137
|
+
**Avoid Circular Dependencies:**
|
|
347
138
|
|
|
348
|
-
|
|
139
|
+
```typescript
|
|
140
|
+
// Shared types file
|
|
141
|
+
export const NOTIFICATION_JOB = "shift-notification";
|
|
142
|
+
export interface NotificationJobPayload {
|
|
143
|
+
shiftId: string;
|
|
349
144
|
}
|
|
145
|
+
|
|
146
|
+
// Enqueue by string name
|
|
147
|
+
await jobs.enqueue<NotificationJobPayload>(NOTIFICATION_JOB, { shiftId });
|
|
350
148
|
```
|
|
351
149
|
|
|
352
|
-
##
|
|
150
|
+
## SQS/EventBridge
|
|
353
151
|
|
|
354
|
-
|
|
355
|
-
- Be explicit about dependencies
|
|
356
|
-
- API hooks in `api/`, logic hooks in `hooks/`
|
|
357
|
-
- Return stable references with `useCallback`/`useMemo`
|
|
152
|
+
**Producer:** Single producer per message type, publish atomically (use jobs as outbox), deterministic message IDs, don't rely on strict ordering.
|
|
358
153
|
|
|
359
|
-
|
|
154
|
+
**Consumer:** Own queue per consumer, must be idempotent, separate process from API, don't auto-consume DLQs.
|
|
360
155
|
|
|
361
|
-
|
|
156
|
+
<!-- Source: .ruler/backend/databasePatterns.md -->
|
|
362
157
|
|
|
363
|
-
|
|
158
|
+
# Database Patterns
|
|
364
159
|
|
|
365
|
-
|
|
366
|
-
- **Zod** for response validation
|
|
160
|
+
## MongoDB/Mongoose
|
|
367
161
|
|
|
368
|
-
|
|
162
|
+
**ObjectId:**
|
|
369
163
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
3. **Rely on React Query state** - Use `isLoading`, `isError`, `isSuccess`
|
|
373
|
-
4. **Use `enabled` for conditional fetching**
|
|
374
|
-
5. **Use `invalidateQueries` for disabled queries** - Not `refetch()` which ignores enabled state
|
|
164
|
+
```typescript
|
|
165
|
+
import mongoose, { Types, Schema } from "mongoose";
|
|
375
166
|
|
|
376
|
-
|
|
167
|
+
const id = new Types.ObjectId();
|
|
377
168
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
queryKey: ["users", userId],
|
|
383
|
-
queryFn: () => api.get(`/users/${userId}`),
|
|
384
|
-
responseSchema: userSchema,
|
|
385
|
-
enabled: !!userId,
|
|
386
|
-
});
|
|
387
|
-
}
|
|
169
|
+
// In schemas
|
|
170
|
+
const schema = new Schema({
|
|
171
|
+
authorId: { type: Schema.Types.ObjectId, ref: "User" },
|
|
172
|
+
});
|
|
388
173
|
|
|
389
|
-
//
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
queryKey: ["items", params],
|
|
393
|
-
queryFn: ({ pageParam }) => api.get("/items", { cursor: pageParam, ...params }),
|
|
394
|
-
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
|
395
|
-
});
|
|
174
|
+
// In interfaces
|
|
175
|
+
interface Post {
|
|
176
|
+
authorId: Types.ObjectId;
|
|
396
177
|
}
|
|
397
178
|
|
|
398
|
-
//
|
|
399
|
-
|
|
400
|
-
const queryClient = useQueryClient();
|
|
401
|
-
return useMutation({
|
|
402
|
-
mutationFn: (data: CreateItemRequest) => api.post("/items", data),
|
|
403
|
-
onSuccess: () => queryClient.invalidateQueries(["items"]),
|
|
404
|
-
});
|
|
179
|
+
// Validation
|
|
180
|
+
if (mongoose.isObjectIdOrHexString(value)) {
|
|
405
181
|
}
|
|
406
182
|
```
|
|
407
183
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
For detailed error handling strategies, see `error-handling.md`.
|
|
184
|
+
**Aggregations:**
|
|
411
185
|
|
|
412
186
|
```typescript
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
// Show error boundary for 500s, not 404s
|
|
418
|
-
return !(axios.isAxiosError(error) && error.response?.status === 404);
|
|
419
|
-
},
|
|
420
|
-
retry: (failureCount, error) => {
|
|
421
|
-
// Don't retry 4xx errors
|
|
422
|
-
if (axios.isAxiosError(error) && error.response?.status < 500) return false;
|
|
423
|
-
return failureCount < 3;
|
|
424
|
-
},
|
|
425
|
-
});
|
|
187
|
+
const result = await Users.aggregate<ResultType>()
|
|
188
|
+
.match({ active: true })
|
|
189
|
+
.group({ _id: "$department", count: { $sum: 1 } })
|
|
190
|
+
.project({ department: "$_id", count: 1 });
|
|
426
191
|
```
|
|
427
192
|
|
|
428
|
-
|
|
193
|
+
**Indexes:**
|
|
429
194
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
|
437
207
|
```
|
|
438
208
|
|
|
439
|
-
|
|
209
|
+
**Verify query plans:**
|
|
440
210
|
|
|
441
211
|
```typescript
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
enabled: !!id, // Only fetch when id exists
|
|
446
|
-
});
|
|
212
|
+
const explanation = await ShiftModel.find(query).explain("executionStats");
|
|
213
|
+
// Check: totalDocsExamined ≈ totalDocsReturned
|
|
214
|
+
// Good: stage 'IXSCAN'; Bad: stage 'COLLSCAN'
|
|
447
215
|
```
|
|
448
216
|
|
|
449
|
-
|
|
217
|
+
**Transactions:**
|
|
450
218
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
+
```
|
|
454
232
|
|
|
455
|
-
##
|
|
233
|
+
## Repository Pattern
|
|
456
234
|
|
|
457
|
-
|
|
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
|
+
```
|
|
458
243
|
|
|
459
|
-
<!-- Source: .ruler/
|
|
244
|
+
<!-- Source: .ruler/backend/errorHandling.md -->
|
|
460
245
|
|
|
461
|
-
# Error Handling
|
|
246
|
+
# Error Handling
|
|
462
247
|
|
|
463
|
-
##
|
|
248
|
+
## Service Errors
|
|
464
249
|
|
|
465
250
|
```typescript
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
},
|
|
473
|
-
retry: (failureCount, error) => {
|
|
474
|
-
// Don't retry 4xx errors
|
|
475
|
-
if (axios.isAxiosError(error) && error.response?.status < 500) return false;
|
|
476
|
-
return failureCount < 3;
|
|
477
|
-
},
|
|
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,
|
|
478
257
|
});
|
|
479
258
|
```
|
|
480
259
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
const { data, isLoading, isError, refetch } = useGetData();
|
|
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
|
|
486
264
|
|
|
487
|
-
|
|
488
|
-
if (isError) return <ErrorState onRetry={refetch} />;
|
|
265
|
+
## Controller Translation
|
|
489
266
|
|
|
490
|
-
|
|
491
|
-
|
|
267
|
+
```typescript
|
|
268
|
+
@UseFilters(HttpExceptionFilter)
|
|
269
|
+
@Controller("shifts")
|
|
270
|
+
export class ShiftController {}
|
|
492
271
|
```
|
|
493
272
|
|
|
494
|
-
##
|
|
273
|
+
## Background Job Errors
|
|
495
274
|
|
|
496
275
|
```typescript
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
onError: (error) => {
|
|
505
|
-
logError("CREATE_DOCUMENT_FAILURE", error);
|
|
506
|
-
showErrorToast("Failed to create document");
|
|
507
|
-
},
|
|
508
|
-
});
|
|
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
|
+
}
|
|
509
283
|
}
|
|
510
284
|
```
|
|
511
285
|
|
|
512
|
-
|
|
286
|
+
<!-- Source: .ruler/backend/notifications.md -->
|
|
287
|
+
|
|
288
|
+
# Notifications
|
|
289
|
+
|
|
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:**
|
|
513
295
|
|
|
514
296
|
```typescript
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
+
};
|
|
520
303
|
|
|
521
|
-
|
|
522
|
-
const validated = formSchema.parse(formData);
|
|
523
|
-
} catch (error) {
|
|
524
|
-
if (error instanceof z.ZodError) {
|
|
525
|
-
error.errors.forEach((err) => showFieldError(err.path.join("."), err.message));
|
|
526
|
-
}
|
|
527
|
-
}
|
|
304
|
+
await adapter.enqueue(TRIGGER_NOTIFICATION_JOB_NAME, jobData, { session });
|
|
528
305
|
```
|
|
529
306
|
|
|
530
|
-
|
|
307
|
+
<!-- Source: .ruler/backend/restApiDesign.md -->
|
|
531
308
|
|
|
532
|
-
|
|
533
|
-
export class ErrorBoundary extends Component<Props, State> {
|
|
534
|
-
state = { hasError: false };
|
|
309
|
+
# REST API Design
|
|
535
310
|
|
|
536
|
-
|
|
537
|
-
return { hasError: true, error };
|
|
538
|
-
}
|
|
311
|
+
## JSON:API Specification
|
|
539
312
|
|
|
540
|
-
|
|
541
|
-
logEvent("ERROR_BOUNDARY", { error: error.message });
|
|
542
|
-
}
|
|
313
|
+
Follow [JSON:API spec](https://jsonapi.org/).
|
|
543
314
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
315
|
+
```json
|
|
316
|
+
{
|
|
317
|
+
"data": [
|
|
318
|
+
{
|
|
319
|
+
"id": "1",
|
|
320
|
+
"type": "worker",
|
|
321
|
+
"attributes": { "firstName": "Alex", "lastName": "Smith" }
|
|
322
|
+
}
|
|
323
|
+
]
|
|
547
324
|
}
|
|
548
325
|
```
|
|
549
326
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
-
|
|
553
|
-
-
|
|
554
|
-
- **Log for debugging** - Include context
|
|
555
|
-
- **Provide recovery actions** - Retry, dismiss, navigate
|
|
556
|
-
- **Different strategies** - Error boundary vs inline vs retry
|
|
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
|
|
557
331
|
|
|
558
|
-
|
|
332
|
+
## URLs
|
|
559
333
|
|
|
560
|
-
|
|
334
|
+
```text
|
|
335
|
+
GET /urgent-shifts # lowercase kebab-case, plural nouns
|
|
336
|
+
GET /workers/:workerId/shifts
|
|
337
|
+
POST /workers/:workerId/referral-codes
|
|
338
|
+
```
|
|
561
339
|
|
|
562
|
-
##
|
|
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
|
|
563
367
|
|
|
564
368
|
```text
|
|
565
|
-
|
|
566
|
-
├── api/ # Data fetching hooks
|
|
567
|
-
│ ├── useGetFeature.ts
|
|
568
|
-
│ ├── useUpdateFeature.ts
|
|
569
|
-
│ └── useDeleteFeature.ts
|
|
570
|
-
├── components/ # Feature-specific components
|
|
571
|
-
│ ├── FeatureCard.tsx
|
|
572
|
-
│ ├── FeatureList.tsx
|
|
573
|
-
│ └── FeatureHeader.tsx
|
|
574
|
-
├── hooks/ # Feature-specific hooks (non-API)
|
|
575
|
-
│ ├── useFeatureLogic.ts
|
|
576
|
-
│ └── useFeatureState.ts
|
|
577
|
-
├── utils/ # Feature utilities
|
|
578
|
-
│ ├── formatFeature.ts
|
|
579
|
-
│ ├── formatFeature.test.ts
|
|
580
|
-
│ └── validateFeature.ts
|
|
581
|
-
├── __tests__/ # Integration tests (optional)
|
|
582
|
-
│ └── FeatureFlow.test.tsx
|
|
583
|
-
├── Page.tsx # Main page component
|
|
584
|
-
├── Router.tsx # Feature routes
|
|
585
|
-
├── paths.ts # Route paths
|
|
586
|
-
├── types.ts # Shared types
|
|
587
|
-
├── constants.ts # Constants
|
|
588
|
-
└── README.md # Feature documentation (optional)
|
|
369
|
+
GET /shifts?filter[verified]=true&sort=startDate,-urgency&page[cursor]=abc&page[size]=50
|
|
589
370
|
```
|
|
590
371
|
|
|
591
|
-
|
|
372
|
+
- Cursor-based pagination only (not offset)
|
|
373
|
+
- Avoid count totals (performance)
|
|
374
|
+
- Only implement filters/sorts clients need
|
|
375
|
+
|
|
376
|
+
## Contracts
|
|
592
377
|
|
|
593
|
-
-
|
|
594
|
-
-
|
|
595
|
-
- Utils: `camelCase.ts` (`formatDate.ts`)
|
|
596
|
-
- Types: `camelCase.types.ts` (`user.types.ts`)
|
|
597
|
-
- Tests: `ComponentName.test.tsx`
|
|
378
|
+
- Add contracts to `contract-<repo-name>` package
|
|
379
|
+
- Use `ts-rest` with composable Zod schemas
|
|
598
380
|
|
|
599
|
-
<!-- Source: .ruler/
|
|
381
|
+
<!-- Source: .ruler/backend/serviceTests.md -->
|
|
600
382
|
|
|
601
|
-
#
|
|
383
|
+
# Service Tests (Primary Testing Approach)
|
|
602
384
|
|
|
603
|
-
|
|
385
|
+
Test the public contract (REST endpoints, events) with real local dependencies (Postgres, Mongo, Redis). Fake slow/external services (Zendesk, Stripe).
|
|
604
386
|
|
|
605
387
|
```typescript
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
+
```
|
|
610
409
|
|
|
611
|
-
|
|
612
|
-
// 1. Hooks
|
|
613
|
-
const { data, isLoading, error } = useGetUser(userId);
|
|
614
|
-
const [isEditing, setIsEditing] = useState(false);
|
|
410
|
+
**Qualities:** One behavior per test, no shared setup, no mocking, <1 second, parallelizable.
|
|
615
411
|
|
|
616
|
-
|
|
617
|
-
const displayName = formatName(data);
|
|
412
|
+
<!-- Source: .ruler/common/commitMessages.md -->
|
|
618
413
|
|
|
619
|
-
|
|
620
|
-
const handleSave = useCallback(async () => {
|
|
621
|
-
await saveUser(data);
|
|
622
|
-
onUpdate?.(data);
|
|
623
|
-
}, [data, onUpdate]);
|
|
414
|
+
# Commit Messages
|
|
624
415
|
|
|
625
|
-
|
|
626
|
-
if (isLoading) return <Loading />;
|
|
627
|
-
if (error) return <Error message={error.message} />;
|
|
628
|
-
if (!data) return <NotFound />;
|
|
416
|
+
Follow Conventional Commits 1.0 spec for commit messages and PR titles.
|
|
629
417
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|
|
638
430
|
```
|
|
639
431
|
|
|
640
|
-
|
|
432
|
+
<!-- Source: .ruler/common/featureFlags.md -->
|
|
641
433
|
|
|
642
|
-
|
|
643
|
-
- Props interface: `Props` (co-located with component) or `ComponentNameProps` (exported/shared)
|
|
644
|
-
- Event handlers: `handle*` (`handleClick`, `handleSubmit`)
|
|
645
|
-
- Boolean props: `is*`, `has*`, `should*`
|
|
434
|
+
# Feature Flags
|
|
646
435
|
|
|
647
|
-
|
|
436
|
+
**Naming:** `YYYY-MM-[kind]-[subject]` (e.g., `2024-03-release-new-booking-flow`)
|
|
648
437
|
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
}
|
|
438
|
+
| Kind | Purpose |
|
|
439
|
+
| ------------ | ------------------------ |
|
|
440
|
+
| `release` | Gradual rollout to 100% |
|
|
441
|
+
| `enable` | Kill switch |
|
|
442
|
+
| `experiment` | Trial for small audience |
|
|
443
|
+
| `configure` | Runtime config |
|
|
656
444
|
|
|
657
|
-
|
|
658
|
-
type ButtonProps = { variant: "link"; href: string } | { variant: "button"; onClick: () => void };
|
|
445
|
+
**Rules:**
|
|
659
446
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
|
666
453
|
|
|
667
|
-
|
|
454
|
+
<!-- Source: .ruler/common/loggingObservability.md -->
|
|
668
455
|
|
|
669
|
-
|
|
670
|
-
// Container/Presentational
|
|
671
|
-
export function UserListContainer() {
|
|
672
|
-
const { data, isLoading, error } = useUsers();
|
|
673
|
-
if (isLoading) return <Loading />;
|
|
674
|
-
if (error) return <Error />;
|
|
675
|
-
return <UserList users={data} />;
|
|
676
|
-
}
|
|
456
|
+
# Logging & Observability
|
|
677
457
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
// Render props
|
|
688
|
-
<DataProvider>
|
|
689
|
-
{({ data, isLoading }) => (
|
|
690
|
-
isLoading ? <Loading /> : <Display data={data} />
|
|
691
|
-
)}
|
|
692
|
-
</DataProvider>
|
|
693
|
-
```
|
|
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 |
|
|
694
466
|
|
|
695
|
-
##
|
|
467
|
+
## Best Practices
|
|
696
468
|
|
|
697
469
|
```typescript
|
|
698
|
-
//
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
+
```
|
|
702
481
|
|
|
703
|
-
|
|
704
|
-
interface Props {
|
|
705
|
-
children: (data: Data) => ReactNode;
|
|
706
|
-
}
|
|
482
|
+
**Never log:** PII, PHI, tokens, secrets, SSN, account numbers, entire request/response/headers.
|
|
707
483
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
}
|
|
484
|
+
Use metrics for counting:
|
|
485
|
+
|
|
486
|
+
```typescript
|
|
487
|
+
datadogMetrics.increment("negotiation.errors", { state: "New York" });
|
|
712
488
|
```
|
|
713
489
|
|
|
714
|
-
|
|
490
|
+
<!-- Source: .ruler/common/pullRequests.md -->
|
|
715
491
|
|
|
716
|
-
|
|
717
|
-
// ✅ Proper keys
|
|
718
|
-
{users.map((user) => <UserCard key={user.id} user={user} />)}
|
|
492
|
+
# Pull Requests
|
|
719
493
|
|
|
720
|
-
|
|
721
|
-
{users.length === 0 ? <EmptyState /> : users.map(...)}
|
|
494
|
+
**Requirements:**
|
|
722
495
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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
|
|
726
501
|
|
|
727
|
-
|
|
502
|
+
**Description:** Link ticket, context, reasoning, areas of concern.
|
|
728
503
|
|
|
729
|
-
|
|
730
|
-
// Simple conditional
|
|
731
|
-
{isLoading && <Spinner />}
|
|
504
|
+
<!-- Source: .ruler/common/security.md -->
|
|
732
505
|
|
|
733
|
-
|
|
734
|
-
{isError ? <Error /> : <Success />}
|
|
506
|
+
# Security
|
|
735
507
|
|
|
736
|
-
|
|
737
|
-
{isLoading ? <Loading /> : isError ? <Error /> : <Data />}
|
|
508
|
+
**Secrets:**
|
|
738
509
|
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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 -->
|
|
744
517
|
|
|
745
|
-
|
|
518
|
+
# Structured Concurrency
|
|
746
519
|
|
|
747
520
|
```typescript
|
|
748
|
-
//
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
return (
|
|
758
|
-
<>
|
|
759
|
-
<CountDisplay count={count} />
|
|
760
|
-
<TextInput value={text} onChange={setText} />
|
|
761
|
-
</>
|
|
762
|
-
);
|
|
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
|
+
}
|
|
763
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");
|
|
764
536
|
```
|
|
765
537
|
|
|
766
|
-
|
|
538
|
+
<!-- Source: .ruler/common/testing.md -->
|
|
767
539
|
|
|
768
|
-
|
|
769
|
-
- **Composition over Props** - Use children and compound components
|
|
770
|
-
- **Colocate State** - Keep state close to where it's used
|
|
771
|
-
- **Type Everything** - Full TypeScript coverage
|
|
772
|
-
- **Test Behavior** - Test user interactions, not implementation
|
|
540
|
+
# Testing
|
|
773
541
|
|
|
774
|
-
|
|
542
|
+
## Unit Tests
|
|
775
543
|
|
|
776
|
-
|
|
544
|
+
Use when: error handling hard to trigger black-box, concurrency scenarios, >5 variations, pure function logic.
|
|
777
545
|
|
|
778
|
-
|
|
779
|
-
- Prefer inline JSX over extracted variables
|
|
780
|
-
- Use custom hooks to encapsulate and reuse stateful logic
|
|
781
|
-
- Use Zod for request/response schemas in data-fetching hooks
|
|
782
|
-
- Use react-hook-form with Zod resolver for forms
|
|
783
|
-
- Use date-fns for date operations
|
|
546
|
+
## Conventions
|
|
784
547
|
|
|
785
|
-
|
|
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
|
|
786
553
|
|
|
787
|
-
|
|
554
|
+
<!-- Source: .ruler/common/typeScript.md -->
|
|
788
555
|
|
|
789
|
-
|
|
556
|
+
# TypeScript
|
|
790
557
|
|
|
791
|
-
|
|
792
|
-
2. **Use theme tokens** - Never hardcode colors/spacing
|
|
793
|
-
3. **Type-safe theme access** - Use `sx={(theme) => ({...})}`
|
|
794
|
-
4. **Use semantic names** - `theme.palette.text.primary`, not `common.white`
|
|
795
|
-
5. **Check Storybook first** - It's the single source of truth
|
|
558
|
+
## Naming Conventions
|
|
796
559
|
|
|
797
|
-
|
|
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` |
|
|
798
565
|
|
|
799
|
-
|
|
566
|
+
## Core Rules
|
|
800
567
|
|
|
801
|
-
-
|
|
802
|
-
-
|
|
803
|
-
-
|
|
804
|
-
-
|
|
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`
|
|
805
578
|
|
|
806
|
-
##
|
|
579
|
+
## Types
|
|
807
580
|
|
|
808
581
|
```typescript
|
|
809
|
-
//
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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;
|
|
822
595
|
```
|
|
823
596
|
|
|
824
|
-
|
|
597
|
+
**Type Techniques:**
|
|
825
598
|
|
|
826
|
-
|
|
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
|
|
827
607
|
|
|
828
608
|
```typescript
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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
|
+
}
|
|
832
627
|
```
|
|
833
628
|
|
|
834
|
-
|
|
629
|
+
## Objects & Arrays
|
|
835
630
|
|
|
836
631
|
```typescript
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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
|
+
}
|
|
842
643
|
```
|
|
843
644
|
|
|
844
|
-
##
|
|
645
|
+
## Async
|
|
845
646
|
|
|
846
647
|
```typescript
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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
|
+
}
|
|
851
662
|
```
|
|
852
663
|
|
|
853
|
-
##
|
|
664
|
+
## Classes
|
|
854
665
|
|
|
855
666
|
```typescript
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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
|
+
}
|
|
861
676
|
```
|
|
862
677
|
|
|
863
|
-
|
|
678
|
+
<!-- Source: .ruler/frontend/customHooks.md -->
|
|
679
|
+
|
|
680
|
+
# Custom Hooks
|
|
864
681
|
|
|
865
|
-
|
|
866
|
-
❌ Avoid abbreviations: `p`, `px`, `my`
|
|
682
|
+
## Naming
|
|
867
683
|
|
|
868
|
-
|
|
684
|
+
- Prefix with `use`
|
|
685
|
+
- Boolean: `useIs*`, `useHas*`, `useCan*`
|
|
686
|
+
- Data: `useGet*`, `use*Data`
|
|
687
|
+
- Actions: `useSubmit*`, `useCreate*`
|
|
869
688
|
|
|
870
|
-
|
|
689
|
+
## Structure
|
|
871
690
|
|
|
872
691
|
```typescript
|
|
873
|
-
|
|
692
|
+
export function useFeature(params: Params, options: Options = {}) {
|
|
693
|
+
const query = useQuery(...);
|
|
694
|
+
const computed = useMemo(() => transform(query.data), [query.data]);
|
|
695
|
+
const handleAction = useCallback(async () => { ... }, []);
|
|
696
|
+
|
|
697
|
+
return { data: computed, isLoading: query.isLoading, handleAction };
|
|
698
|
+
}
|
|
874
699
|
```
|
|
875
700
|
|
|
876
|
-
##
|
|
701
|
+
## Shared State with Constate
|
|
877
702
|
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
- Full property names, not abbreviations
|
|
703
|
+
```typescript
|
|
704
|
+
import constate from "constate";
|
|
881
705
|
|
|
882
|
-
|
|
706
|
+
function useFilters() {
|
|
707
|
+
const [filters, setFilters] = useState<Filters>({});
|
|
708
|
+
return { filters, setFilters };
|
|
709
|
+
}
|
|
883
710
|
|
|
884
|
-
|
|
711
|
+
export const [FiltersProvider, useFiltersContext] = constate(useFilters);
|
|
712
|
+
```
|
|
885
713
|
|
|
886
|
-
|
|
714
|
+
Use constate for: sharing state between siblings, feature-level state.
|
|
715
|
+
Don't use for: server state (use React Query), simple parent-child (use props).
|
|
887
716
|
|
|
888
|
-
|
|
717
|
+
<!-- Source: .ruler/frontend/dataFetching.md -->
|
|
889
718
|
|
|
890
|
-
|
|
719
|
+
# Data Fetching
|
|
891
720
|
|
|
892
|
-
|
|
893
|
-
2. **Integration** - Most valuable, test features not isolated components
|
|
894
|
-
3. **Unit** - For pure helpers/utilities only
|
|
895
|
-
4. **E2E** - Critical flows only
|
|
721
|
+
## Core Rules
|
|
896
722
|
|
|
897
|
-
|
|
723
|
+
1. Use React Query for all API calls
|
|
724
|
+
2. Define Zod schemas for all request/response types
|
|
725
|
+
3. Use `isLoading`, `isError`, `isSuccess`—don't create custom state
|
|
726
|
+
4. Use `enabled` option for conditional fetching
|
|
727
|
+
5. Use `invalidateQueries` (not `refetch`) for disabled queries
|
|
898
728
|
|
|
899
|
-
##
|
|
729
|
+
## Hook Pattern
|
|
900
730
|
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
731
|
+
```typescript
|
|
732
|
+
// Define in: FeatureName/api/useGetFeature.ts
|
|
733
|
+
const responseSchema = z.object({
|
|
734
|
+
id: z.string(),
|
|
735
|
+
name: z.string(),
|
|
736
|
+
});
|
|
905
737
|
|
|
906
|
-
|
|
738
|
+
export type FeatureResponse = z.infer<typeof responseSchema>;
|
|
907
739
|
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
740
|
+
export function useGetFeature(id: string, options = {}) {
|
|
741
|
+
return useGetQuery({
|
|
742
|
+
url: `feature/${id}`,
|
|
743
|
+
responseSchema,
|
|
744
|
+
enabled: !!id,
|
|
745
|
+
meta: { logErrorMessage: APP_EVENTS.GET_FEATURE_FAILURE },
|
|
746
|
+
...options,
|
|
913
747
|
});
|
|
748
|
+
}
|
|
749
|
+
```
|
|
914
750
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
751
|
+
## Query Keys
|
|
752
|
+
|
|
753
|
+
Include URL and params for predictable cache invalidation:
|
|
754
|
+
|
|
755
|
+
```typescript
|
|
756
|
+
queryKey: ["users", userId];
|
|
757
|
+
queryKey: ["users", userId, "posts"];
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
## Conditional Fetching
|
|
761
|
+
|
|
762
|
+
```typescript
|
|
763
|
+
const { data } = useGetFeature(
|
|
764
|
+
{ id: dependencyData?.id },
|
|
765
|
+
{ enabled: isDefined(dependencyData?.id) },
|
|
766
|
+
);
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
## Mutations
|
|
770
|
+
|
|
771
|
+
```typescript
|
|
772
|
+
export function useCreateItem() {
|
|
773
|
+
const queryClient = useQueryClient();
|
|
774
|
+
return useMutation({
|
|
775
|
+
mutationFn: (data: CreateItemRequest) => api.post("/items", data),
|
|
776
|
+
onSuccess: () => queryClient.invalidateQueries(["items"]),
|
|
777
|
+
onError: (error) => logError("CREATE_ITEM_FAILURE", error),
|
|
920
778
|
});
|
|
921
|
-
}
|
|
779
|
+
}
|
|
922
780
|
```
|
|
923
781
|
|
|
924
|
-
|
|
782
|
+
<!-- Source: .ruler/frontend/e2eTesting.md -->
|
|
783
|
+
|
|
784
|
+
# E2E Testing (Playwright)
|
|
785
|
+
|
|
786
|
+
## Core Rules
|
|
925
787
|
|
|
926
|
-
|
|
788
|
+
- Test critical user flows only—not exhaustive scenarios
|
|
789
|
+
- Each test sets up its own data (no shared state between tests)
|
|
790
|
+
- Mock feature flags and third-party services
|
|
791
|
+
- Use user-centric locators (role, label, text)—avoid CSS selectors
|
|
927
792
|
|
|
928
|
-
##
|
|
793
|
+
## Locator Priority
|
|
929
794
|
|
|
930
|
-
1. `getByRole`
|
|
931
|
-
2. `
|
|
932
|
-
3. `
|
|
933
|
-
4. `
|
|
795
|
+
1. `page.getByRole()`
|
|
796
|
+
2. `page.getByLabel()`
|
|
797
|
+
3. `page.getByPlaceholder()`
|
|
798
|
+
4. `page.getByText()`
|
|
799
|
+
5. `page.getByTestId()` (last resort)
|
|
800
|
+
|
|
801
|
+
## Assertions
|
|
934
802
|
|
|
935
803
|
```typescript
|
|
936
|
-
// ✅
|
|
937
|
-
|
|
938
|
-
screen.getByLabelText("Email");
|
|
804
|
+
// ✅ Assert visibility
|
|
805
|
+
await expect(page.getByText("Submit")).toBeVisible();
|
|
939
806
|
|
|
940
|
-
// ❌
|
|
941
|
-
|
|
942
|
-
screen.getByTestId("element");
|
|
807
|
+
// ❌ Don't assert DOM attachment
|
|
808
|
+
await expect(page.getByText("Submit")).toBeAttached();
|
|
943
809
|
```
|
|
944
810
|
|
|
945
|
-
##
|
|
811
|
+
## Avoid
|
|
812
|
+
|
|
813
|
+
- Hard-coded timeouts (`page.waitForTimeout`)
|
|
814
|
+
- Testing loading states (non-deterministic)
|
|
815
|
+
- Shared data between tests
|
|
816
|
+
- CSS/XPath selectors
|
|
817
|
+
|
|
818
|
+
<!-- Source: .ruler/frontend/errorHandling.md -->
|
|
819
|
+
|
|
820
|
+
# Error Handling
|
|
821
|
+
|
|
822
|
+
## Component Level
|
|
946
823
|
|
|
947
824
|
```typescript
|
|
948
|
-
|
|
949
|
-
const
|
|
950
|
-
const onSubmit = vi.fn();
|
|
951
|
-
render(<Form onSubmit={onSubmit} />);
|
|
825
|
+
export function DataComponent() {
|
|
826
|
+
const { data, isLoading, isError, refetch } = useGetData();
|
|
952
827
|
|
|
953
|
-
|
|
954
|
-
|
|
828
|
+
if (isLoading) return <LoadingState />;
|
|
829
|
+
if (isError) return <ErrorState onRetry={refetch} />;
|
|
955
830
|
|
|
956
|
-
|
|
957
|
-
}
|
|
831
|
+
return <DataDisplay data={data} />;
|
|
832
|
+
}
|
|
958
833
|
```
|
|
959
834
|
|
|
960
|
-
##
|
|
835
|
+
## Mutation Level
|
|
961
836
|
|
|
962
837
|
```typescript
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
838
|
+
useMutation({
|
|
839
|
+
mutationFn: createItem,
|
|
840
|
+
onSuccess: () => showSuccessToast("Created"),
|
|
841
|
+
onError: (error) => {
|
|
842
|
+
logError("CREATE_FAILURE", error);
|
|
843
|
+
showErrorToast("Failed to create");
|
|
844
|
+
},
|
|
969
845
|
});
|
|
970
846
|
```
|
|
971
847
|
|
|
972
|
-
##
|
|
973
|
-
|
|
974
|
-
**Always export factory functions, not static handlers:**
|
|
848
|
+
## Validation
|
|
975
849
|
|
|
976
850
|
```typescript
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
851
|
+
try {
|
|
852
|
+
const validated = formSchema.parse(formData);
|
|
853
|
+
} catch (error) {
|
|
854
|
+
if (error instanceof z.ZodError) {
|
|
855
|
+
error.errors.forEach((err) => showFieldError(err.path.join("."), err.message));
|
|
856
|
+
}
|
|
857
|
+
}
|
|
983
858
|
```
|
|
984
859
|
|
|
985
|
-
|
|
860
|
+
<!-- Source: .ruler/frontend/fileOrganization.md -->
|
|
986
861
|
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
862
|
+
# File Organization
|
|
863
|
+
|
|
864
|
+
```text
|
|
865
|
+
FeatureName/
|
|
866
|
+
├── api/ # Data fetching hooks
|
|
867
|
+
│ └── useGetFeature.ts
|
|
868
|
+
├── components/ # Feature-specific components
|
|
869
|
+
│ └── FeatureCard.tsx
|
|
870
|
+
├── hooks/ # Non-API hooks
|
|
871
|
+
│ └── useFeatureLogic.ts
|
|
872
|
+
├── utils/ # Utilities + tests
|
|
873
|
+
│ ├── formatFeature.ts
|
|
874
|
+
│ └── formatFeature.test.ts
|
|
875
|
+
├── Page.tsx # Main page
|
|
876
|
+
├── Router.tsx # Routes
|
|
877
|
+
├── paths.ts # Route constants
|
|
878
|
+
└── types.ts # Shared types
|
|
991
879
|
```
|
|
992
880
|
|
|
993
|
-
##
|
|
881
|
+
## Naming
|
|
882
|
+
|
|
883
|
+
- Components: `PascalCase.tsx`
|
|
884
|
+
- Hooks: `camelCase.ts` (prefixed with `use`)
|
|
885
|
+
- Utils: `camelCase.ts`
|
|
886
|
+
- Tests: `*.test.tsx`
|
|
887
|
+
|
|
888
|
+
<!-- Source: .ruler/frontend/frontendTechnologyStack.md -->
|
|
994
889
|
|
|
995
|
-
|
|
890
|
+
# Frontend Technology Stack
|
|
996
891
|
|
|
997
|
-
-
|
|
998
|
-
-
|
|
999
|
-
-
|
|
1000
|
-
-
|
|
892
|
+
- **React** with TypeScript (strict mode)
|
|
893
|
+
- **MUI** for UI components
|
|
894
|
+
- **React Query** (@tanstack/react-query) for data fetching
|
|
895
|
+
- **Zod** for runtime validation
|
|
896
|
+
- **Vitest** + **@testing-library/react** for testing
|
|
897
|
+
- **MSW** for API mocking
|
|
898
|
+
- **Playwright** for E2E tests
|
|
899
|
+
- **constate** for shared state
|
|
900
|
+
|
|
901
|
+
<!-- Source: .ruler/frontend/interactiveElements.md -->
|
|
902
|
+
|
|
903
|
+
# Interactive Elements
|
|
1001
904
|
|
|
1002
|
-
|
|
905
|
+
Never add `onClick` to `div` or `span`. Use:
|
|
1003
906
|
|
|
1004
|
-
-
|
|
1005
|
-
-
|
|
1006
|
-
-
|
|
907
|
+
- `<button>` for actions
|
|
908
|
+
- `<a>` (Link) for navigation
|
|
909
|
+
- MUI interactive components (`Button`, `IconButton`, `ListItemButton`)
|
|
1007
910
|
|
|
1008
|
-
|
|
911
|
+
This ensures proper accessibility: focus states, keyboard navigation, ARIA roles.
|
|
1009
912
|
|
|
1010
|
-
|
|
913
|
+
<!-- Source: .ruler/frontend/modalRoutes.md -->
|
|
1011
914
|
|
|
1012
|
-
|
|
915
|
+
# Modal Routes
|
|
1013
916
|
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
917
|
+
Modal visibility is driven by URL, not local state:
|
|
918
|
+
|
|
919
|
+
```typescript
|
|
920
|
+
<ModalRoute
|
|
921
|
+
path={`${basePath}/confirm`}
|
|
922
|
+
closeModalPath={basePath}
|
|
923
|
+
render={({ modalState }) => (
|
|
924
|
+
<ConfirmDialog modalState={modalState} />
|
|
925
|
+
)}
|
|
926
|
+
/>
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
Use `history.replace` (not `push`) when navigating between modals to avoid awkward back-button behavior.
|
|
930
|
+
|
|
931
|
+
<!-- Source: .ruler/frontend/reactComponents.md -->
|
|
932
|
+
|
|
933
|
+
# React Components
|
|
1019
934
|
|
|
1020
935
|
## Type vs Interface
|
|
1021
936
|
|
|
1022
937
|
```typescript
|
|
1023
|
-
//
|
|
938
|
+
// Use interface for: props, object shapes, extensible types
|
|
1024
939
|
interface UserCardProps {
|
|
1025
940
|
user: User;
|
|
1026
941
|
onSelect: (id: string) => void;
|
|
1027
942
|
}
|
|
943
|
+
```
|
|
1028
944
|
|
|
1029
|
-
|
|
1030
|
-
id: string;
|
|
1031
|
-
createdAt: string;
|
|
1032
|
-
}
|
|
945
|
+
## Structure Order
|
|
1033
946
|
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
947
|
+
```typescript
|
|
948
|
+
export function Component({ userId, onUpdate }: Props) {
|
|
949
|
+
// 1. Hooks
|
|
950
|
+
const { data, isLoading } = useGetUser(userId);
|
|
951
|
+
const [isEditing, setIsEditing] = useState(false);
|
|
952
|
+
|
|
953
|
+
// 2. Derived state (no useMemo for cheap operations)
|
|
954
|
+
const displayName = formatName(data);
|
|
1037
955
|
|
|
1038
|
-
//
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
956
|
+
// 3. Event handlers
|
|
957
|
+
const handleSave = useCallback(async () => { ... }, [deps]);
|
|
958
|
+
|
|
959
|
+
// 4. Early returns for loading/error/empty
|
|
960
|
+
if (isLoading) return <Loading />;
|
|
961
|
+
if (!data) return <NotFound />;
|
|
962
|
+
|
|
963
|
+
// 5. Main render
|
|
964
|
+
return <Card>...</Card>;
|
|
965
|
+
}
|
|
1043
966
|
```
|
|
1044
967
|
|
|
1045
968
|
## Naming Conventions
|
|
1046
969
|
|
|
970
|
+
- Components: `PascalCase`
|
|
971
|
+
- Event handlers: `handle*` (e.g., `handleClick`)
|
|
972
|
+
- Props interface: `Props` (co-located) or `ComponentNameProps` (exported)
|
|
973
|
+
|
|
974
|
+
## Component Guidelines
|
|
975
|
+
|
|
976
|
+
- **One file per component**—never extract JSX into local variables
|
|
977
|
+
- **Composition over configuration**—prefer `children` over many props
|
|
978
|
+
- **Presentational components**: stateless, UI-focused, no API calls
|
|
979
|
+
- **Container components**: feature logic, data fetching, state management
|
|
980
|
+
- Pass primitives as props, not entire API response objects
|
|
981
|
+
|
|
1047
982
|
```typescript
|
|
1048
|
-
//
|
|
1049
|
-
interface
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
// ✅
|
|
1054
|
-
interface
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
983
|
+
// ❌ Bad—coupled to API shape
|
|
984
|
+
interface Props {
|
|
985
|
+
shift: ShiftApiResponse;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// ✅ Good—only required fields
|
|
989
|
+
interface Props {
|
|
990
|
+
shiftId: string;
|
|
991
|
+
shiftPay: number;
|
|
992
|
+
workplaceId: string;
|
|
1058
993
|
}
|
|
1059
994
|
```
|
|
1060
995
|
|
|
1061
|
-
##
|
|
996
|
+
## Inline JSX and Handlers
|
|
1062
997
|
|
|
1063
998
|
```typescript
|
|
1064
|
-
//
|
|
1065
|
-
const
|
|
1066
|
-
|
|
1067
|
-
name: z.string(),
|
|
1068
|
-
});
|
|
999
|
+
// ❌ Don't extract JSX to variables
|
|
1000
|
+
const content = <p>Content</p>;
|
|
1001
|
+
return <>{content}</>;
|
|
1069
1002
|
|
|
1070
|
-
|
|
1003
|
+
// ✅ Keep inline or extract to new component file
|
|
1004
|
+
return <p>Content</p>;
|
|
1071
1005
|
|
|
1072
|
-
//
|
|
1073
|
-
const
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1006
|
+
// ❌ Don't extract handlers unnecessarily
|
|
1007
|
+
const handleChange = (e) => { ... };
|
|
1008
|
+
return <Input onChange={handleChange} />;
|
|
1009
|
+
|
|
1010
|
+
// ✅ Keep inline
|
|
1011
|
+
return <Input onChange={(e) => { ... }} />;
|
|
1077
1012
|
```
|
|
1078
1013
|
|
|
1079
|
-
##
|
|
1014
|
+
## List Rendering
|
|
1080
1015
|
|
|
1081
1016
|
```typescript
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
}
|
|
1017
|
+
// ✅ Always use stable keys
|
|
1018
|
+
{users.map((user) => <UserCard key={user.id} user={user} />)}
|
|
1085
1019
|
|
|
1086
|
-
//
|
|
1087
|
-
|
|
1088
|
-
console.log(data.name); // TypeScript knows data is User
|
|
1089
|
-
}
|
|
1020
|
+
// ❌ Never use index as key for dynamic lists
|
|
1021
|
+
{items.map((item, i) => <Item key={i} />)}
|
|
1090
1022
|
```
|
|
1091
1023
|
|
|
1092
|
-
|
|
1024
|
+
<!-- Source: .ruler/frontend/styling.md -->
|
|
1025
|
+
|
|
1026
|
+
# Styling
|
|
1027
|
+
|
|
1028
|
+
## Core Rules
|
|
1029
|
+
|
|
1030
|
+
1. **Always use `sx` prop**—never CSS/SCSS/SASS/styled()/makeStyles()
|
|
1031
|
+
2. **Use theme tokens**—never use raw string values for colors/spacing
|
|
1032
|
+
3. **Type-safe theme access**—use `sx={(theme) => ({...})}`
|
|
1033
|
+
4. **Use semantic token names**—`theme.palette.text.primary`, not `common.white`
|
|
1034
|
+
|
|
1035
|
+
## Patterns
|
|
1093
1036
|
|
|
1094
1037
|
```typescript
|
|
1095
|
-
//
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1038
|
+
// ✅ Correct
|
|
1039
|
+
<Box sx={(theme) => ({
|
|
1040
|
+
backgroundColor: theme.palette.background.primary,
|
|
1041
|
+
color: theme.palette.text.secondary,
|
|
1042
|
+
padding: theme.spacing(4), // or just: padding: 4
|
|
1043
|
+
})} />
|
|
1100
1044
|
|
|
1101
|
-
|
|
1045
|
+
// ❌ Wrong
|
|
1046
|
+
<Box sx={{
|
|
1047
|
+
backgroundColor: "red", // raw color
|
|
1048
|
+
padding: "16px", // raw size
|
|
1049
|
+
color: "text.secondary", // string path (not type-safe)
|
|
1050
|
+
}} />
|
|
1102
1051
|
```
|
|
1103
1052
|
|
|
1104
|
-
##
|
|
1053
|
+
## Spacing
|
|
1054
|
+
|
|
1055
|
+
Use theme spacing indices 1-12:
|
|
1105
1056
|
|
|
1106
1057
|
```typescript
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
type RequiredUser = Required<User>;
|
|
1110
|
-
type ReadonlyUser = Readonly<User>;
|
|
1111
|
-
type UserIdOnly = Pick<User, "id" | "name">;
|
|
1112
|
-
type UserWithoutPassword = Omit<User, "password">;
|
|
1113
|
-
type UserMap = Record<string, User>;
|
|
1114
|
-
type DefinedString = NonNullable<string | null>;
|
|
1115
|
-
|
|
1116
|
-
// Function utilities
|
|
1117
|
-
type GetUserResult = ReturnType<typeof getUser>;
|
|
1118
|
-
type UpdateUserParams = Parameters<typeof updateUser>;
|
|
1058
|
+
<Box sx={{ padding: 5 }} />
|
|
1059
|
+
<Box sx={{ marginX: 4 }} />
|
|
1119
1060
|
```
|
|
1120
1061
|
|
|
1121
|
-
|
|
1062
|
+
Use `rem` for fonts/heights (scales with user zoom), spacing indices for padding/margin.
|
|
1122
1063
|
|
|
1123
|
-
|
|
1124
|
-
// ❌ Bad - Loses type safety
|
|
1125
|
-
function process(data: any) { ... }
|
|
1064
|
+
## Property Names
|
|
1126
1065
|
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
if (typeof data === "object" && data !== null) {
|
|
1130
|
-
// Type guard
|
|
1131
|
-
}
|
|
1132
|
-
}
|
|
1066
|
+
- ✅ Use full names: `padding`, `paddingX`, `marginY`
|
|
1067
|
+
- ❌ Avoid abbreviations: `p`, `px`, `my`
|
|
1133
1068
|
|
|
1134
|
-
|
|
1135
|
-
function process<T extends { value: string }>(data: T) {
|
|
1136
|
-
return data.value;
|
|
1137
|
-
}
|
|
1069
|
+
## Pseudo-classes
|
|
1138
1070
|
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
}
|
|
1071
|
+
```typescript
|
|
1072
|
+
<Box sx={(theme) => ({
|
|
1073
|
+
"&:hover": { backgroundColor: theme.palette.primary.dark },
|
|
1074
|
+
"&:disabled": { opacity: 0.5 },
|
|
1075
|
+
"& .MuiTypography-root": { color: theme.palette.text.secondary },
|
|
1076
|
+
})} />
|
|
1145
1077
|
```
|
|
1146
1078
|
|
|
1147
|
-
|
|
1079
|
+
<!-- Source: .ruler/frontend/testing.md -->
|
|
1148
1080
|
|
|
1149
|
-
|
|
1150
|
-
// Generic function
|
|
1151
|
-
function getFirst<T>(array: T[]): T | undefined {
|
|
1152
|
-
return array[0];
|
|
1153
|
-
}
|
|
1081
|
+
# Testing
|
|
1154
1082
|
|
|
1155
|
-
|
|
1156
|
-
interface ListProps<T> {
|
|
1157
|
-
items: T[];
|
|
1158
|
-
renderItem: (item: T) => ReactNode;
|
|
1159
|
-
}
|
|
1083
|
+
## Philosophy
|
|
1160
1084
|
|
|
1161
|
-
|
|
1162
|
-
return <>{items.map((item) => renderItem(item))}</>;
|
|
1163
|
-
}
|
|
1085
|
+
Focus on integration tests—test how components work together as users experience them.
|
|
1164
1086
|
|
|
1165
|
-
|
|
1166
|
-
function findById<T extends { id: string }>(items: T[], id: string): T | undefined {
|
|
1167
|
-
return items.find((item) => item.id === id);
|
|
1168
|
-
}
|
|
1169
|
-
```
|
|
1087
|
+
**Priority:** Static (TS/ESLint) → Integration → Unit (pure utilities only) → E2E (critical flows only)
|
|
1170
1088
|
|
|
1171
|
-
##
|
|
1089
|
+
## Test Structure
|
|
1172
1090
|
|
|
1173
1091
|
```typescript
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
| { status: "success"; data: T }
|
|
1179
|
-
| { status: "error"; error: Error };
|
|
1180
|
-
|
|
1181
|
-
// Automatic type narrowing
|
|
1182
|
-
if (state.status === "success") {
|
|
1183
|
-
console.log(state.data); // TypeScript knows data exists
|
|
1184
|
-
}
|
|
1092
|
+
describe("ComponentName", () => {
|
|
1093
|
+
it("should [behavior] when [condition]", async () => {
|
|
1094
|
+
const user = userEvent.setup();
|
|
1095
|
+
render(<Component />);
|
|
1185
1096
|
|
|
1186
|
-
|
|
1187
|
-
type Action = { type: "SET_USER"; payload: User } | { type: "CLEAR_USER" };
|
|
1097
|
+
await user.click(screen.getByRole("button", { name: "Submit" }));
|
|
1188
1098
|
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
return { ...state, user: null };
|
|
1195
|
-
}
|
|
1196
|
-
}
|
|
1099
|
+
await waitFor(() => {
|
|
1100
|
+
expect(screen.getByText("Success")).toBeInTheDocument();
|
|
1101
|
+
});
|
|
1102
|
+
});
|
|
1103
|
+
});
|
|
1197
1104
|
```
|
|
1198
1105
|
|
|
1199
|
-
##
|
|
1106
|
+
## Query Priority
|
|
1200
1107
|
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
return value.toUpperCase();
|
|
1206
|
-
}
|
|
1207
|
-
return value.toFixed(2);
|
|
1208
|
-
}
|
|
1108
|
+
1. `getByRole` (best for accessibility)
|
|
1109
|
+
2. `getByLabelText` (form fields)
|
|
1110
|
+
3. `getByText` (non-interactive content)
|
|
1111
|
+
4. `getByTestId` (last resort only)
|
|
1209
1112
|
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
} else {
|
|
1215
|
-
animal.fly();
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1113
|
+
```typescript
|
|
1114
|
+
// ✅ Prefer
|
|
1115
|
+
screen.getByRole("button", { name: /submit/i });
|
|
1116
|
+
screen.getByLabelText("Email");
|
|
1218
1117
|
|
|
1219
|
-
//
|
|
1220
|
-
|
|
1221
|
-
if (error instanceof Error) {
|
|
1222
|
-
console.log(error.message);
|
|
1223
|
-
}
|
|
1224
|
-
}
|
|
1118
|
+
// ❌ Avoid
|
|
1119
|
+
screen.getByTestId("submit-button");
|
|
1225
1120
|
```
|
|
1226
1121
|
|
|
1227
|
-
##
|
|
1228
|
-
|
|
1229
|
-
```typescript
|
|
1230
|
-
// Optional chaining
|
|
1231
|
-
const userName = user?.profile?.name;
|
|
1122
|
+
## MSW Handlers
|
|
1232
1123
|
|
|
1233
|
-
|
|
1234
|
-
const displayName = userName ?? "Anonymous";
|
|
1124
|
+
Export factory functions, not static handlers:
|
|
1235
1125
|
|
|
1236
|
-
|
|
1237
|
-
|
|
1126
|
+
```typescript
|
|
1127
|
+
// ✅ Good—flexible per test
|
|
1128
|
+
export const createUserHandler = (userData: User) =>
|
|
1129
|
+
rest.get("/api/user", (req, res, ctx) => res(ctx.json(userData)));
|
|
1238
1130
|
|
|
1239
|
-
//
|
|
1240
|
-
|
|
1241
|
-
type EventHandler = `on${Capitalize<string>}`;
|
|
1131
|
+
// Usage
|
|
1132
|
+
mockServer.use(createUserHandler(customData));
|
|
1242
1133
|
```
|
|
1243
1134
|
|
|
1244
|
-
##
|
|
1135
|
+
## What to Test
|
|
1245
1136
|
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
- **Zod** for runtime validation
|
|
1249
|
-
- **Type guards** over type assertions
|
|
1250
|
-
- **Const assertions** for readonly values
|
|
1251
|
-
- **Utility types** to transform existing types
|
|
1137
|
+
✅ Test: user interactions, all states (loading/success/error), integration between components
|
|
1138
|
+
❌ Don't test: implementation details, third-party libraries, styles
|