@valentia-ai-skills/framework 2.0.7 → 2.0.9
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/README.md +150 -6
- package/bin/cli.js +772 -56
- package/bin/code-audit-config.mjs +323 -0
- package/package.json +1 -1
- package/skills/global/aisupportapp-project-architecture/SKILL.md +1 -1
- package/skills/global/aisupportapp-project-conventions/SKILL.md +1 -1
- package/skills/global/aisupportapp-project-workflows/SKILL.md +1 -1
- package/skills/global/api-design/SKILL.md +1 -1
- package/skills/global/appointment-oas-app/SKILL.md +1 -1
- package/skills/global/code-quality-auditor/SKILL.md +704 -0
- package/skills/global/code-standards/SKILL.md +1 -1
- package/skills/global/codebase-legacy-intelligence/SKILL.md +1 -1
- package/skills/global/legacy-api-converter/SKILL.md +979 -0
- package/skills/global/legacy-redevelopment-planner/SKILL.md +622 -0
- package/skills/global/observability-integrations/SKILL.md +835 -0
- package/skills/global/project-scanner/SKILL.md +1 -1
- package/skills/global/ui-replication-engine/SKILL.md +591 -0
- package/skills/global/aisupportapp-test-installation/SKILL.md +0 -32
- package/skills/global/viteapp-core-workflows/SKILL.md +0 -32
|
@@ -0,0 +1,979 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: legacy-api-converter
|
|
3
|
+
description: Converts legacy .NET MVC API projects to Node.js microservices, module by module, using the intelligence package from codebase-legacy-intelligence and optionally db-intelligence as the source of truth. Produces production-grade microservices with API gateway, authentication service, shared contracts, inter-service communication, database access layer (supporting both ORM and raw stored procedure invocation on the existing database), security hardening, testing, Docker containerization, CI/CD templates, and structured logging. The skill recommends the optimal Node.js framework (NestJS, Express, or Fastify) based on project complexity and converts one module at a time, preserving every business rule from the legacy system. Use this skill whenever someone asks to: convert a .NET API to Node.js, migrate an MVC project to microservices, rewrite a C# backend in TypeScript, modernize a .NET API, convert controllers to Node.js routes, migrate stored procedure calls to Node.js, or rebuild a monolith as microservices. Also trigger when someone says things like "convert this .NET API to Node", "rewrite our C# backend", "migrate this MVC project", "turn this monolith into microservices", "I need this API in Node.js", or "convert module by module to Node". This skill requires the codebase-legacy-intelligence output and optionally the db-intelligence output as input — it does not scan code directly, it reads the pre-scanned intelligence.
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
scope: global
|
|
6
|
+
last_reviewed: 2026-04-02
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
name: legacy-api-converter
|
|
11
|
+
description: >
|
|
12
|
+
Converts legacy .NET MVC API projects to Node.js microservices, module by module, using the
|
|
13
|
+
intelligence package from codebase-legacy-intelligence and optionally db-intelligence as the
|
|
14
|
+
source of truth. Produces production-grade microservices with API gateway, authentication
|
|
15
|
+
service, shared contracts, inter-service communication, database access layer (supporting both
|
|
16
|
+
ORM and raw stored procedure invocation on the existing database), security hardening, testing,
|
|
17
|
+
Docker containerization, CI/CD templates, and structured logging. The skill recommends the
|
|
18
|
+
optimal Node.js framework (NestJS, Express, or Fastify) based on project complexity and
|
|
19
|
+
converts one module at a time, preserving every business rule from the legacy system. Use this
|
|
20
|
+
skill whenever someone asks to: convert a .NET API to Node.js, migrate an MVC project to
|
|
21
|
+
microservices, rewrite a C# backend in TypeScript, modernize a .NET API, convert controllers
|
|
22
|
+
to Node.js routes, migrate stored procedure calls to Node.js, or rebuild a monolith as
|
|
23
|
+
microservices. Also trigger when someone says things like "convert this .NET API to Node",
|
|
24
|
+
"rewrite our C# backend", "migrate this MVC project", "turn this monolith into microservices",
|
|
25
|
+
"I need this API in Node.js", or "convert module by module to Node". This skill requires the
|
|
26
|
+
codebase-legacy-intelligence output and optionally the db-intelligence output as input — it
|
|
27
|
+
does not scan code directly, it reads the pre-scanned intelligence.
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
# Legacy API Converter
|
|
31
|
+
|
|
32
|
+
You are a senior backend architect specializing in .NET-to-Node.js migrations. You take the complete intelligence from a legacy .NET MVC API — every endpoint, business rule, data model, stored procedure, and integration — and systematically convert it into production-grade Node.js microservices, one module at a time.
|
|
33
|
+
|
|
34
|
+
## Philosophy
|
|
35
|
+
|
|
36
|
+
Migration failures happen when teams try to "rewrite" instead of "convert." Rewriting means reimagining — which means losing business rules nobody remembered existed. Converting means preserving exact behavior in a new runtime. Every endpoint must return the same response shapes. Every business rule must produce the same outcomes. Every stored procedure call must work identically. The legacy intelligence package is your contract — if the intelligence says the old system does X, your Node.js service MUST do X.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Step 0: Load Intelligence & Assess
|
|
41
|
+
|
|
42
|
+
### Required Inputs
|
|
43
|
+
|
|
44
|
+
Before converting anything, you need the legacy intelligence package. Ask the user:
|
|
45
|
+
|
|
46
|
+
1. **Legacy intelligence location**: Check `.ai-skills/legacy-projects/{project}/` first, then `./{project}-intelligence/`, then ask.
|
|
47
|
+
2. **DB intelligence location** (optional but recommended): Check for `{project}-db-intelligence/` — if available, you get stored procedure logic and database schema.
|
|
48
|
+
3. **Which module to convert first**: Present the module list from `MASTER_SKILL.md` and let the user choose. Never start without explicit user selection.
|
|
49
|
+
|
|
50
|
+
### Read These Files (in order):
|
|
51
|
+
|
|
52
|
+
1. **manifest.json** — Project metadata, module list, statistics
|
|
53
|
+
2. **MASTER_SKILL.md** — Architecture overview, module map, cross-cutting concerns
|
|
54
|
+
3. **API_REGISTRY.md** — Every endpoint (this is your conversion contract)
|
|
55
|
+
4. **BUSINESS_RULES.md** — Every business rule (this is your logic contract)
|
|
56
|
+
5. **DATA_MODELS.md** — Entity schemas and relationships
|
|
57
|
+
6. **DEPENDENCIES.md** — External service integrations
|
|
58
|
+
7. **ENV_CONFIG.md** — Environment variables and configuration
|
|
59
|
+
8. **The selected module's `SKILL.md`** — Deep dive for the module being converted
|
|
60
|
+
|
|
61
|
+
If DB intelligence exists, also read:
|
|
62
|
+
9. **STORED_PROCEDURES.md** — SP definitions and business rules
|
|
63
|
+
10. **DB_BUSINESS_RULES.md** — Database-level rules
|
|
64
|
+
11. **SCHEMA.md** — Full database schema
|
|
65
|
+
|
|
66
|
+
### Framework Recommendation
|
|
67
|
+
|
|
68
|
+
After reading the intelligence, recommend the Node.js framework based on:
|
|
69
|
+
|
|
70
|
+
**Recommend NestJS when:**
|
|
71
|
+
- Project has 15+ modules or 50+ endpoints
|
|
72
|
+
- Heavy use of dependency injection in the .NET code
|
|
73
|
+
- Complex middleware pipelines
|
|
74
|
+
- Team is coming from .NET (NestJS feels familiar — decorators, modules, DI)
|
|
75
|
+
- Enterprise environment requiring structured patterns
|
|
76
|
+
|
|
77
|
+
**Recommend Fastify + TypeScript when:**
|
|
78
|
+
- Performance is a stated priority
|
|
79
|
+
- 5-15 modules, moderate complexity
|
|
80
|
+
- Team prefers lightweight frameworks
|
|
81
|
+
- High throughput API (thousands of requests/second)
|
|
82
|
+
|
|
83
|
+
**Recommend Express + TypeScript when:**
|
|
84
|
+
- Small project (<5 modules, <20 endpoints)
|
|
85
|
+
- Team already knows Express
|
|
86
|
+
- Simple CRUD APIs without complex middleware
|
|
87
|
+
- Rapid prototyping phase
|
|
88
|
+
|
|
89
|
+
Present your recommendation with reasoning:
|
|
90
|
+
> "Based on the intelligence package, this project has {N} modules, {N} endpoints, heavy DI usage, and complex middleware. I recommend **NestJS** because it mirrors the .NET patterns your team is familiar with and scales well for this complexity. Happy to go with a different choice if you prefer."
|
|
91
|
+
|
|
92
|
+
Wait for user confirmation before proceeding.
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Step 1: Foundation — Build Before Any Module
|
|
97
|
+
|
|
98
|
+
Before converting any business module, build the shared infrastructure. This is non-negotiable.
|
|
99
|
+
|
|
100
|
+
### 1.1 Monorepo Structure
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
{project}-services/
|
|
104
|
+
├── package.json ← Root workspace config
|
|
105
|
+
├── tsconfig.base.json ← Shared TypeScript config
|
|
106
|
+
├── docker-compose.yml ← Local development orchestration
|
|
107
|
+
├── docker-compose.prod.yml ← Production orchestration
|
|
108
|
+
├── .env.example ← From ENV_CONFIG.md
|
|
109
|
+
├── .gitignore
|
|
110
|
+
│
|
|
111
|
+
├── packages/
|
|
112
|
+
│ ├── contracts/ ← Shared DTOs, interfaces, error codes
|
|
113
|
+
│ │ ├── package.json
|
|
114
|
+
│ │ ├── tsconfig.json
|
|
115
|
+
│ │ └── src/
|
|
116
|
+
│ │ ├── dto/ ← Request/response DTOs from DATA_MODELS.md
|
|
117
|
+
│ │ ├── interfaces/ ← Shared interfaces
|
|
118
|
+
│ │ ├── errors/ ← Error codes and error classes
|
|
119
|
+
│ │ ├── events/ ← Inter-service event schemas
|
|
120
|
+
│ │ └── constants/ ← Shared enums, status codes
|
|
121
|
+
│ │
|
|
122
|
+
│ └── db-client/ ← Shared database access layer
|
|
123
|
+
│ ├── package.json
|
|
124
|
+
│ ├── tsconfig.json
|
|
125
|
+
│ └── src/
|
|
126
|
+
│ ├── prisma/ ← Prisma schema + client (if using ORM)
|
|
127
|
+
│ ├── stored-procedures/ ← SP invocation wrappers
|
|
128
|
+
│ └── connection.ts ← Database connection management
|
|
129
|
+
│
|
|
130
|
+
├── services/
|
|
131
|
+
│ ├── gateway/ ← API Gateway
|
|
132
|
+
│ ├── auth/ ← Authentication service
|
|
133
|
+
│ └── {module}/ ← One service per converted module
|
|
134
|
+
│
|
|
135
|
+
└── infrastructure/
|
|
136
|
+
├── docker/ ← Per-service Dockerfiles
|
|
137
|
+
├── ci/ ← CI/CD pipeline templates
|
|
138
|
+
└── k8s/ ← Kubernetes manifests (optional)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### 1.2 Shared Contracts Package
|
|
142
|
+
|
|
143
|
+
Generate from `DATA_MODELS.md` and `API_REGISTRY.md`:
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
// packages/contracts/src/dto/patient.dto.ts
|
|
147
|
+
// Generated from DATA_MODELS.md Entity: Patient
|
|
148
|
+
|
|
149
|
+
export interface CreatePatientDto {
|
|
150
|
+
nhi: string // Rule: 3 letters + 4 digits
|
|
151
|
+
firstName: string // Required, min 2 chars
|
|
152
|
+
lastName: string // Required, min 2 chars
|
|
153
|
+
dateOfBirth: string // ISO date, cannot be future
|
|
154
|
+
gender: 'Male' | 'Female' | 'Other' | 'Unknown'
|
|
155
|
+
email?: string // Required if notificationPref = 'email'
|
|
156
|
+
phone?: string // NZ format: +64 or 0 prefix
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface PatientResponseDto {
|
|
160
|
+
id: string
|
|
161
|
+
nhi: string
|
|
162
|
+
firstName: string
|
|
163
|
+
lastName: string
|
|
164
|
+
dateOfBirth: string
|
|
165
|
+
gender: string
|
|
166
|
+
email: string | null
|
|
167
|
+
phone: string | null
|
|
168
|
+
status: 'Active' | 'Inactive' | 'Suspended'
|
|
169
|
+
createdAt: string
|
|
170
|
+
updatedAt: string
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Response envelope — consistent across ALL services
|
|
174
|
+
export interface ApiResponse<T> {
|
|
175
|
+
success: boolean
|
|
176
|
+
data: T | null
|
|
177
|
+
error: ApiError | null
|
|
178
|
+
meta?: {
|
|
179
|
+
page?: number
|
|
180
|
+
pageSize?: number
|
|
181
|
+
totalCount?: number
|
|
182
|
+
totalPages?: number
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface ApiError {
|
|
187
|
+
code: string
|
|
188
|
+
message: string
|
|
189
|
+
details?: Record<string, string[]>
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
```typescript
|
|
194
|
+
// packages/contracts/src/errors/error-codes.ts
|
|
195
|
+
// Generated from BUSINESS_RULES.md error responses + API_REGISTRY.md error cases
|
|
196
|
+
|
|
197
|
+
export const ErrorCodes = {
|
|
198
|
+
// Auth
|
|
199
|
+
AUTH_INVALID_CREDENTIALS: 'AUTH_001',
|
|
200
|
+
AUTH_TOKEN_EXPIRED: 'AUTH_002',
|
|
201
|
+
AUTH_INSUFFICIENT_PERMISSIONS: 'AUTH_003',
|
|
202
|
+
|
|
203
|
+
// Patient
|
|
204
|
+
PATIENT_NOT_FOUND: 'PAT_001',
|
|
205
|
+
PATIENT_NHI_DUPLICATE: 'PAT_002',
|
|
206
|
+
PATIENT_NHI_INVALID_FORMAT: 'PAT_003',
|
|
207
|
+
|
|
208
|
+
// Appointment
|
|
209
|
+
APPOINTMENT_DOUBLE_BOOKING: 'APT_001',
|
|
210
|
+
APPOINTMENT_PAST_DATE: 'APT_002',
|
|
211
|
+
APPOINTMENT_SLOT_UNAVAILABLE: 'APT_003',
|
|
212
|
+
|
|
213
|
+
// ... map every error from API_REGISTRY.md
|
|
214
|
+
} as const
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
// packages/contracts/src/events/index.ts
|
|
219
|
+
// Inter-service event contracts
|
|
220
|
+
|
|
221
|
+
export interface PatientCreatedEvent {
|
|
222
|
+
type: 'patient.created'
|
|
223
|
+
payload: { patientId: string; nhi: string; createdBy: string }
|
|
224
|
+
timestamp: string
|
|
225
|
+
correlationId: string
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface AppointmentBookedEvent {
|
|
229
|
+
type: 'appointment.booked'
|
|
230
|
+
payload: { appointmentId: string; patientId: string; providerId: string; dateTime: string }
|
|
231
|
+
timestamp: string
|
|
232
|
+
correlationId: string
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### 1.3 Database Client Package — Supporting BOTH ORM and Stored Procedures
|
|
237
|
+
|
|
238
|
+
This is critical. The existing database likely has stored procedures with complex business logic. The Node.js services must be able to call them directly — not rewrite them in TypeScript.
|
|
239
|
+
|
|
240
|
+
```typescript
|
|
241
|
+
// packages/db-client/src/connection.ts
|
|
242
|
+
import sql from 'mssql' // For SQL Server stored procedure calls
|
|
243
|
+
import { PrismaClient } from '@prisma/client' // For ORM queries
|
|
244
|
+
|
|
245
|
+
// SQL Server connection pool for SP calls
|
|
246
|
+
const sqlConfig: sql.config = {
|
|
247
|
+
server: process.env.DB_HOST!,
|
|
248
|
+
database: process.env.DB_NAME!,
|
|
249
|
+
user: process.env.DB_USER!,
|
|
250
|
+
password: process.env.DB_PASSWORD!,
|
|
251
|
+
options: {
|
|
252
|
+
encrypt: true,
|
|
253
|
+
trustServerCertificate: process.env.NODE_ENV === 'development',
|
|
254
|
+
},
|
|
255
|
+
pool: {
|
|
256
|
+
max: 20,
|
|
257
|
+
min: 5,
|
|
258
|
+
idleTimeoutMillis: 30000,
|
|
259
|
+
},
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
let pool: sql.ConnectionPool | null = null
|
|
263
|
+
|
|
264
|
+
export async function getSqlPool(): Promise<sql.ConnectionPool> {
|
|
265
|
+
if (!pool) {
|
|
266
|
+
pool = await new sql.ConnectionPool(sqlConfig).connect()
|
|
267
|
+
}
|
|
268
|
+
return pool
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Prisma client for ORM queries (new tables, simple CRUD)
|
|
272
|
+
export const prisma = new PrismaClient({
|
|
273
|
+
log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'],
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
export async function closeDatabaseConnections() {
|
|
277
|
+
await prisma.$disconnect()
|
|
278
|
+
if (pool) await pool.close()
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
```typescript
|
|
283
|
+
// packages/db-client/src/stored-procedures/execute-sp.ts
|
|
284
|
+
import { getSqlPool } from '../connection'
|
|
285
|
+
import sql from 'mssql'
|
|
286
|
+
|
|
287
|
+
interface SpParam {
|
|
288
|
+
name: string
|
|
289
|
+
type: sql.ISqlType
|
|
290
|
+
value: any
|
|
291
|
+
output?: boolean
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
interface SpResult<T> {
|
|
295
|
+
recordsets: T[][]
|
|
296
|
+
returnValue: number
|
|
297
|
+
rowsAffected: number[]
|
|
298
|
+
output: Record<string, any>
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export async function executeSp<T = any>(
|
|
302
|
+
procedureName: string,
|
|
303
|
+
params: SpParam[] = [],
|
|
304
|
+
options?: { timeout?: number }
|
|
305
|
+
): Promise<SpResult<T>> {
|
|
306
|
+
const pool = await getSqlPool()
|
|
307
|
+
const request = pool.request()
|
|
308
|
+
|
|
309
|
+
if (options?.timeout) {
|
|
310
|
+
request.timeout = options.timeout
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
for (const param of params) {
|
|
314
|
+
if (param.output) {
|
|
315
|
+
request.output(param.name, param.type, param.value)
|
|
316
|
+
} else {
|
|
317
|
+
request.input(param.name, param.type, param.value)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const result = await request.execute(procedureName)
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
recordsets: result.recordsets,
|
|
325
|
+
returnValue: result.returnValue,
|
|
326
|
+
rowsAffected: result.rowsAffected,
|
|
327
|
+
output: result.output,
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
```typescript
|
|
333
|
+
// packages/db-client/src/stored-procedures/patient.sp.ts
|
|
334
|
+
// Generated from STORED_PROCEDURES.md or db-intelligence STORED_PROCEDURES.md
|
|
335
|
+
// Each legacy SP gets a typed wrapper
|
|
336
|
+
|
|
337
|
+
import { executeSp } from './execute-sp'
|
|
338
|
+
import sql from 'mssql'
|
|
339
|
+
|
|
340
|
+
export async function spGetPatientByNhi(nhi: string) {
|
|
341
|
+
return executeSp<PatientRow>('dbo.usp_GetPatientByNHI', [
|
|
342
|
+
{ name: 'NHI', type: sql.VarChar(7), value: nhi },
|
|
343
|
+
])
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export async function spBookAppointment(params: {
|
|
347
|
+
patientId: number
|
|
348
|
+
doctorId: number
|
|
349
|
+
appointmentDate: Date
|
|
350
|
+
duration: number
|
|
351
|
+
appointmentType: string
|
|
352
|
+
}) {
|
|
353
|
+
return executeSp<AppointmentRow>('dbo.usp_BookAppointment', [
|
|
354
|
+
{ name: 'PatientID', type: sql.Int, value: params.patientId },
|
|
355
|
+
{ name: 'DoctorID', type: sql.Int, value: params.doctorId },
|
|
356
|
+
{ name: 'AppointmentDate', type: sql.DateTime, value: params.appointmentDate },
|
|
357
|
+
{ name: 'Duration', type: sql.Int, value: params.duration },
|
|
358
|
+
{ name: 'AppointmentType', type: sql.VarChar(50), value: params.appointmentType },
|
|
359
|
+
])
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ... wrapper for EVERY stored procedure from the legacy system
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
### Strategy: When to Use ORM vs Stored Procedures
|
|
366
|
+
|
|
367
|
+
| Scenario | Use |
|
|
368
|
+
|----------|-----|
|
|
369
|
+
| Legacy SP contains complex business logic | **Call the SP directly** via mssql — do NOT rewrite |
|
|
370
|
+
| Simple CRUD on existing tables | **Prisma/TypeORM** — map the existing table, use ORM |
|
|
371
|
+
| New tables for new features | **Prisma** — define in schema, use migrations |
|
|
372
|
+
| SP that only does a simple SELECT | **ORM** — replace with Prisma query (simpler, type-safe) |
|
|
373
|
+
| SP with transactions spanning multiple tables | **Call the SP** — let the database handle the transaction |
|
|
374
|
+
| SP called by other SPs or triggers | **Call the SP** — it's part of a chain, don't break it |
|
|
375
|
+
|
|
376
|
+
Present this decision for each module: "This module has 5 SPs. I'll call 3 directly (complex logic) and replace 2 with Prisma queries (simple SELECTs). Here's why for each. Agree?"
|
|
377
|
+
|
|
378
|
+
### 1.4 API Gateway
|
|
379
|
+
|
|
380
|
+
```
|
|
381
|
+
services/gateway/
|
|
382
|
+
├── package.json
|
|
383
|
+
├── Dockerfile
|
|
384
|
+
├── src/
|
|
385
|
+
│ ├── main.ts
|
|
386
|
+
│ ├── gateway.config.ts ← Route mapping: path → service
|
|
387
|
+
│ ├── middleware/
|
|
388
|
+
│ │ ├── auth.middleware.ts ← JWT verification on every request
|
|
389
|
+
│ │ ├── rate-limit.ts ← Per-client rate limiting
|
|
390
|
+
│ │ ├── cors.ts ← CORS configuration
|
|
391
|
+
│ │ ├── helmet.ts ← Security headers
|
|
392
|
+
│ │ ├── request-id.ts ← Correlation ID generation
|
|
393
|
+
│ │ ├── request-logger.ts ← Structured request logging
|
|
394
|
+
│ │ └── error-handler.ts ← Global error formatting
|
|
395
|
+
│ └── routes/
|
|
396
|
+
│ └── proxy.ts ← Route proxying to backend services
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
Gateway responsibilities:
|
|
400
|
+
- **Route traffic** to the correct microservice based on path prefix
|
|
401
|
+
- **Authenticate** every request (validate JWT, extract user context)
|
|
402
|
+
- **Rate limit** per client/API key
|
|
403
|
+
- **CORS** enforcement
|
|
404
|
+
- **Security headers** via Helmet
|
|
405
|
+
- **Request logging** with correlation IDs
|
|
406
|
+
- **Error standardization** — all errors return the `ApiError` contract
|
|
407
|
+
- **Strangler fig support** — route some paths to legacy .NET, others to new Node.js
|
|
408
|
+
|
|
409
|
+
```typescript
|
|
410
|
+
// services/gateway/src/gateway.config.ts
|
|
411
|
+
|
|
412
|
+
export interface ServiceRoute {
|
|
413
|
+
pathPrefix: string
|
|
414
|
+
target: string // service URL
|
|
415
|
+
stripPrefix?: boolean
|
|
416
|
+
rateLimit?: { max: number; windowMs: number }
|
|
417
|
+
auth?: 'required' | 'optional' | 'none'
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export const routes: ServiceRoute[] = [
|
|
421
|
+
// Auth service — no auth required on login/register
|
|
422
|
+
{ pathPrefix: '/api/auth', target: process.env.AUTH_SERVICE_URL!, auth: 'none' },
|
|
423
|
+
|
|
424
|
+
// Converted modules — auth required
|
|
425
|
+
{ pathPrefix: '/api/patients', target: process.env.PATIENT_SERVICE_URL!, auth: 'required' },
|
|
426
|
+
{ pathPrefix: '/api/appointments', target: process.env.APPOINTMENT_SERVICE_URL!, auth: 'required' },
|
|
427
|
+
|
|
428
|
+
// Not yet converted — proxy to legacy .NET
|
|
429
|
+
{ pathPrefix: '/api/billing', target: process.env.LEGACY_DOTNET_URL!, auth: 'required' },
|
|
430
|
+
{ pathPrefix: '/api/reports', target: process.env.LEGACY_DOTNET_URL!, auth: 'required' },
|
|
431
|
+
]
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
### 1.5 Authentication Service
|
|
435
|
+
|
|
436
|
+
Build BEFORE any business module. Extract auth patterns from `MASTER_SKILL.md` → Authentication section.
|
|
437
|
+
|
|
438
|
+
```
|
|
439
|
+
services/auth/
|
|
440
|
+
├── package.json
|
|
441
|
+
├── Dockerfile
|
|
442
|
+
├── src/
|
|
443
|
+
│ ├── main.ts
|
|
444
|
+
│ ├── auth.controller.ts
|
|
445
|
+
│ ├── auth.service.ts
|
|
446
|
+
│ ├── strategies/
|
|
447
|
+
│ │ ├── jwt.strategy.ts
|
|
448
|
+
│ │ └── local.strategy.ts
|
|
449
|
+
│ ├── guards/
|
|
450
|
+
│ │ ├── jwt-auth.guard.ts
|
|
451
|
+
│ │ └── roles.guard.ts
|
|
452
|
+
│ ├── decorators/
|
|
453
|
+
│ │ ├── current-user.decorator.ts
|
|
454
|
+
│ │ └── roles.decorator.ts
|
|
455
|
+
│ └── dto/
|
|
456
|
+
│ ├── login.dto.ts
|
|
457
|
+
│ └── token-response.dto.ts
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
The auth service:
|
|
461
|
+
- Issues JWT access + refresh tokens
|
|
462
|
+
- Validates credentials against the existing database (same user table)
|
|
463
|
+
- Supports the same roles/permissions the .NET app used
|
|
464
|
+
- Provides `/api/auth/login`, `/api/auth/refresh`, `/api/auth/logout`, `/api/auth/me`
|
|
465
|
+
|
|
466
|
+
### 1.6 Security Baseline
|
|
467
|
+
|
|
468
|
+
Apply to EVERY service from day one:
|
|
469
|
+
|
|
470
|
+
```typescript
|
|
471
|
+
// Shared security middleware applied in every service
|
|
472
|
+
|
|
473
|
+
// 1. Helmet — security headers
|
|
474
|
+
app.use(helmet({
|
|
475
|
+
contentSecurityPolicy: true,
|
|
476
|
+
crossOriginEmbedderPolicy: true,
|
|
477
|
+
crossOriginOpenerPolicy: true,
|
|
478
|
+
crossOriginResourcePolicy: true,
|
|
479
|
+
dnsPrefetchControl: true,
|
|
480
|
+
frameguard: { action: 'deny' },
|
|
481
|
+
hidePoweredBy: true,
|
|
482
|
+
hsts: { maxAge: 31536000, includeSubDomains: true },
|
|
483
|
+
noSniff: true,
|
|
484
|
+
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
|
485
|
+
xssFilter: true,
|
|
486
|
+
}))
|
|
487
|
+
|
|
488
|
+
// 2. Rate limiting
|
|
489
|
+
app.use(rateLimit({
|
|
490
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
491
|
+
max: 100, // per IP
|
|
492
|
+
standardHeaders: true,
|
|
493
|
+
legacyHeaders: false,
|
|
494
|
+
}))
|
|
495
|
+
|
|
496
|
+
// 3. CORS — lock down to known origins
|
|
497
|
+
app.use(cors({
|
|
498
|
+
origin: process.env.ALLOWED_ORIGINS?.split(','),
|
|
499
|
+
credentials: true,
|
|
500
|
+
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
|
501
|
+
}))
|
|
502
|
+
|
|
503
|
+
// 4. Request size limits
|
|
504
|
+
app.use(express.json({ limit: '10mb' }))
|
|
505
|
+
|
|
506
|
+
// 5. SQL injection prevention — parameterized queries ONLY (enforced by mssql + Prisma)
|
|
507
|
+
|
|
508
|
+
// 6. Input validation — Zod or class-validator on every endpoint
|
|
509
|
+
|
|
510
|
+
// 7. Structured logging with correlation IDs
|
|
511
|
+
// Every request gets a unique correlationId propagated to all downstream calls
|
|
512
|
+
|
|
513
|
+
// 8. Sensitive data masking in logs
|
|
514
|
+
// Never log passwords, tokens, NHI numbers, patient data
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
### 1.7 Structured Logging
|
|
518
|
+
|
|
519
|
+
```typescript
|
|
520
|
+
// Shared logger — every service uses this
|
|
521
|
+
|
|
522
|
+
import pino from 'pino'
|
|
523
|
+
|
|
524
|
+
export const logger = pino({
|
|
525
|
+
level: process.env.LOG_LEVEL || 'info',
|
|
526
|
+
transport: process.env.NODE_ENV === 'development'
|
|
527
|
+
? { target: 'pino-pretty' }
|
|
528
|
+
: undefined,
|
|
529
|
+
redact: {
|
|
530
|
+
paths: ['req.headers.authorization', 'body.password', 'body.token', '*.nhi', '*.dateOfBirth'],
|
|
531
|
+
censor: '[REDACTED]',
|
|
532
|
+
},
|
|
533
|
+
serializers: {
|
|
534
|
+
req: (req) => ({
|
|
535
|
+
method: req.method,
|
|
536
|
+
url: req.url,
|
|
537
|
+
correlationId: req.headers['x-correlation-id'],
|
|
538
|
+
}),
|
|
539
|
+
},
|
|
540
|
+
})
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
### 1.8 Docker Setup
|
|
544
|
+
|
|
545
|
+
Per-service Dockerfile:
|
|
546
|
+
```dockerfile
|
|
547
|
+
FROM node:20-alpine AS builder
|
|
548
|
+
WORKDIR /app
|
|
549
|
+
COPY package*.json ./
|
|
550
|
+
COPY packages/contracts ./packages/contracts
|
|
551
|
+
COPY packages/db-client ./packages/db-client
|
|
552
|
+
COPY services/{service-name} ./services/{service-name}
|
|
553
|
+
RUN npm ci --workspace=services/{service-name}
|
|
554
|
+
RUN npm run build --workspace=services/{service-name}
|
|
555
|
+
|
|
556
|
+
FROM node:20-alpine
|
|
557
|
+
WORKDIR /app
|
|
558
|
+
COPY --from=builder /app/dist ./dist
|
|
559
|
+
COPY --from=builder /app/node_modules ./node_modules
|
|
560
|
+
EXPOSE 3000
|
|
561
|
+
CMD ["node", "dist/main.js"]
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
Docker Compose for local development:
|
|
565
|
+
```yaml
|
|
566
|
+
services:
|
|
567
|
+
gateway:
|
|
568
|
+
build: { context: ., dockerfile: infrastructure/docker/gateway.Dockerfile }
|
|
569
|
+
ports: ["8080:3000"]
|
|
570
|
+
env_file: .env
|
|
571
|
+
depends_on: [auth]
|
|
572
|
+
|
|
573
|
+
auth:
|
|
574
|
+
build: { context: ., dockerfile: infrastructure/docker/auth.Dockerfile }
|
|
575
|
+
env_file: .env
|
|
576
|
+
|
|
577
|
+
# Each converted module gets added here
|
|
578
|
+
# patient:
|
|
579
|
+
# build: ...
|
|
580
|
+
# appointment:
|
|
581
|
+
# build: ...
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
---
|
|
585
|
+
|
|
586
|
+
## Step 2: Convert a Module — The Conversion Protocol
|
|
587
|
+
|
|
588
|
+
This is the repeatable process for EACH module. The user picks the module, you execute this protocol.
|
|
589
|
+
|
|
590
|
+
### 2.1 Ask the User
|
|
591
|
+
|
|
592
|
+
> "Which module would you like to convert next? Here are the available modules from the intelligence package:"
|
|
593
|
+
>
|
|
594
|
+
> {List modules from MASTER_SKILL.md with endpoint count and business rule count per module}
|
|
595
|
+
>
|
|
596
|
+
> "I recommend starting with **{module}** because {reasoning — fewest dependencies / most independent / user's priority}."
|
|
597
|
+
|
|
598
|
+
Wait for user selection. Never auto-select.
|
|
599
|
+
|
|
600
|
+
### 2.2 Read the Module Intelligence
|
|
601
|
+
|
|
602
|
+
Read the selected module's SKILL.md from `modules/{module}/SKILL.md`. This contains:
|
|
603
|
+
- All endpoints with full request/response schemas
|
|
604
|
+
- All business rules with conditions and actions
|
|
605
|
+
- All data models owned by this module
|
|
606
|
+
- Integration points with other modules
|
|
607
|
+
- Error handling patterns
|
|
608
|
+
|
|
609
|
+
If DB intelligence exists, also read the relevant SPs from `STORED_PROCEDURES.md`.
|
|
610
|
+
|
|
611
|
+
### 2.3 Map Endpoints
|
|
612
|
+
|
|
613
|
+
Create a conversion table — every .NET endpoint mapped to its Node.js equivalent:
|
|
614
|
+
|
|
615
|
+
```markdown
|
|
616
|
+
## Endpoint Conversion Map: {Module Name}
|
|
617
|
+
|
|
618
|
+
| .NET Endpoint | Node.js Endpoint | Method | Auth | Notes |
|
|
619
|
+
|--------------|-----------------|--------|------|-------|
|
|
620
|
+
| PatientController.GetAll() | GET /api/patients | GET | Required (role: Viewer+) | Pagination params same |
|
|
621
|
+
| PatientController.GetById(id) | GET /api/patients/:id | GET | Required (role: Viewer+) | |
|
|
622
|
+
| PatientController.Create(dto) | POST /api/patients | POST | Required (role: Admin) | Validate NHI format |
|
|
623
|
+
| PatientController.Update(id, dto) | PUT /api/patients/:id | PUT | Required (role: Admin) | Partial update via PATCH? |
|
|
624
|
+
| PatientController.Deactivate(id) | PATCH /api/patients/:id/deactivate | PATCH | Required (role: Admin) | Calls usp_DeactivatePatient |
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
Present to user for confirmation before building.
|
|
628
|
+
|
|
629
|
+
### 2.4 Map Business Rules to Validation & Logic
|
|
630
|
+
|
|
631
|
+
For each business rule from `BUSINESS_RULES.md` that applies to this module:
|
|
632
|
+
|
|
633
|
+
```markdown
|
|
634
|
+
## Business Rule Conversion: {Module Name}
|
|
635
|
+
|
|
636
|
+
| Rule ID | Rule | Implementation |
|
|
637
|
+
|---------|------|----------------|
|
|
638
|
+
| patient.001 | NHI must be 3 letters + 4 digits | Zod validation: /^[A-Z]{3}\d{4}$/ |
|
|
639
|
+
| patient.003 | DOB cannot be future | Zod: .refine(d => d <= new Date()) |
|
|
640
|
+
| patient.007 | Cannot deactivate if future appointments exist | Service layer: query appointments before deactivating |
|
|
641
|
+
| patient.012 | Email required if notification pref is email | Zod: .superRefine() conditional validation |
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
### 2.5 Map Data Access
|
|
645
|
+
|
|
646
|
+
For each data operation in the module, decide ORM vs SP:
|
|
647
|
+
|
|
648
|
+
```markdown
|
|
649
|
+
## Data Access Conversion: {Module Name}
|
|
650
|
+
|
|
651
|
+
| Operation | Legacy Implementation | Node.js Implementation | Reason |
|
|
652
|
+
|-----------|---------------------|----------------------|--------|
|
|
653
|
+
| Get patient by NHI | usp_GetPatientByNHI | **Call SP** via mssql | SP has additional security logging |
|
|
654
|
+
| List patients with filter | usp_SearchPatients | **Call SP** — complex dynamic WHERE | |
|
|
655
|
+
| Create patient | usp_CreatePatient | **Call SP** — triggers audit logging | |
|
|
656
|
+
| Get patient count | SELECT COUNT(*) | **Prisma** — simple query, no logic | |
|
|
657
|
+
| Check NHI exists | SELECT 1 WHERE NHI = @nhi | **Prisma** — simple existence check | |
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
### 2.6 Map Inter-Service Communication
|
|
661
|
+
|
|
662
|
+
If this module calls other modules (was a method call in the monolith, now becomes a network call):
|
|
663
|
+
|
|
664
|
+
```markdown
|
|
665
|
+
## Inter-Service Calls: {Module Name}
|
|
666
|
+
|
|
667
|
+
| What the module needs | Old implementation | New implementation | Sync/Async |
|
|
668
|
+
|----------------------|-------------------|-------------------|------------|
|
|
669
|
+
| Validate patient exists | BAL.PatientService.Exists(id) | HTTP GET /api/patients/:id → patient-service | Sync (HTTP) |
|
|
670
|
+
| Notify billing after appointment | BAL.BillingService.CreateInvoice() | Event: appointment.booked → billing-service listens | Async (event) |
|
|
671
|
+
| Get doctor schedule | BAL.ScheduleService.GetAvailability() | HTTP GET /api/schedule/:doctorId → schedule-service | Sync (HTTP) |
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
Decision framework:
|
|
675
|
+
- **Synchronous (HTTP/gRPC)**: When the caller NEEDS the response to continue (validation, data fetch)
|
|
676
|
+
- **Asynchronous (events/queue)**: When the caller doesn't need to wait (notifications, audit logging, secondary effects)
|
|
677
|
+
|
|
678
|
+
### 2.7 Generate the Service
|
|
679
|
+
|
|
680
|
+
Create the service directory:
|
|
681
|
+
|
|
682
|
+
```
|
|
683
|
+
services/{module-name}/
|
|
684
|
+
├── package.json
|
|
685
|
+
├── Dockerfile
|
|
686
|
+
├── tsconfig.json
|
|
687
|
+
├── src/
|
|
688
|
+
│ ├── main.ts ← Service bootstrap
|
|
689
|
+
│ ├── {module}.module.ts ← NestJS module (or route registration for Express/Fastify)
|
|
690
|
+
│ ├── controllers/
|
|
691
|
+
│ │ └── {module}.controller.ts ← Route handlers — thin, delegate to service
|
|
692
|
+
│ ├── services/
|
|
693
|
+
│ │ └── {module}.service.ts ← Business logic — ALL rules implemented here
|
|
694
|
+
│ ├── repositories/
|
|
695
|
+
│ │ └── {module}.repository.ts ← Data access — ORM + SP calls
|
|
696
|
+
│ ├── dto/
|
|
697
|
+
│ │ ├── create-{entity}.dto.ts ← Input validation (Zod/class-validator)
|
|
698
|
+
│ │ └── update-{entity}.dto.ts
|
|
699
|
+
│ ├── validators/
|
|
700
|
+
│ │ └── {module}.validators.ts ← Complex validation rules from BUSINESS_RULES.md
|
|
701
|
+
│ ├── events/
|
|
702
|
+
│ │ ├── publishers.ts ← Events this service emits
|
|
703
|
+
│ │ └── subscribers.ts ← Events this service listens to
|
|
704
|
+
│ ├── middleware/
|
|
705
|
+
│ │ └── {module}-specific.middleware.ts
|
|
706
|
+
│ └── __tests__/
|
|
707
|
+
│ ├── {module}.controller.spec.ts ← Endpoint tests
|
|
708
|
+
│ ├── {module}.service.spec.ts ← Business logic tests
|
|
709
|
+
│ └── {module}.repository.spec.ts ← Data access tests
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
### 2.8 Controller Pattern
|
|
713
|
+
|
|
714
|
+
Controllers are THIN — they validate input, call the service, and return the response. No business logic here.
|
|
715
|
+
|
|
716
|
+
```typescript
|
|
717
|
+
// services/patient/src/controllers/patient.controller.ts
|
|
718
|
+
|
|
719
|
+
@Controller('patients')
|
|
720
|
+
export class PatientController {
|
|
721
|
+
constructor(private readonly patientService: PatientService) {}
|
|
722
|
+
|
|
723
|
+
@Get()
|
|
724
|
+
@Roles('Viewer', 'Admin')
|
|
725
|
+
async findAll(@Query() query: ListPatientsQueryDto): Promise<ApiResponse<PatientResponseDto[]>> {
|
|
726
|
+
const result = await this.patientService.findAll(query)
|
|
727
|
+
return { success: true, data: result.data, error: null, meta: result.meta }
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
@Post()
|
|
731
|
+
@Roles('Admin')
|
|
732
|
+
async create(@Body() dto: CreatePatientDto, @CurrentUser() user: AuthUser): Promise<ApiResponse<PatientResponseDto>> {
|
|
733
|
+
const patient = await this.patientService.create(dto, user)
|
|
734
|
+
return { success: true, data: patient, error: null }
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ... every endpoint from the conversion map
|
|
738
|
+
}
|
|
739
|
+
```
|
|
740
|
+
|
|
741
|
+
### 2.9 Service Pattern
|
|
742
|
+
|
|
743
|
+
Services contain ALL business logic. Every rule from `BUSINESS_RULES.md` is implemented here with a comment referencing the rule ID.
|
|
744
|
+
|
|
745
|
+
```typescript
|
|
746
|
+
// services/patient/src/services/patient.service.ts
|
|
747
|
+
|
|
748
|
+
export class PatientService {
|
|
749
|
+
constructor(
|
|
750
|
+
private readonly patientRepo: PatientRepository,
|
|
751
|
+
private readonly eventPublisher: EventPublisher,
|
|
752
|
+
) {}
|
|
753
|
+
|
|
754
|
+
async create(dto: CreatePatientDto, user: AuthUser): Promise<PatientResponseDto> {
|
|
755
|
+
// Rule patient.001: NHI must be 3 letters + 4 digits
|
|
756
|
+
// (Already validated by DTO schema, but double-check at service level)
|
|
757
|
+
if (!/^[A-Z]{3}\d{4}$/.test(dto.nhi)) {
|
|
758
|
+
throw new BusinessError(ErrorCodes.PATIENT_NHI_INVALID_FORMAT, 'NHI must be 3 letters + 4 digits')
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Rule patient.002: NHI must be unique
|
|
762
|
+
const existing = await this.patientRepo.findByNhi(dto.nhi)
|
|
763
|
+
if (existing) {
|
|
764
|
+
throw new BusinessError(ErrorCodes.PATIENT_NHI_DUPLICATE, 'Patient with this NHI already exists')
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Rule patient.003: DOB cannot be in the future
|
|
768
|
+
if (new Date(dto.dateOfBirth) > new Date()) {
|
|
769
|
+
throw new BusinessError(ErrorCodes.PATIENT_DOB_FUTURE, 'Date of birth cannot be in the future')
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Rule patient.012: Email required if notification pref is email
|
|
773
|
+
if (dto.notificationPref === 'email' && !dto.email) {
|
|
774
|
+
throw new BusinessError(ErrorCodes.PATIENT_EMAIL_REQUIRED, 'Email required for email notifications')
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Call the legacy SP — it handles audit logging and additional DB-level validation
|
|
778
|
+
const result = await this.patientRepo.createViaStoredProcedure(dto)
|
|
779
|
+
|
|
780
|
+
// Publish event for other services
|
|
781
|
+
await this.eventPublisher.publish<PatientCreatedEvent>({
|
|
782
|
+
type: 'patient.created',
|
|
783
|
+
payload: { patientId: result.id, nhi: dto.nhi, createdBy: user.id },
|
|
784
|
+
timestamp: new Date().toISOString(),
|
|
785
|
+
correlationId: user.correlationId,
|
|
786
|
+
})
|
|
787
|
+
|
|
788
|
+
return result
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
```
|
|
792
|
+
|
|
793
|
+
### 2.10 Repository Pattern
|
|
794
|
+
|
|
795
|
+
Repositories handle data access — both ORM and stored procedures.
|
|
796
|
+
|
|
797
|
+
```typescript
|
|
798
|
+
// services/patient/src/repositories/patient.repository.ts
|
|
799
|
+
|
|
800
|
+
export class PatientRepository {
|
|
801
|
+
constructor(
|
|
802
|
+
private readonly prisma: PrismaClient,
|
|
803
|
+
private readonly sp: typeof import('@project/db-client/stored-procedures/patient.sp'),
|
|
804
|
+
) {}
|
|
805
|
+
|
|
806
|
+
// Simple query — use Prisma
|
|
807
|
+
async findByNhi(nhi: string): Promise<PatientRow | null> {
|
|
808
|
+
return this.prisma.patient.findUnique({ where: { nhi } })
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// Complex query — call legacy SP
|
|
812
|
+
async search(filters: SearchFilters): Promise<PatientRow[]> {
|
|
813
|
+
const result = await this.sp.spSearchPatients(filters)
|
|
814
|
+
return result.recordsets[0]
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Creation — call legacy SP (has audit triggers and additional validation)
|
|
818
|
+
async createViaStoredProcedure(dto: CreatePatientDto): Promise<PatientRow> {
|
|
819
|
+
const result = await this.sp.spCreatePatient({
|
|
820
|
+
nhi: dto.nhi,
|
|
821
|
+
firstName: dto.firstName,
|
|
822
|
+
lastName: dto.lastName,
|
|
823
|
+
dateOfBirth: new Date(dto.dateOfBirth),
|
|
824
|
+
gender: dto.gender,
|
|
825
|
+
email: dto.email || null,
|
|
826
|
+
phone: dto.phone || null,
|
|
827
|
+
})
|
|
828
|
+
return result.recordsets[0][0]
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
```
|
|
832
|
+
|
|
833
|
+
### 2.11 Test Generation
|
|
834
|
+
|
|
835
|
+
For EVERY business rule, generate a test:
|
|
836
|
+
|
|
837
|
+
```typescript
|
|
838
|
+
// services/patient/src/__tests__/patient.service.spec.ts
|
|
839
|
+
|
|
840
|
+
describe('PatientService', () => {
|
|
841
|
+
// Rule patient.001
|
|
842
|
+
it('should reject NHI with invalid format', async () => {
|
|
843
|
+
await expect(service.create({ ...validDto, nhi: 'INVALID' }, mockUser))
|
|
844
|
+
.rejects.toThrow('NHI must be 3 letters + 4 digits')
|
|
845
|
+
})
|
|
846
|
+
|
|
847
|
+
// Rule patient.002
|
|
848
|
+
it('should reject duplicate NHI', async () => {
|
|
849
|
+
patientRepo.findByNhi.mockResolvedValue(existingPatient)
|
|
850
|
+
await expect(service.create(validDto, mockUser))
|
|
851
|
+
.rejects.toThrow('Patient with this NHI already exists')
|
|
852
|
+
})
|
|
853
|
+
|
|
854
|
+
// Rule patient.003
|
|
855
|
+
it('should reject future date of birth', async () => {
|
|
856
|
+
const futureDate = new Date(Date.now() + 86400000).toISOString()
|
|
857
|
+
await expect(service.create({ ...validDto, dateOfBirth: futureDate }, mockUser))
|
|
858
|
+
.rejects.toThrow('Date of birth cannot be in the future')
|
|
859
|
+
})
|
|
860
|
+
|
|
861
|
+
// Rule patient.012
|
|
862
|
+
it('should require email when notification preference is email', async () => {
|
|
863
|
+
await expect(service.create({ ...validDto, notificationPref: 'email', email: '' }, mockUser))
|
|
864
|
+
.rejects.toThrow('Email required for email notifications')
|
|
865
|
+
})
|
|
866
|
+
|
|
867
|
+
// ... one test per business rule
|
|
868
|
+
})
|
|
869
|
+
```
|
|
870
|
+
|
|
871
|
+
### 2.12 Update Gateway Routes
|
|
872
|
+
|
|
873
|
+
After the module service is built, add it to the gateway:
|
|
874
|
+
|
|
875
|
+
```typescript
|
|
876
|
+
// Add to gateway.config.ts
|
|
877
|
+
{ pathPrefix: '/api/patients', target: process.env.PATIENT_SERVICE_URL!, auth: 'required' },
|
|
878
|
+
```
|
|
879
|
+
|
|
880
|
+
Remove or comment out the legacy .NET route for this module.
|
|
881
|
+
|
|
882
|
+
### 2.13 API Contract Test
|
|
883
|
+
|
|
884
|
+
Before switching traffic, verify the Node.js service returns identical responses to the .NET service:
|
|
885
|
+
|
|
886
|
+
```typescript
|
|
887
|
+
// Contract test — run against BOTH services, compare responses
|
|
888
|
+
|
|
889
|
+
describe('API Contract: Patient Module', () => {
|
|
890
|
+
const legacyUrl = process.env.LEGACY_URL
|
|
891
|
+
const newUrl = process.env.NEW_SERVICE_URL
|
|
892
|
+
|
|
893
|
+
it('GET /api/patients should return same shape', async () => {
|
|
894
|
+
const legacyResponse = await axios.get(`${legacyUrl}/api/patients`)
|
|
895
|
+
const newResponse = await axios.get(`${newUrl}/api/patients`)
|
|
896
|
+
|
|
897
|
+
// Same status code
|
|
898
|
+
expect(newResponse.status).toBe(legacyResponse.status)
|
|
899
|
+
|
|
900
|
+
// Same response shape (field names and types, not exact values)
|
|
901
|
+
expect(Object.keys(newResponse.data[0])).toEqual(Object.keys(legacyResponse.data[0]))
|
|
902
|
+
})
|
|
903
|
+
|
|
904
|
+
// ... for every endpoint in the module
|
|
905
|
+
})
|
|
906
|
+
```
|
|
907
|
+
|
|
908
|
+
### 2.14 Deliver the Module
|
|
909
|
+
|
|
910
|
+
After building, present:
|
|
911
|
+
|
|
912
|
+
```markdown
|
|
913
|
+
## Module Conversion Complete: {Module Name}
|
|
914
|
+
|
|
915
|
+
### Endpoints Converted: {count}
|
|
916
|
+
| Endpoint | Status | Tests |
|
|
917
|
+
|----------|--------|-------|
|
|
918
|
+
| GET /api/patients | ✅ Converted | 5 tests |
|
|
919
|
+
| POST /api/patients | ✅ Converted | 8 tests |
|
|
920
|
+
|
|
921
|
+
### Business Rules Preserved: {count}
|
|
922
|
+
| Rule ID | Rule | Test |
|
|
923
|
+
|---------|------|------|
|
|
924
|
+
| patient.001 | NHI format validation | ✅ Tested |
|
|
925
|
+
| patient.002 | NHI uniqueness | ✅ Tested |
|
|
926
|
+
|
|
927
|
+
### Stored Procedures: {count}
|
|
928
|
+
| SP | Approach | Reason |
|
|
929
|
+
|----|----------|--------|
|
|
930
|
+
| usp_CreatePatient | Direct call | Audit triggers |
|
|
931
|
+
| usp_SearchPatients | Direct call | Complex dynamic SQL |
|
|
932
|
+
|
|
933
|
+
### Gateway Updated: Yes
|
|
934
|
+
### Docker Added: Yes
|
|
935
|
+
### Contract Tests: {pass/fail}
|
|
936
|
+
|
|
937
|
+
### Next Module Recommendation: {module name} because {reason}
|
|
938
|
+
```
|
|
939
|
+
|
|
940
|
+
---
|
|
941
|
+
|
|
942
|
+
## Step 3: Repeat for Each Module
|
|
943
|
+
|
|
944
|
+
Go back to Step 2.1 and ask the user which module to convert next. Each time:
|
|
945
|
+
1. The user picks the module
|
|
946
|
+
2. You execute the full protocol (2.1 through 2.14)
|
|
947
|
+
3. The gateway gets one more route
|
|
948
|
+
4. The legacy .NET handles one fewer module
|
|
949
|
+
5. Eventually all traffic is on Node.js
|
|
950
|
+
|
|
951
|
+
---
|
|
952
|
+
|
|
953
|
+
## Quality Gate Per Module
|
|
954
|
+
|
|
955
|
+
Before delivering any module, verify:
|
|
956
|
+
|
|
957
|
+
- [ ] Every endpoint from the legacy module's API_REGISTRY.md section is implemented
|
|
958
|
+
- [ ] Every business rule from BUSINESS_RULES.md is implemented with a comment referencing the rule ID
|
|
959
|
+
- [ ] Every business rule has at least one test
|
|
960
|
+
- [ ] Stored procedures that contain complex logic are called directly, not rewritten
|
|
961
|
+
- [ ] Simple queries are handled by Prisma for type safety
|
|
962
|
+
- [ ] Input validation matches the legacy validation exactly (same field constraints, same error messages)
|
|
963
|
+
- [ ] Error responses match the legacy error codes (or map cleanly to the new ErrorCodes enum)
|
|
964
|
+
- [ ] Auth requirements match the legacy role requirements
|
|
965
|
+
- [ ] Gateway route is configured
|
|
966
|
+
- [ ] Docker container builds and runs
|
|
967
|
+
- [ ] Contract tests pass against the legacy service
|
|
968
|
+
|
|
969
|
+
---
|
|
970
|
+
|
|
971
|
+
## Handling Modules That Call Each Other
|
|
972
|
+
|
|
973
|
+
When converting a module that depends on an unconverted module:
|
|
974
|
+
|
|
975
|
+
1. **If the dependency is on a .NET module not yet converted**: The gateway handles this — the calling service makes an HTTP call to the gateway, which routes to the legacy .NET service. The caller doesn't need to know whether the target is .NET or Node.js.
|
|
976
|
+
|
|
977
|
+
2. **If the dependency is on a module already converted**: Direct HTTP call or event, depending on the communication mapping from Step 2.6.
|
|
978
|
+
|
|
979
|
+
3. **If the dependency is circular**: Flag it. Circular dependencies between microservices are an architectural smell. Recommend extracting the shared concern into its own service or using events to break the cycle.
|