mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(server): delete trips.bridge via leaf membership reads and prompt relocation
This commit is contained in:
@@ -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).
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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).
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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_` } }],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 }>;
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user