feat(weather): migrate /api/weather to the NestJS pilot module (L1) (#1053)

First strangler migration (L1): /api/weather is served by a NestJS module.

- @trek/shared/weather Zod contract; Nest controller byte-identical to the legacy Express route (paths, query params, status codes, { error } bodies, lang default, ApiError/500 passthrough). Service reuses getWeather/getDetailedWeather (+ shared cache; MCP tools unchanged).
- Strangler routes /api/weather to Nest by default; the legacy Express route + its migration-time parity test were decommissioned in this PR.
- Frontend (FE2): weatherApi typed against the @trek/shared WeatherResult contract.
- Harness: reusable Nest-vs-Express parity harness, e2e harness (temp SQLite + seed/cookie helpers, real JwtAuthGuard), src/nest coverage gate raised to >=80%, src/nest test guide.
- Verified end-to-end on a prod mirror (dev1): 401/400/200 via Nest with real Open-Meteo data, Express route gone.
This commit is contained in:
Maurice
2026-05-25 17:00:58 +02:00
committed by GitHub
parent 0b218d53b2
commit 0257e4e71e
21 changed files with 614 additions and 317 deletions
+2 -2
View File
@@ -26,7 +26,6 @@ import airportsRoutes from './routes/airports';
import filesRoutes from './routes/files';
import reservationsRoutes from './routes/reservations';
import dayNotesRoutes from './routes/dayNotes';
import weatherRoutes from './routes/weather';
import settingsRoutes from './routes/settings';
import budgetRoutes from './routes/budget';
import collabRoutes from './routes/collab';
@@ -361,7 +360,8 @@ export function createApp(): express.Application {
app.use('/api/photos', photoRoutes);
app.use('/api/maps', mapsRoutes);
app.use('/api/airports', airportsRoutes);
app.use('/api/weather', weatherRoutes);
// /api/weather is served by the NestJS weather module (see src/nest/weather);
// the legacy Express route was decommissioned after the migration (L1).
app.use('/api/settings', settingsRoutes);
app.use('/api/system-notices', systemNoticesRoutes);
app.use('/api/backup', backupRoutes);
+58
View File
@@ -0,0 +1,58 @@
# NestJS migration layer — module & test guide
This folder holds the co-hosted NestJS app that incrementally strangles the legacy
Express API (see the "Brownfield Rewrite" board). Until a prefix is migrated, the
top-level dispatcher in `src/index.ts` routes it to the legacy app; migrated
prefixes go to Nest. **Weather (`weather/`) is the reference implementation** — copy
its shape when migrating a new domain.
## Module layout (per domain)
```
shared/src/<domain>/<domain>.schema.ts(.spec.ts) # Zod contract — single source of truth
server/src/nest/<domain>/<domain>.service.ts # business logic (ported 1:1 from the Express service)
server/src/nest/<domain>/<domain>.controller.ts # same routes/verbs/params/status codes as Express
server/src/nest/<domain>/<domain>.module.ts # registered in app.module.ts
```
Add the prefix to `DEFAULT_NEST_PREFIXES` in `strangler.ts` to route it to Nest
(operators can override at runtime via the `NEST_PREFIXES` env var — instant
rollback, no redeploy).
## Parity is law
A migrated route must be **byte-identical** for the client: same URL, method,
query/body, HTTP status, `Set-Cookie`, and JSON body — including bespoke error
strings. Where the legacy route returns a hand-written error (e.g. weather's
`{ error: 'Latitude and longitude are required' }`), reproduce that exact body in
the controller rather than relying on the generic `ZodValidationPipe` envelope.
## How to write the tests
Every module ships three kinds of tests; the coverage gate (`vitest.config.ts`,
scoped to `src/nest/**`) requires ≥80%.
1. **Service / controller unit spec**`tests/unit/nest/<domain>.controller.test.ts`.
Instantiate the controller with a mocked service; assert status codes, the exact
`{ error }` bodies, and that inputs are forwarded correctly (defaults, coercion).
See `weather.controller.test.ts`.
2. **Parity test**`tests/parity/<domain>.parity.test.ts`. Mock the shared service
identically for both apps, then fire the same request at the Express route and the
Nest controller with the `expectParity()` harness (`tests/parity/parity.ts`) and
assert identical status + body. This is the gate before flipping the toggle.
See `weather.parity.test.ts`.
3. **e2e**`tests/e2e/<domain>.e2e.test.ts`. Boot the Nest module against a temp
in-memory SQLite db via the shared harness (`tests/e2e/harness.ts`:
`createTempDb`/`seedUser`/`sessionCookie`), exercising the **real** `JwtAuthGuard`
end-to-end (401 without cookie, 200 with a signed session). Mock external I/O
(HTTP/etc.). See `weather.e2e.test.ts`.
## Definition of Done (per module)
Contract in `@trek/shared` → service ported 1:1 → controller with identical routes →
validation/error parity → unit + parity + e2e tests over the gate → prefix toggled to
Nest → parity verified on the demo DB → **then** decommission the old Express
route/service (separate step, after the toggle is confirmed in prod) → frontend points
at the typed contract (Frontend Track).
+2 -1
View File
@@ -3,6 +3,7 @@ import { APP_FILTER } from '@nestjs/core';
import { DatabaseModule } from './database/database.module';
import { HealthController } from './health/health.controller';
import { HealthService } from './health/health.service';
import { WeatherModule } from './weather/weather.module';
import { TrekExceptionFilter } from './common/trek-exception.filter';
/**
@@ -10,7 +11,7 @@ import { TrekExceptionFilter } from './common/trek-exception.filter';
* (weather, notifications, ...) get registered here as they are migrated.
*/
@Module({
imports: [DatabaseModule],
imports: [DatabaseModule, WeatherModule],
controllers: [HealthController],
providers: [
HealthService,
+1 -1
View File
@@ -8,7 +8,7 @@
* rollback — no redeploy, no code change. Setting `NEST_PREFIXES=` (empty) routes
* everything back to the legacy app.
*/
const DEFAULT_NEST_PREFIXES = ['/api/_nest'];
const DEFAULT_NEST_PREFIXES = ['/api/_nest', '/api/weather'];
export function getNestPrefixes(): string[] {
const raw = process.env.NEST_PREFIXES;
@@ -0,0 +1,66 @@
import { Controller, Get, HttpException, Query, UseGuards } from '@nestjs/common';
import type { WeatherResult } from '@trek/shared';
import { WeatherService } from './weather.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ApiError } from '../../services/weatherService';
/**
* /api/weather — first migrated leaf module (the pilot).
*
* Behaviour is byte-identical to the legacy Express route (server/src/routes/
* weather.ts): same paths, query params, status codes and `{ error }` bodies.
*
* Parity note: the "X is required" 400s and the 500 fallback messages are bespoke
* strings, not the generic Zod-pipe envelope, so they are reproduced here exactly
* rather than derived from the schema. The Zod contract/types live in
* @trek/shared/weather and are used for typing; `lang` defaults to 'de' only when
* the param is absent, matching the Express destructuring default.
*/
@Controller('api/weather')
@UseGuards(JwtAuthGuard)
export class WeatherController {
constructor(private readonly weather: WeatherService) {}
@Get()
async getWeather(
@Query('lat') lat?: string,
@Query('lng') lng?: string,
@Query('date') date?: string,
@Query('lang') lang?: string,
): Promise<WeatherResult> {
if (!lat || !lng) {
throw new HttpException({ error: 'Latitude and longitude are required' }, 400);
}
try {
return await this.weather.get(lat, lng, date, lang ?? 'de');
} catch (err: unknown) {
throw toHttp(err, 'Weather error:', 'Error fetching weather data');
}
}
@Get('detailed')
async getDetailed(
@Query('lat') lat?: string,
@Query('lng') lng?: string,
@Query('date') date?: string,
@Query('lang') lang?: string,
): Promise<WeatherResult> {
if (!lat || !lng || !date) {
throw new HttpException({ error: 'Latitude, longitude, and date are required' }, 400);
}
try {
return await this.weather.getDetailed(lat, lng, date, lang ?? 'de');
} catch (err: unknown) {
throw toHttp(err, 'Detailed weather error:', 'Error fetching detailed weather data');
}
}
}
/** Maps a thrown error to the same status + `{ error }` body the Express route sent. */
function toHttp(err: unknown, logPrefix: string, fallback: string): HttpException {
if (err instanceof ApiError) {
return new HttpException({ error: err.message }, err.status);
}
console.error(logPrefix, err);
return new HttpException({ error: fallback }, 500);
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { WeatherController } from './weather.controller';
import { WeatherService } from './weather.service';
/** Weather domain (pilot leaf module). Registered in AppModule. */
@Module({
controllers: [WeatherController],
providers: [WeatherService],
})
export class WeatherModule {}
@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import type { WeatherResult } from '@trek/shared';
import { getWeather, getDetailedWeather } from '../../services/weatherService';
/**
* Thin Nest wrapper around the existing weather service. It delegates to the
* exact same `getWeather` / `getDetailedWeather` functions the legacy route and
* the MCP tools use, so behaviour — including the shared in-memory cache and the
* Open-Meteo calls — is identical. No logic is duplicated; the upstream service
* stays the single source of truth (still consumed by the MCP weather tools).
*/
@Injectable()
export class WeatherService {
get(lat: string, lng: string, date: string | undefined, lang: string): Promise<WeatherResult> {
return getWeather(lat, lng, date, lang) as Promise<WeatherResult>;
}
getDetailed(lat: string, lng: string, date: string, lang: string): Promise<WeatherResult> {
return getDetailedWeather(lat, lng, date, lang) as Promise<WeatherResult>;
}
}
-45
View File
@@ -1,45 +0,0 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { getWeather, getDetailedWeather, ApiError } from '../services/weatherService';
const router = express.Router();
router.get('/', authenticate, async (req: Request, res: Response) => {
const { lat, lng, date, lang = 'de' } = req.query as { lat: string; lng: string; date?: string; lang?: string };
if (!lat || !lng) {
return res.status(400).json({ error: 'Latitude and longitude are required' });
}
try {
const result = await getWeather(lat, lng, date, lang);
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) {
return res.status(err.status).json({ error: err.message });
}
console.error('Weather error:', err);
res.status(500).json({ error: 'Error fetching weather data' });
}
});
router.get('/detailed', authenticate, async (req: Request, res: Response) => {
const { lat, lng, date, lang = 'de' } = req.query as { lat: string; lng: string; date: string; lang?: string };
if (!lat || !lng || !date) {
return res.status(400).json({ error: 'Latitude, longitude, and date are required' });
}
try {
const result = await getDetailedWeather(lat, lng, date, lang);
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) {
return res.status(err.status).json({ error: err.message });
}
console.error('Detailed weather error:', err);
res.status(500).json({ error: 'Error fetching detailed weather data' });
}
});
export default router;