refactor(server): delete trips.bridge via leaf membership reads and prompt relocation

This commit is contained in:
jubnl
2026-08-11 20:28:49 +02:00
parent e1162c1c07
commit 079064fdcd
20 changed files with 249 additions and 269 deletions
+23 -60
View File
@@ -1,5 +1,5 @@
import {
McpController, Prompt, Tool, ResourceTemplate, type McpContext,
McpController, Tool, ResourceTemplate, type McpContext,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, errorResult, ok,
@@ -9,7 +9,7 @@ import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { isDemoUserId } from '../common/demo-write';
import { ADDON_IDS } from '../../addons';
import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared';
import { getTripOwner, listMembers, getTripSummary } from '../trips/trips.bridge';
import { TripMembershipService } from '../trip-membership/trip-membership.service';
import { DatabaseService } from '../database/database.service';
import { BudgetService } from './budget.service';
import { ExchangeRatesService } from './exchange-rates.service';
@@ -30,20 +30,6 @@ function parseId(value: string | string[]): number | null {
return Number.isInteger(n) && n > 0 ? n : null;
}
/**
* Resolve the equal-split participants for a new budget item. When member_ids is
* omitted, default to the whole trip (owner + all members), deduped — reproducing
* the client's own create flow (CostsPanel seeds participants from all members).
* An explicit empty array means "planning-only, no split" and is passed through.
*/
function resolveMemberIds(tripId: number, member_ids?: number[]): number[] | undefined {
if (member_ids !== undefined) return member_ids;
const owner = getTripOwner(tripId);
if (!owner) return undefined;
const { members } = listMembers(tripId, owner.user_id);
return Array.from(new Set([owner.user_id, ...members.map(m => m.id)]));
}
/**
* Budget MCP surface — ported from the legacy registrars: the eleven tools
* from src/mcp/tools/budget.ts and the trek://trips/{tripId}/budget,
@@ -71,6 +57,7 @@ export class BudgetMcp {
private readonly exchangeRates: ExchangeRatesService,
private readonly db: DatabaseService,
private readonly env: RuntimeEnvService,
private readonly membership: TripMembershipService,
readonly addons: AddonsService,
) {}
@@ -79,6 +66,21 @@ export class BudgetMcp {
return isDemoUserId(this.env, this.db, userId);
}
/**
* Resolve the equal-split participants for a new budget item. When member_ids
* is omitted, default to the whole trip (owner + all members), deduped —
* reproducing the client's own create flow (CostsPanel seeds participants
* from all members). An explicit empty array means "planning-only, no split"
* and is passed through. Reads ride the leaf TripMembershipService — the
* hydrated member services all live in modules that import this one.
*/
private resolveMemberIds(tripId: number, member_ids?: number[]): number[] | undefined {
if (member_ids !== undefined) return member_ids;
const ownerId = this.membership.getOwnerId(tripId);
if (ownerId === null) return undefined;
return Array.from(new Set([ownerId, ...this.membership.listMemberUserIds(tripId)]));
}
// --- BUDGET ---
@Tool({
@@ -109,7 +111,7 @@ export class BudgetMcp {
if (this.isDemoUser(ctx.userId)) return demoDenied();
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
const members = resolveMemberIds(tripId, member_ids);
const members = this.resolveMemberIds(tripId, member_ids);
const itemData = { category, name, total_price, currency, member_ids: members, payers, expense_date, note };
// Freeze the live FX rate at entry time so a settled position isn't re-opened
// when live rates drift (#1445) — same as the REST create path.
@@ -206,7 +208,7 @@ export class BudgetMcp {
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
// Omitted userIds → default to the whole trip, matching create_budget_item.
const members = (userIds && userIds.length > 0) ? userIds : resolveMemberIds(tripId, undefined);
const members = (userIds && userIds.length > 0) ? userIds : this.resolveMemberIds(tripId, undefined);
try {
const item = this.db.transaction(() => {
const created = this.budget.createBudgetItem(tripId, { category, name, total_price, note, member_ids: members });
@@ -475,46 +477,7 @@ export class BudgetMcp {
};
}
/**
* Moved 1:1 from the legacy registrar src/mcp/tools/prompts.ts. The
* registration-time `if (isAddonEnabled(BUDGET))` became the same `when` gate
* the tools above use, and the trip lookup keeps going through trips.bridge —
* injecting TripsService here would close a TripsModule/BudgetModule cycle.
*/
@Prompt({
name: 'budget-overview',
title: 'Budget Overview',
description: 'Get a formatted budget summary for a trip',
argsSchema: {
tripId: z.number().int().positive().describe('Trip ID'),
},
when: budgetAddonOn,
})
async budgetOverviewPrompt({ tripId }: { tripId: number }, ctx: McpContext) {
if (!this.budget.verifyTripAccess(tripId, ctx.userId)) {
return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found or access denied.' } }] };
}
const summary = getTripSummary(tripId, ctx.userId);
if (!summary) {
return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found.' } }] };
}
const { trip, budget } = summary;
const currency = trip?.currency || 'EUR';
const byCategory = (budget?.items || []).reduce((acc: Record<string, number>, item: { category?: string; total_price?: number }) => {
const cat = item.category || 'Uncategorized';
acc[cat] = (acc[cat] || 0) + (item.total_price || 0);
return acc;
}, {} as Record<string, number>);
const total = Object.values(byCategory).reduce((sum, v) => sum + v, 0);
const lines = Object.entries(byCategory)
.sort(([, a], [, b]) => b - a)
.map(([cat, amount]) => `- ${cat}: ${amount} ${currency}`)
.join('\n');
const memberCount = Math.max(1, [summary.members?.owner, ...(summary.members?.collaborators || [])].filter(Boolean).length);
const perPerson = (total / memberCount).toFixed(2);
return {
description: `Budget overview for "${trip?.title || tripId}"`,
messages: [{ role: 'user' as const, content: { type: 'text' as const, text: `# Budget: ${trip?.title || 'Trip'}\n\n**Total: ${total} ${currency}** (${perPerson} ${currency} per person)\n\n${lines || 'No expenses recorded.'}` } }],
};
}
// The budget-overview prompt moved to trips/trip-prompts.mcp.ts: it reads the
// whole-trip summary, and the read model lives above this module (the fold
// that deleted trips.bridge).
}
+2 -1
View File
@@ -9,6 +9,7 @@ import { PluginGuardsModule } from '../plugins/host/plugin-guards.module';
import { RealtimeModule } from '../realtime/realtime.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { AppConfigModule } from '../app-config/app-config.module';
import { TripMembershipModule } from '../trip-membership/trip-membership.module';
import { AddonsModule } from '../addons/addons.module';
/** Budget domain (S4 — Phase 2 trip sub-domain). Registered in AppModule.
@@ -19,7 +20,7 @@ import { AddonsModule } from '../addons/addons.module';
* AppConfigModule is @Global in the app graph; the explicit import keeps the
* partial e2e TestingModules resolving RuntimeEnvService. */
@Module({
imports: [PermissionsModule, AppConfigModule, RealtimeModule, PluginGuardsModule, AddonsModule],
imports: [PermissionsModule, AppConfigModule, RealtimeModule, PluginGuardsModule, AddonsModule, TripMembershipModule],
controllers: [BudgetController],
providers: [BudgetService, ExchangeRatesService, BudgetMcp, ExchangeRatesRpc, CostsRpc],
// For in-container consumers (CostsRpc, TripsService,
+7 -4
View File
@@ -6,7 +6,7 @@ import { num, schemaMessage } from '../plugins/host/rpc-params';
import type { PluginRpcContext } from '../plugins/host/rpc-kit/types';
import { RealtimeService } from '../realtime/realtime.service';
import { DatabaseService } from '../database/database.service';
import { listTripsForUser } from '../trips/trips.bridge';
import { TripMembershipService } from '../trip-membership/trip-membership.service';
import { ADDON_IDS } from '../../addons';
import { BudgetService } from './budget.service';
@@ -33,6 +33,7 @@ export class CostsRpc {
private readonly db: DatabaseService,
private readonly realtime: RealtimeService,
private readonly guards: PluginGuards,
private readonly membership: TripMembershipService,
) {}
@PluginMethod('costs.getByTrip', { permission: 'db:read:costs' })
@@ -49,9 +50,11 @@ export class CostsRpc {
// the same way tripRead refuses one.
if (ctx.actingUserId === undefined) throw new ForbiddenResource('cost reads require an authenticated user context');
this.requireBudgetAddon();
// Through the bridge, not by injection: see the note on listTripsForUser.
const trips = listTripsForUser(ctx.actingUserId);
return trips.flatMap((t) => this.budget.listBudgetItems(t.id));
// The leaf membership read, not TripsService.list: TripsModule imports this
// one, so injecting TripsService here would close a cycle. Same id set,
// same newest-first order.
const tripIds = this.membership.listAccessibleTripIds(ctx.actingUserId);
return tripIds.flatMap((id) => this.budget.listBudgetItems(id));
}
@PluginMethod('costs.create', { permission: 'db:write:costs' })
@@ -9,8 +9,8 @@ import { NotificationsService } from './notifications.service';
/**
* The out-of-container NotificationsService, for the bridges that hand-build
* their collaborators (trips.bridge and airtrail.bridge — the permanent
* cycle-dodges; this file lives exactly as long as either does). The reminder
* their collaborators (airtrail.bridge is the last one — trips.bridge died
* 2026-08-11; this file lives exactly as long as it does). The reminder
* crons that used to reach it through a dedicated send bridge inject the
* container singleton now (ReminderJobsService); that bridge is gone.
*
+4 -43
View File
@@ -1,5 +1,5 @@
import {
McpController, Prompt, Tool, ResourceTemplate, type McpContext,
McpController, Tool, ResourceTemplate, type McpContext,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, errorResult, ok,
@@ -9,10 +9,6 @@ import { AuthService } from '../auth/auth.service';
import { ADDON_IDS } from '../../addons';
import { safeBroadcast, noAccess, hasTripPermission, permissionDenied, isAdminUser, adminRequired } from '../../mcp/tools/_shared';
import { PackingService } from './packing.service';
// trips.bridge, not an injected TripsService: TripsModule imports PackingModule,
// so injecting it here would close a module cycle. Same documented in-container
// exception as atlas.mcp.ts, and the legacy registrar reached for it the same way.
import { getTripSummary } from '../trips/trips.bridge';
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
@@ -473,42 +469,7 @@ export class PackingMcp {
};
}
/**
* Moved 1:1 from the legacy registrar src/mcp/tools/prompts.ts. The
* registration-time `if (isAddonEnabled(PACKING))` became the same `when`
* gate the tools above use.
*/
@Prompt({
name: 'packing-list',
title: 'Packing List',
description: 'Get a formatted packing checklist for a trip',
argsSchema: {
tripId: z.number().int().positive().describe('Trip ID'),
},
when: packingAddonOn,
})
async packingListPrompt({ tripId }: { tripId: number }, ctx: McpContext) {
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) {
return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found or access denied.' } }] };
}
// Hide other members' private items (#858) from the requesting user.
const items = this.packing.listItems(tripId, ctx.userId);
if (!items.length) {
return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'No packing items found for this trip.' } }] };
}
const grouped = items.reduce((acc: Record<string, unknown[]>, item: { category?: string }) => {
const cat = item.category || 'General';
if (!acc[cat]) acc[cat] = [];
acc[cat].push(item);
return acc;
}, {});
const lines = Object.entries(grouped).map(([cat, catItems]) =>
`## ${cat}\n${(catItems as { checked?: unknown; name?: string }[]).map((i) => `- [${i.checked ? 'x' : ' '}] ${i.name}`).join('\n')}`
).join('\n\n');
const { trip } = getTripSummary(tripId, ctx.userId) || {};
return {
description: `Packing list for "${trip?.title || tripId}"`,
messages: [{ role: 'user' as const, content: { type: 'text' as const, text: `# Packing List: ${trip?.title || 'Trip'}\n\n${lines}\n\n_${items.length} items across ${Object.keys(grouped).length} categories_` } }],
};
}
// The packing-list prompt moved to trips/trip-prompts.mcp.ts: it reads the
// whole-trip summary for the title, and the read model lives above this
// module (the fold that deleted trips.bridge).
}
+1 -1
View File
@@ -22,7 +22,7 @@ import { broadcast, broadcastToUser, getOnlineUserIds } from '../../websocket';
* method wasn't asked for — many test mocks provide `broadcast` only.
*
* The service is deliberately dependency-free so module-level bridge code
* (the trips.bridge.ts pattern) can construct it with a bare `new RealtimeService()`.
* (the airtrail.bridge.ts pattern) can construct it with a bare `new RealtimeService()`.
*/
@Injectable()
export class RealtimeService {
@@ -2,15 +2,51 @@ import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
/**
* Adding an existing user to a trip as a member. Its own domain rather than a
* method on auth or trip-invite: both of those call it, as does OIDC and the
* plugin RPC host, and folding it into either would put AuthModule and
* TripInviteModule in a cycle.
* Adding an existing user to a trip as a member, plus the leaf membership
* READS. Its own domain rather than a method on auth or trip-invite: both of
* those call it, as does OIDC and the plugin RPC host, and folding it into
* either would put AuthModule and TripInviteModule in a cycle.
*
* The reads exist for the same reason: BudgetMcp and CostsRpc need "who is on
* this trip" / "which trips can this user see", but every service that owns
* the hydrated answer (TripsService, TripMembersService, TripReadModelService)
* lives in a module that imports the budget domain, so injecting one there
* closes a real cycle. This module imports nothing, so it is the one place
* those id-level reads can live — the fold that deleted trips.bridge.
*/
@Injectable()
export class TripMembershipService {
constructor(private readonly db: DatabaseService) {}
/** The trip owner's user id, or null when the trip does not exist. */
getOwnerId(tripId: string | number): number | null {
const row = this.db.get<{ user_id: number }>('SELECT user_id FROM trips WHERE id = ?', tripId);
return row ? row.user_id : null;
}
/** Member user ids (owner excluded), in added_at order like listMembers. */
listMemberUserIds(tripId: string | number): number[] {
return this.db
.all<{ user_id: number }>('SELECT user_id FROM trip_members WHERE trip_id = ? ORDER BY added_at ASC', tripId)
.map((r) => r.user_id);
}
/**
* Ids of every trip the user owns or is a member of, newest first — the id
* half of TripsService.list(userId, null), same WHERE and ORDER BY.
*/
listAccessibleTripIds(userId: number): number[] {
return this.db
.prepare(`
SELECT t.id FROM trips t
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
WHERE (t.user_id = :userId OR m.user_id IS NOT NULL)
ORDER BY t.created_at DESC
`)
.all({ userId })
.map((r) => (r as { id: number }).id);
}
/**
* Add an existing user to a trip as a member, by user id.
*
+103
View File
@@ -0,0 +1,103 @@
import { McpController, Prompt, type McpContext } from '@trek/nest-mcp';
import { z } from 'zod';
import { ADDON_IDS } from '../../addons';
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { PackingService } from '../packing/packing.service';
import { TripReadModelService } from '../trip-read-model/trip-read-model.service';
import { TripsService } from './trips.service';
const budgetAddonOn = addonGate(ADDON_IDS.BUDGET);
const packingAddonOn = addonGate(ADDON_IDS.PACKING);
/**
* The two cross-domain prompts, relocated from budget.mcp.ts / packing.mcp.ts
* (where the 1:1 port from src/mcp/tools/prompts.ts had left them). Both read
* the whole-trip summary, and the read model lives ABOVE the budget/packing
* modules — TripReadModelModule imports both — so hosting them down there
* forced the trips.bridge seam. Here in the trips domain the summary is an
* injection, and that bridge is deleted. Names, gates and payloads are
* unchanged; the budget prompt's access check swaps
* BudgetService.verifyTripAccess for TripsService.canAccessTrip, both of which
* are the same DatabaseService.canAccessTrip call.
*/
@McpController()
export class TripPromptsMcp {
constructor(
private readonly trips: TripsService,
private readonly readModel: TripReadModelService,
private readonly packing: PackingService,
readonly addons: AddonsService,
) {}
@Prompt({
name: 'budget-overview',
title: 'Budget Overview',
description: 'Get a formatted budget summary for a trip',
argsSchema: {
tripId: z.number().int().positive().describe('Trip ID'),
},
when: budgetAddonOn,
})
async budgetOverviewPrompt({ tripId }: { tripId: number }, ctx: McpContext) {
if (!this.trips.canAccessTrip(tripId, ctx.userId)) {
return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found or access denied.' } }] };
}
const summary = this.readModel.getTripSummary(tripId, ctx.userId);
if (!summary) {
return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found.' } }] };
}
const { trip, budget } = summary;
const currency = trip?.currency || 'EUR';
const byCategory = (budget?.items || []).reduce((acc: Record<string, number>, item: { category?: string; total_price?: number }) => {
const cat = item.category || 'Uncategorized';
acc[cat] = (acc[cat] || 0) + (item.total_price || 0);
return acc;
}, {} as Record<string, number>);
const total = Object.values(byCategory).reduce((sum, v) => sum + v, 0);
const lines = Object.entries(byCategory)
.sort(([, a], [, b]) => b - a)
.map(([cat, amount]) => `- ${cat}: ${amount} ${currency}`)
.join('\n');
const memberCount = Math.max(1, [summary.members?.owner, ...(summary.members?.collaborators || [])].filter(Boolean).length);
const perPerson = (total / memberCount).toFixed(2);
return {
description: `Budget overview for "${trip?.title || tripId}"`,
messages: [{ role: 'user' as const, content: { type: 'text' as const, text: `# Budget: ${trip?.title || 'Trip'}\n\n**Total: ${total} ${currency}** (${perPerson} ${currency} per person)\n\n${lines || 'No expenses recorded.'}` } }],
};
}
@Prompt({
name: 'packing-list',
title: 'Packing List',
description: 'Get a formatted packing checklist for a trip',
argsSchema: {
tripId: z.number().int().positive().describe('Trip ID'),
},
when: packingAddonOn,
})
async packingListPrompt({ tripId }: { tripId: number }, ctx: McpContext) {
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) {
return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found or access denied.' } }] };
}
// Hide other members' private items (#858) from the requesting user.
const items = this.packing.listItems(tripId, ctx.userId);
if (!items.length) {
return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'No packing items found for this trip.' } }] };
}
const grouped = items.reduce((acc: Record<string, unknown[]>, item: { category?: string }) => {
const cat = item.category || 'General';
if (!acc[cat]) acc[cat] = [];
acc[cat].push(item);
return acc;
}, {});
const lines = Object.entries(grouped).map(([cat, catItems]) =>
`## ${cat}\n${(catItems as { checked?: unknown; name?: string }[]).map((i) => `- [${i.checked ? 'x' : ' '}] ${i.name}`).join('\n')}`
).join('\n\n');
const { trip } = this.readModel.getTripSummary(tripId, ctx.userId) || {};
return {
description: `Packing list for "${trip?.title || tripId}"`,
messages: [{ role: 'user' as const, content: { type: 'text' as const, text: `# Packing List: ${trip?.title || 'Trip'}\n\n${lines}\n\n_${items.length} items across ${Object.keys(grouped).length} categories_` } }],
};
}
}
-103
View File
@@ -1,103 +0,0 @@
import { db } from '../../db/database';
import { JourneyDomainService } from '../journey/journey-domain.service';
import { TrekPhotosRepository } from '../photos/trek-photos.repository';
import { DatabaseService } from '../database/database.service';
import { RealtimeService } from '../realtime/realtime.service';
import { PermissionsService } from '../permissions/permissions.service';
import { QueryHelpersService } from '../query-helpers/query-helpers.service';
import { UnsplashService } from '../unsplash/unsplash.service';
import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { TripsService } from './trips.service';
import { TodoService } from '../todo/todo.service';
import { PackingService } from '../packing/packing.service';
import { FilesService } from '../files/files.service';
import { ReservationsService } from '../reservations/reservations.service';
import { DaysService } from '../days/days.service';
import { BudgetService } from '../budget/budget.service';
import { ExchangeRatesService } from '../budget/exchange-rates.service';
import { CollabService } from '../collab/collab.service';
import { VacayService } from '../vacay/vacay.service';
import { PlacesService } from '../places/places.service';
import { MapsService } from '../maps/maps.service';
import { UserCleanupService } from '../auth/user-cleanup.service';
import { AccommodationsService } from '../accommodations/accommodations.service';
import { TripMembersService } from '../trip-members/trip-members.service';
import { TripReadModelService } from '../trip-read-model/trip-read-model.service';
import { notificationsInstance } from '../notifications/notifications.instance';
import { EphemeralTokenService } from '../auth/ephemeral-token.service';
/**
* Verified-permanent cycle-dodge for the trip domain. Three in-container
* consumers cannot inject the backing services: budget.mcp.ts (getTripOwner /
* listMembers / getTripSummary), packing.mcp.ts (getTripSummary) and
* costs.rpc.ts (listTripsForUser). Every direction was checked 2026-08-11 and
* every one closes a real module cycle — TripsModule imports BudgetModule and
* PackingModule, TripReadModelModule imports both, and TripMembersModule
* imports BudgetModule, so none of TripsService, TripReadModelService or
* TripMembersService can be injected into the budget/packing surfaces without
* forwardRef. Exports only what those three consume, 1:1. Everything else
* injects the services. This file dies if the trip read model ever stops
* importing the budget/packing domains, not before.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton.
*/
const dbs = () => new DatabaseService(db);
// One instance, not one per consumer: the in-flight dedup only works if the
// stampede guard is shared (see PlacePhotoCacheService).
const photoCache = new PlacePhotoCacheService(dbs(), new RuntimeEnvService());
const journeyDomain = new JourneyDomainService(dbs(), new RealtimeService(), new TrekPhotosRepository(dbs()));
const budget = new BudgetService(dbs(), new PermissionsService(dbs()), new ExchangeRatesService(), new RealtimeService());
const permissions = new PermissionsService(dbs());
const realtime = new RealtimeService();
const days = new DaysService(dbs(), permissions, realtime, new QueryHelpersService(dbs()));
const accommodations = new AccommodationsService(dbs(), permissions, realtime);
const reservations = new ReservationsService(dbs(), permissions, budget, realtime, notificationsInstance());
const places = new PlacesService(dbs(), permissions, realtime, new MapsService(dbs(), photoCache), new QueryHelpersService(dbs()), new UnsplashService(dbs(), new RuntimeEnvService()), photoCache, journeyDomain);
const trips = new TripsService(
dbs(),
reservations,
days,
permissions,
budget,
new VacayService(dbs(), realtime, notificationsInstance()),
realtime,
new UnsplashService(dbs(), new RuntimeEnvService()),
);
const members = new TripMembersService(dbs(), budget, new UserCleanupService(dbs(), budget), permissions, realtime, notificationsInstance());
const readModel = new TripReadModelService(
dbs(),
members,
days,
accommodations,
budget,
new PackingService(dbs(), permissions, realtime, notificationsInstance()),
reservations,
new CollabService(dbs(), permissions, realtime, notificationsInstance()),
places,
new TodoService(dbs(), permissions, realtime),
new FilesService(dbs(), permissions, realtime, new EphemeralTokenService()),
);
export function getTripOwner(tripId: string | number) {
return trips.getOwner(tripId);
}
export function listMembers(tripId: string | number, tripOwnerId: number) {
return members.listMembers(tripId, tripOwnerId);
}
export function getTripSummary(tripId: number, viewerUserId?: number) {
return readModel.getTripSummary(tripId, viewerUserId);
}
/**
* Every trip the user can access. Used by CostsRpc for the cross-trip cost feed:
* BudgetModule cannot import TripsModule, because TripsModule already imports
* BudgetModule, and injecting it would need a forwardRef'd cycle. Same reason
* budget.mcp.ts reaches for this file.
*/
export function listTripsForUser(userId: number) {
return trips.list(userId, null) as Array<{ id: number }>;
}
+2 -1
View File
@@ -12,6 +12,7 @@ import { AuditModule } from '../audit/audit.module';
import { PlacesModule } from '../places/places.module';
import { TripsController } from './trips.controller';
import { TripsService } from './trips.service';
import { TripPromptsMcp } from './trip-prompts.mcp';
import { TripsRpc } from './trips.rpc';
import { PluginGuardsModule } from '../plugins/host/plugin-guards.module';
import { RealtimeModule } from '../realtime/realtime.module';
@@ -31,7 +32,7 @@ import { AddonsModule } from '../addons/addons.module';
@Module({
imports: [TodoModule, PackingModule, FilesModule, ReservationsModule, DaysModule, PermissionsModule, AuditModule, BudgetModule, CollabModule, VacayModule, PlacesModule, AuthModule, AppConfigModule, UnsplashModule, RealtimeModule, PluginGuardsModule, AddonsModule, TripMembershipModule, CalendarModule, AccommodationsModule, TripMembersModule, TripReadModelModule],
controllers: [TripsController],
providers: [TripsService, TripsMcp, TripsRpc],
providers: [TripsService, TripsMcp, TripPromptsMcp, TripsRpc],
// Exported for FeedsModule (ICS feeds) and PluginsModule (RPC host injection).
exports: [TripsService],
})
+3 -1
View File
@@ -65,6 +65,7 @@ import { AccommodationsService } from '../../src/nest/accommodations/accommodati
import { AccommodationsMcp } from '../../src/nest/accommodations/accommodations.mcp';
import { TripMembersService } from '../../src/nest/trip-members/trip-members.service';
import { TripReadModelService } from '../../src/nest/trip-read-model/trip-read-model.service';
import { TripPromptsMcp } from '../../src/nest/trips/trip-prompts.mcp';
import { PlacePhotoCacheService } from '../../src/nest/place-photos/place-photo-cache.service';
import { RuntimeEnvService } from '../../src/nest/app-config/runtime-env.service';
import { makeNotificationsService, makeNotificationPreferencesService } from './notifications';
@@ -156,7 +157,7 @@ export function createMcpTestRegistry(): McpRegistry {
new AuthMcp(),
new TodoMcp(todoService, authService, addonsService),
new PackingMcp(packingService, authService, addonsService),
new BudgetMcp(budgetService, exchangeRatesService, dbService, new RuntimeEnvService(), addonsService),
new BudgetMcp(budgetService, exchangeRatesService, dbService, new RuntimeEnvService(), new TripMembershipService(dbService), addonsService),
new ReservationsMcp(reservationsService, daysService, budgetService, authService, assignmentsService),
new DayNotesMcp(new DayNotesService(dbService, permissionsService, realtimeService), authService),
new DaysMcp(daysService, authService),
@@ -165,6 +166,7 @@ export function createMcpTestRegistry(): McpRegistry {
new CollabMcp(collabService, authService, addonsService),
new VacayMcp(new VacayService(dbService, realtimeService, notificationsStub()), authService, addonsService),
new TripsMcp(tripsService, todoService, collabService, authService, calendarService, membersService, readModelService, addonsService),
new TripPromptsMcp(tripsService, readModelService, packingService, addonsService),
new ShareMcp(new ShareService(dbService, new SettingsService(dbService), permissionsService, queryHelpersService, placePhotoCache), authService),
new MapsMcp(mapsService),
new PlacesMcp(placesService, mapsService, dbService, authService, journeyDomain),
+1 -1
View File
@@ -125,7 +125,7 @@ export function createPluginRpcHostFactory(dbs: DatabaseService): PluginRpcHostF
new AccommodationsRpc(accommodations, realtime, guards),
new ItineraryRpc(assignments, realtime, guards),
new TripsRpc(trips, reservations, days, membership, dbs, realtime, guards, accommodations, members),
new CostsRpc(budget, dbs, realtime, guards),
new CostsRpc(budget, dbs, realtime, guards, membership),
new ReservationsRpc(reservations, realtime, guards),
new CollabRpc(collab, realtime, guards),
new AtlasRpc(atlas, guards),
+1 -1
View File
@@ -72,7 +72,7 @@ export function allRpcControllers(): object[] {
new DaysRpc(anyService(), anyService(), anyService()),
new ItineraryRpc(anyService(), anyService(), anyService()),
new TripsRpc(anyService(), anyService(), anyService(), anyService(), anyService(), anyService(), anyService(), anyService(), anyService()),
new CostsRpc(anyService(), anyService(), anyService(), anyService()),
new CostsRpc(anyService(), anyService(), anyService(), anyService(), anyService()),
new ReservationsRpc(anyService(), anyService(), anyService()),
new AccommodationsRpc(anyService(), anyService(), anyService()),
new CollabRpc(anyService(), anyService(), anyService()),
+4 -5
View File
@@ -17,13 +17,10 @@ import type { RealtimeService } from '../../../src/nest/realtime/realtime.servic
import type { DatabaseService } from '../../../src/nest/database/database.service';
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import type { AddonsService } from '../../../src/nest/addons/addons.service';
import type { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service';
import type { RpcRequest, RpcError } from '../../../src/nest/plugins/protocol/envelope';
import { makeDeps } from '../../helpers/rpc-host-deps';
vi.mock('../../../src/nest/trips/trips.bridge', () => ({
listTripsForUser: vi.fn(() => [{ id: 1 }, { id: 2 }]),
}));
const req = (method: string, params: Record<string, unknown> = {}): RpcRequest => ({ k: 'req', id: 'x', method, params });
/** Trip 1 belongs to user 42, cost 5 sits on it, and the addon is on unless said otherwise. */
@@ -44,7 +41,9 @@ function build(opts: { addonOn?: boolean; canEdit?: boolean; missing?: boolean }
{ checkPermission: vi.fn(() => opts.canEdit ?? true) } as unknown as PermissionsService,
{ isAddonEnabled: vi.fn(() => opts.addonOn ?? true) } as unknown as AddonsService,
);
const rpc = new CostsRpc(budget, db, realtime, guards);
// The leaf membership read replaced the deleted trips.bridge for listMine.
const membership = { listAccessibleTripIds: vi.fn(() => [1, 2]) } as unknown as TripMembershipService;
const rpc = new CostsRpc(budget, db, realtime, guards, membership);
const host = (...grants: string[]) =>
new PluginRpcHost('p', new Set(grants.length ? grants : ['db:read:costs', 'db:write:costs']), makeDeps(), createTestPluginRegistry([rpc]));
return { budget, realtime, host };
+11 -12
View File
@@ -54,12 +54,8 @@ const addonsStub = {
const { mockGetTripSummary } = vi.hoisted(() => ({
mockGetTripSummary: vi.fn(),
}));
// prompts.ts consumes getTripSummary via the trips bridge since the trip fold.
vi.mock('../../../src/nest/trips/trips.bridge', () => ({
getTripSummary: mockGetTripSummary,
getTripOwner: vi.fn(),
listMembers: vi.fn(() => ({ members: [] })),
}));
// The prompts read the summary through the injected read model (readModelStub
// below wraps the same controllable mock) — trips.bridge is deleted.
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
@@ -68,6 +64,8 @@ import { createUser, createTrip, addTripMember, createPackingItem, createBudgetI
import { createTestRegistry } from '@trek/nest-mcp';
import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../../src/mcp/nest-mcp-policy';
import { TripsMcp } from '../../../src/nest/trips/trips.mcp';
import { TripPromptsMcp } from '../../../src/nest/trips/trip-prompts.mcp';
import { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service';
import { PackingMcp } from '../../../src/nest/packing/packing.mcp';
import { PackingService } from '../../../src/nest/packing/packing.service';
import { BudgetMcp } from '../../../src/nest/budget/budget.mcp';
@@ -112,18 +110,19 @@ const tripsMcp = new TripsMcp(
// in-memory DB so the cases below keep asserting real rows.
const promptDbs = () => new DatabaseService(testDb);
const authStub = { isDemoUser: () => false } as unknown as AuthService;
const packingMcp = new PackingMcp(
new PackingService(promptDbs(), new PermissionsService(promptDbs()), new RealtimeService(), notificationsStub()),
authStub,
addonsStub,
);
const promptPackingService = new PackingService(promptDbs(), new PermissionsService(promptDbs()), new RealtimeService(), notificationsStub());
const packingMcp = new PackingMcp(promptPackingService, authStub, addonsStub);
const budgetMcp = new BudgetMcp(
new BudgetService(promptDbs(), new PermissionsService(promptDbs()), new ExchangeRatesService(), new RealtimeService()),
new ExchangeRatesService(),
promptDbs(),
new RuntimeEnvService(),
new TripMembershipService(promptDbs()),
addonsStub,
);
// The packing-list / budget-overview prompts live here since the trips.bridge
// fold; the summary rides the same readModelStub the trip-summary prompt uses.
const tripPromptsMcp = new TripPromptsMcp(tripsStub, readModelStub, promptPackingService, addonsStub);
const authMcp = new AuthMcp();
beforeAll(() => {
@@ -177,7 +176,7 @@ function buildServer(userId: number, opts: { isStaticToken?: boolean } = {}): Mc
const server = new McpServer({ name: 'trek-test', version: '1.0.0' });
// Every prompt is DI-discovered now; attach them the way registerTools does in
// production, including the isStaticToken flag the notice's `when` gate reads.
createTestRegistry([tripsMcp, packingMcp, budgetMcp, authMcp], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess })
createTestRegistry([tripsMcp, tripPromptsMcp, packingMcp, budgetMcp, authMcp], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess })
.attach(server, { userId, scopes: null, isStaticToken: opts.isStaticToken ?? false });
return server;
}
@@ -83,7 +83,7 @@ const KML_FIXTURE = path.join(__dirname, '../../fixtures/test.kml');
const dbs = new DatabaseService(testDb);
/**
* Same collaborator set the container hands PlacesService (see trips.bridge.ts),
* Same collaborator set the container hands PlacesService,
* built once here so the two construction sites cannot drift apart again. The
* journey domain is a real instance on the same in-memory DB rather than a stub:
* its place hooks are fire-and-forget behind a catch, so a missing one would have
@@ -68,3 +68,42 @@ describe('joinTripAsMember', () => {
expect(r.joined).toBe(false);
});
});
// The leaf reads that replaced trips.bridge for BudgetMcp/CostsRpc — see the
// service docblock for why they live on this dependency-free module.
describe('leaf membership reads', () => {
const svc = () => new TripMembershipService(new DatabaseService(testDb));
it('TRIP-READ-001: getOwnerId answers the owner and null for a missing trip', () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
expect(svc().getOwnerId(trip.id)).toBe(owner.id);
expect(svc().getOwnerId(999999)).toBeNull();
});
it('TRIP-READ-002: listMemberUserIds excludes the owner and follows added_at order', () => {
const { user: owner } = createUser(testDb);
const { user: m1 } = createUser(testDb);
const { user: m2 } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
testDb.prepare("INSERT INTO trip_members (trip_id, user_id, added_at) VALUES (?, ?, '2026-01-02')").run(trip.id, m2.id);
testDb.prepare("INSERT INTO trip_members (trip_id, user_id, added_at) VALUES (?, ?, '2026-01-01')").run(trip.id, m1.id);
expect(svc().listMemberUserIds(trip.id)).toEqual([m1.id, m2.id]);
expect(svc().listMemberUserIds(999999)).toEqual([]);
});
it('TRIP-READ-003: listAccessibleTripIds unions owned and member trips, newest first', () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const owned = createTrip(testDb, user.id);
const memberOf = createTrip(testDb, other.id);
const foreign = createTrip(testDb, other.id);
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(memberOf.id, user.id);
// Distinct created_at so the ORDER BY is actually asserted, not assumed.
testDb.prepare("UPDATE trips SET created_at = '2026-01-01' WHERE id = ?").run(owned.id);
testDb.prepare("UPDATE trips SET created_at = '2026-01-02' WHERE id = ?").run(memberOf.id);
const ids = svc().listAccessibleTripIds(user.id);
expect(ids).toEqual([memberOf.id, owned.id]);
expect(ids).not.toContain(foreign.id);
});
});
@@ -80,7 +80,7 @@ import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repos
const dbs = () => new DatabaseService(testDb);
const budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs()), new ExchangeRatesService(), new RealtimeService());
const daysSvc = new DaysService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new QueryHelpersService(dbs()));
// One shared cache instance, as in trips.bridge.ts: the in-flight dedup in
// One shared cache instance (the PlacePhotoCacheService rule): the in-flight dedup in
// PlacePhotoCacheService only works while both consumers hold the same object.
const photoCache = new PlacePhotoCacheService(dbs(), new RuntimeEnvService());
const placesSvc = new PlacesService(
+3 -25
View File
@@ -2,8 +2,8 @@
* Unit tests for the DI-native TripsService — TRIP-SVC-001 through TRIP-SVC-058
* (001–038 moved 1:1 from the legacy tests/unit/services/tripService.test.ts;
* the exportICS cases that duplicated the generateDays 010–012 numbering were
* renumbered to 024–026 with the post-fold quirk-fix commit; 040–041 pin the
* trips.bridge delegation; 042–050 cover the folded
* renumbered to 024–026 with the post-fold quirk-fix commit; 040–041 pinned
* the deleted trips.bridge and died with it; 042–050 cover the folded
* summary/list/create/delete/copy SQL; 051–053 pin the post-fold quirk fixes
* (transactional deletes, owner display_name)). Uses a real in-memory SQLite
* DB so SQL logic is exercised faithfully.
@@ -78,7 +78,6 @@ import { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-pho
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
import { getTripOwner, listMembers as bridgeListMembers } from '../../../src/nest/trips/trips.bridge';
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
import fs from 'fs';
import { notificationsStub } from '../../helpers/notifications';
@@ -89,7 +88,7 @@ import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.se
const dbs = () => new DatabaseService(testDb);
const budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs()), new ExchangeRatesService(), new RealtimeService());
const daysSvc = new DaysService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new QueryHelpersService(dbs()));
// Same collaborator set the container hands PlacesService (see trips.bridge.ts).
// Same collaborator set the container hands PlacesService (see places.service.test.ts).
// Only the read-model aggregation reaches into places here, but the photo cache,
// Unsplash and journey domain are real instances over the same in-memory DB
// rather than casts: the place hooks are fire-and-forget behind a catch, so a
@@ -641,27 +640,6 @@ describe('guest members (#1362)', () => {
});
});
// ── trips.bridge delegation (coverage gate) ───────────────────────────────────
describe('trips.bridge delegation', () => {
it('TRIP-SVC-040: getTripOwner delegates 1:1', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(getTripOwner(trip.id)).toEqual({ user_id: user.id });
expect(getTripOwner(99999)).toBeUndefined();
});
it('TRIP-SVC-041: listMembers delegates 1:1', () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
const result = bridgeListMembers(trip.id, owner.id);
expect(result.owner.id).toBe(owner.id);
expect(result.members.map(m => m.id)).toEqual([member.id]);
});
});
// ── Folded CRUD SQL (summary / list / create / delete / copy) ─────────────────
describe('folded trip CRUD', () => {
@@ -26,8 +26,6 @@ import type { AddonsService } from '../../../src/nest/addons/addons.service';
import type { RpcRequest, RpcError } from '../../../src/nest/plugins/protocol/envelope';
import { makeDeps } from '../../helpers/rpc-host-deps';
vi.mock('../../../src/nest/trips/trips.bridge', () => ({ listTripsForUser: vi.fn(() => [{ id: 1 }]) }));
const req = (method: string, params: Record<string, unknown> = {}): RpcRequest => ({ k: 'req', id: 'x', method, params });
/** Records every service call, so "the service was never touched" is checkable. */
@@ -67,7 +65,7 @@ function build(addonOn: boolean) {
new VacayRpc(spyService(calls, 'vacay'), guards),
new JournalRpc(spyService(calls, 'journey'), guards),
new CollectionsRpc(spyService(calls, 'collections'), guards),
new CostsRpc(spyService(calls, 'budget'), db, { broadcast: vi.fn() } as never, guards),
new CostsRpc(spyService(calls, 'budget'), db, { broadcast: vi.fn() } as never, guards, spyService(calls, 'membership')),
]);
const granted = new Set([
'db:read:collab', 'db:write:collab', 'db:read:journal', 'db:write:journal',