Attachments were treated as ready-made HTML, but a real mail is multipart with base64 or quoted-printable transfer encoding. What reached the model was the plaintext headers followed by a wall of base64, truncated before any content — which is why a sender name from the Subject line still showed up while the booking never did. The html part is preferred, plain text is the fallback, transfer encoding and charset are decoded and entities resolved before the text goes out. No new dependency. If no text part is found the previous raw path still runs.
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). Trip-scoped mounts use a pattern prefix with a :param
segment (e.g. /api/trips/:tripId/packing); the matcher routes only that nested
mount to Nest and leaves the sibling trip routes (days, places, ...) on Express.
Migrated so far
- Phase 1 (leaf): weather, airports, config (public), system-notices, maps, categories, tags, notifications, atlas.
- Phase 2 (trip sub-domains): vacay (addon), packing, todo.
- DI-native services (legacy
src/services/*deleted): tags, categories, todo, packing, day-notes, trip-invite, assignments, share, settings, files, collab, vacay, reservations, day, permissions, audit, budget, trip — see the migration recipe below.
Cross-cutting Foundation pieces
common/idempotency.interceptor.ts— globalAPP_INTERCEPTORreplaying the client'sX-Idempotency-Keyon mutations, mirroring the legacyapplyIdempotencymiddleware so retried writes don't double-apply.strangler.ts— supports both static prefixes and:parampattern prefixes.app-config/— the@nestjs/configbinding (AppConfigModule, global). Never readprocess.envin a module (ESLint enforces this): inject a boot-stable namespace via itsregisterAstoken (@Inject(mcpConfig.KEY) … ConfigType<…>) or read runtime-toggled values live throughRuntimeEnvService/readEnv()fromsrc/app-config. The classification and invariants live insrc/app-config/README.md.
Parity gotchas worth remembering
- A POST that answers with
res.jsonin Express stays 200; add@HttpCode(200)(Nest defaults POST to 201). Creates that Express sends as 201 need nothing. - Static sub-routes that collide with a
:idparam (e.g./in-app/allvs/in-app/:id,/reordervs/:id) must be declared before the param route. - Reproduce bespoke admin/error wording exactly — e.g. notifications'
test-smtpreturns{ error: 'Admin only' }, not the AdminGuard'sAdmin access required. - Trip-scoped routes verify trip access (404) and the relevant permission (403)
per handler and forward
X-Socket-Idto the WebSocket broadcast.
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%.
-
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). Seeweather.controller.test.ts. -
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 theexpectParity()harness (tests/parity/parity.ts) and assert identical status + body. This is the gate before flipping the toggle. Seeweather.parity.test.ts. -
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 realJwtAuthGuardend-to-end (401 without cookie, 200 with a signed session). Mock external I/O (HTTP/etc.). Seeweather.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).
Migrating a legacy src/services/* service into its Nest module (recipe)
Pilot: tags (services/tagService.ts → nest/tags/tags.service.ts +
nest/tags/tags.bridge.ts); categories followed the same shape (and piloted the
first @Resource in categories.mcp.ts); todo followed too (and piloted the
first @ResourceTemplate plus the when addon gate in todo.mcp.ts, and the
first in-container consumer wiring: TripsService injects TodoService via
exports: [TodoService] instead of using the bridge); packing followed (the
largest port so far: a 17-tool + 2-resource packing.mcp.ts with inline admin
gates, and the first PluginHostDepsFactory swap done as part of a service
migration — no bridge entry for the plugin host); day-notes followed (the first
migration needing no bridge at all: after its three tools + resource moved
to day-notes.mcp.ts and the plugin host injection, nothing outside the
container consumed it); trip-invite followed (the smallest port: no MCP
surface, no plugin-host import and no bridge — the SQL folded straight into
trip-invite.service.ts); assignments followed (a 7-tool assignments.mcp.ts,
the plugin-host swap, and a bridge kept only for the two legacy registrars —
places and reservations — that borrow its existence checks; the batch loaders
stay in services/queryHelpers.ts, shared with the unmigrated place
service); share followed (never imported by the plugin host, and its three MCP
tools stay in the legacy trips registrar — their trips:share scope gate has
no declarative access: { group, mode } equivalent — so the port is the SQL
fold plus a 3-export share.bridge.ts for mcp/tools/trips.ts); settings
followed (no MCP surface, no bridge, and the first migration that converted an
in-container plain-function consumer into a provider instead of bridging it:
llm-parse/llm-config.resolver.ts became the injectable LlmConfigResolver,
injected by LlmParseService and PluginHostDepsFactory); files followed (no
MCP surface and no addon gate; the load-time constants + pure helpers consumed
by three controllers' module-scope multer configs moved to
files.constants.ts, and files.bridge.ts survives with a single export —
the request-time getAllowedExtensions app_settings read those configs need
outside DI — while every function consumer, including the plugin host and
TripsService, injects FilesService); collab followed (a 12-tool +
3-resource collab.mcp.ts that piloted composite when gates — the collab
addon AND the per-sub-feature getCollabFeatures() flags (notes/polls/chat)
that the legacy registrar and resources checked at registration time — plus
the plugin-host swap and a 3-export collab.bridge.ts for the two remaining
legacy consumers, tripService's trip summary and mcp/tools/trips.ts);
vacay followed (the largest MCP port yet — a 26-tool + 3-resource
vacay.mcp.ts, including the first fixed-URI @Resource behind a when
addon gate — plus the plugin-host swap and a 1-export vacay.bridge.ts for
tripService's trip-window entry shift; the DTO ratchet for its 13
grandfathered body contracts landed as a sibling commit); reservations
followed (the residue fold: the wrapper ReservationsService was already
DI-native at the edge, so the 626-line legacy module folded into it — a 5-tool
- 1-resource
reservations.mcp.ts, the plugin host's last plain-function reservation import (notifyBookingChange) swapped for the injected service,TripsServiceandBookingImportServiceconverted from function imports to injection, and a 9-export + 3-typereservations.bridge.tsfor the legacytripService, the airtrail import/sync pair and the still-legacy transit + transports registrars; the DTO ratchet for its 4 grandfathered body contracts landed as a sibling commit, which also loosened the shared positions schema to the real wire contract —day_plan_positionoptional, pinned by RESV-006); day followed (the 592-line legacydayServicefolded into the existing wrapperDaysService— including the accommodation SQL thatnest/reservations/accommodations.service.tsnow reaches via an injectedDaysService— with the hand-rolledBEGIN/COMMITblocks in reorder/insert converted todb.transaction(); a 7-tool + 2-resourcedays.mcp.ts, the plugin host's 11-symbol import swapped for the injected service,TripsService+ the assignments/reservations MCP controllers converted to injection, and a 6-exportdays.bridge.tsfor the legacytripServiceand the still-legacy transit + transports registrars; the DTO ratchet for its 4 day + 2 accommodation grandfathered body contracts landed as a sibling commit); permissions followed (the first Wave-2 cross-cutting migration and the first greenfield module in the series — no prior wrapper, controller, MCP surface or DTO of its own: a newnest/permissions/whosePermissionsServiceis injected by 16 domain services, the airtrail-import controller andPluginHostDepsFactory(its 16th constructor dep) in one move, plus a 5-functionpermissions.bridge.tsformcp/tools/_shared.ts— one repoint covering everyhasTripPermissioncall site — and the four legacy consumers adminService/authService/backupService/collectionsService; the permissions cache stays module-scoped inpermissions.service.tson purpose, so the bridge instance and the container singleton share one cache and backup-restore's bridge-sideinvalidatePermissionsCache()flushes what request handlers serve; the domain e2e suites swapped their path mocks for avi.spyOn(app.get(PermissionsService), 'checkPermission')instance spy); auditLog followed (the other Wave-2 half, split into five files: the injectableAuditService(writeAuditoverDatabaseService), the pureclient-ip.ts(files.constants precedent — the four getClientIp-only controllers stay plain imports), the deliberately side-effectful plainaudit-log.logger.ts— frozen-at-importLOG_LEVELand the import-timedata/logsmkdir are a documented parity exception to the no-side-effects rule becauseindex.tslazy-requires it pre-container and tests/setup.ts sets the level pre-import — plusaudit.module.tsand a full-surfaceaudit.bridge.tsformcp/index.ts,mcp/oauthProvider.tsand the legacy airtrail/immich/oauth services, while log*-only consumers (index.ts's lazy require strings, scheduler, globalMiddleware, notifications) point at the logger directly; 8 controllers +PluginRuntimeServiceinjectAuditService, and the domain e2e suites dropped the audit mock entirely — writeAudit runs for real against anaudit_logtable in their temp DBs). The exchangeRateService fold followed (the pure-infra FX helper — Frankfurter fetch + module-scoped 6h cache, no SQL, no controller, no MCP registrar of its own — folded intonest/budget/as a dep-freeExchangeRatesService, injected byBudgetServiceandPluginHostDepsFactory(its 17th constructor dep); the rate cache stays module-scoped like the permissions cache, so any out-of-container instance and the container singleton share one cached upstream feed). budgetService followed (the 755-line Wave-4 money core folded into the wrapperBudgetService: items/members/payers CRUD, the FX freeze + rebase paths and the settlement maths withsplitEqualSharesgone private; the freeze-then-write composites kept their wrapper names while the raw settlement writes becameinsertSettlement/applySettlementUpdateso the MCP paths keep skipping the freeze; the 11-toolmcp/tools/budget.tsregistrar + 3resources.tsbudget resources moved tobudget.mcp.ts; TripsService, ReservationsService (+ its MCP class) and BookingImportService injectBudgetService; a 4-exportbudget.bridge.tsserves the legacy tripService/userCleanupService and the trips/transports registrars;exchange-rates.bridge.tswas deleted with its last consumers, and the controller adoptedbudget.dto.ts— all nine allow-list entries removed). tripService followed (the 1121-line Wave-4 hub — the biggest fold — moved into the wrapperTripsService: TRIP_SELECT + list/create/get, thegenerateDaystwo-phase renumber engine, the updateTrip date-shift transaction, the member/guest lifecycle (#973/#1362), the ICS export with its module-scoped tz-validity cache,copyTripByIdandgetTripSummary; its six bridge imports became injected services (CollabService + VacayService joined the constructor); the 10-tool trips registrar + 3resources.tstrip resources + the trip-summary prompt moved totrips.mcp.ts— the first@Promptuse, with the fire-once static-token deprecation notice now riding theregistry.attachctx — and the 3 share-link tools it carried moved toshare.mcp.tson thecanShareTripspredicate (delete_trip and the canReadTrips reads are predicates too — the broadened legacy gates have no declarative equivalent); FeedsService and the plugin host injectTripsService(its 20th constructor dep); a 3-exporttrips.bridge.tsserves the legacy prompts registrar and budget.mcp.ts's owner/member seam (injecting there would need a forwardRef'd TripsModule↔BudgetModule cycle);todo.bridge.ts,share.bridge.ts,collab.bridge.tsandvacay.bridge.tswere deleted with their last consumers and the unused days/budget bridge exports pruned; the controller adoptedtrips.dto.ts— all seven allow-list entries removed). Repeat these steps per service (next up: the mapsService → transitService → placeService chain — or the notifications fan-in — per the dependency-honest order inmigration-graph.md). This is a pure relocation — byte-identical SQL, statuses, bodies, and error strings. The plugin RPC host is no longer a bridge consumer: since Option A ofsrc/nest/plugins/DI-MIGRATION.mdit injects domain services viaPluginHostDepsFactory, so a migrated domain addsexports: [XService]+ aPluginsModuleimport instead of a bridge entry. Only legacysrc/mcpregistrars (and scheduler/websocket code) still need bridges.
- Move the SQL into
<domain>.service.tsas methods over an injectedDatabaseService(this.db.all<T>/get<T>/run/prepare/transaction; strict constructor injection, no@Optional()). Preserve every quirk: falsy-coercion defaults (x || fallback, never??), post-insert/post-update re-selects (no RETURNING),COALESCEsemantics. If a controller already wraps the legacy functions, do not change the service's method surface. The module needs noimports: [DatabaseModule]— it's@Global. - Add
<domain>.bridge.tsnext to the service only if non-Nest consumers exist (legacy MCP tool registrars, scheduler, websocket — the plugin RPC host now injects instead, see above). It builds a module-level instance over the shared connection Proxy —new XService(new DatabaseService(db)), reinitialize-proof, same pattern asnest/todo/todo.bridge.ts— and exports the legacy function names 1:1. Container code injects the service; only outside-container code imports the bridge. When porting an MCP registrar, note theaccess: { group, mode }markers are typed against the scope-derivedScopeGroupunion and boot-validated bytrekMcpValidateAccess(src/mcp/nest-mcp-policy.ts) — an unknown group, ormode: 'write'on a read-only group (geo,weather), fails app boot. (Design decision, settled with the tags pilot: MCP tools stay outside the container and use the bridge. The alternative — handing the Nest app to the MCP layer viaapp.get(XService)— was rejected: it would thread the container throughmcpHandler+ every tool registrar and force a Nest bootstrap into the container-lesstests/helpers/mcp-harness.tsused by ~25 MCP suites.) - Repoint non-Nest consumers — import-path-only diffs from
services/<x>Servicetonest/<domain>/<domain>.bridge; call sites unchanged. - Delete the legacy service once
grep -rn "services/<x>Service" src testsonly hits the tests you're rewriting. - Tests:
- Move
tests/unit/services/<x>Service.test.ts→tests/unit/nest/<domain>.service.test.ts, preserving case IDs. Construct the service directly —new XService(new DatabaseService(testDb))— no TestingModule, nooverrideProvider(repo convention). Add one delegation case per bridge export: the bridge sits undersrc/nest/**, inside the ≥80% coverage gate, and these cases pin it deterministically. - Convert the module e2e to the DI-native pattern (exemplar
tests/e2e/trips.e2e.test.ts): temp-db DDL for the domain's tables,imports: [DatabaseModule, <Domain>Module], real SQL assertions. Keep only thevi.mock('../../src/db/database', …)— the auth guard still reads users through the singleton, andDatabaseModule's factory picks up the same mocked db. Drop the legacy-service mock entirely. - Suites that mocked the legacy module mock the bridge path instead — same
factory shape, path-only change. (For the plugin host suite,
plugin-host-deps.factory.test.ts, the domain becomes a constructor stub instead of a path mock.)
- Move
- Verify (from
server/):npm run typecheck,test:unit,test:integration,test:e2e,lint:check,test:coverage.