mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
refactor(server): let user-cleanup inject BudgetService and delete budget.bridge
This commit is contained in:
@@ -2,6 +2,9 @@ import { db } from '../../db/database';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { TripMembershipService } from '../trip-membership/trip-membership.service';
|
||||
import { BudgetService } from '../budget/budget.service';
|
||||
import { ExchangeRatesService } from '../budget/exchange-rates.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import { TokenService } from '../tokens/token.service';
|
||||
import { MailerService } from '../notifications/mailer/mailer.service';
|
||||
@@ -21,7 +24,8 @@ import { EphemeralTokenService } from './ephemeral-token.service';
|
||||
* verifyJwtToken is login identity and deliberately stays on AuthService
|
||||
* (see token.service.ts), so the whole AuthService collaborator graph below
|
||||
* exists to serve that one call. WebauthnConfigService and UserCleanupService
|
||||
* are constructed only because AuthService takes them — nothing this file
|
||||
* (with the BudgetService it injects) are constructed only because AuthService
|
||||
* takes them — nothing this file
|
||||
* exports reaches either one. The pending-MFA and reset-throttle maps are
|
||||
* module-scoped in auth.service.ts, so this instance and the container
|
||||
* singleton share one copy.
|
||||
@@ -36,7 +40,7 @@ const auth = new AuthService(
|
||||
permissions,
|
||||
new TripMembershipService(dbs),
|
||||
new WebauthnConfigService(dbs),
|
||||
new UserCleanupService(dbs),
|
||||
new UserCleanupService(dbs, new BudgetService(dbs, permissions, new ExchangeRatesService(), new RealtimeService())),
|
||||
new MailerService(dbs),
|
||||
new EphemeralTokenService(),
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AuthMcp } from './auth.mcp';
|
||||
import { UserCleanupService } from './user-cleanup.service';
|
||||
import { WebauthnConfigService } from './webauthn-config.service';
|
||||
import { AppConfigModule } from '../app-config/app-config.module';
|
||||
import { BudgetModule } from '../budget/budget.module';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { MailerModule } from '../notifications/mailer/mailer.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
@@ -31,7 +32,10 @@ import { EphemeralTokenModule } from './ephemeral-token.module';
|
||||
* AtlasModule is deliberately absent. getTravelStats was the only reason for it
|
||||
* and now belongs to AtlasService; the direction is reversed, so AtlasModule
|
||||
* imports AuthModule and atlas.mcp.ts can inject AuthService instead of routing
|
||||
* through auth.bridge. AuthService is
|
||||
* through auth.bridge. BudgetModule went the same way: it stopped importing
|
||||
* AuthModule when BudgetMcp's demo guard moved off AuthService, so this module
|
||||
* imports it and UserCleanupService injects BudgetService (which retired
|
||||
* budget.bridge). AuthService is
|
||||
* exported for the in-container consumers (the domain *.mcp.ts demo guards,
|
||||
* OidcService, PasskeyEnabledGuard); PasskeyService for AdminService's passkey
|
||||
* reset; UserCleanupService for the two account-deletion paths (AdminService)
|
||||
@@ -39,7 +43,7 @@ import { EphemeralTokenModule } from './ephemeral-token.module';
|
||||
* through auth.bridge.ts.
|
||||
*/
|
||||
@Module({
|
||||
imports: [EphemeralTokenModule, RateLimitModule, AuditModule, PermissionsModule, TripMembershipModule, MailerModule, AppConfigModule, TokensModule],
|
||||
imports: [EphemeralTokenModule, RateLimitModule, AuditModule, PermissionsModule, TripMembershipModule, MailerModule, AppConfigModule, TokensModule, BudgetModule],
|
||||
controllers: [AuthPublicController, AuthController, PasskeyController],
|
||||
providers: [AuthService, UserProfileService, RegistrationInvitesService, PasskeyService, UserCleanupService, WebauthnConfigService, AuthMcp],
|
||||
exports: [AuthService, RegistrationInvitesService, PasskeyService, UserCleanupService],
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import fs from 'node:fs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
// budget.bridge, not an injected BudgetService: BudgetModule imports AuthModule
|
||||
// (BudgetMcp's demo guard), so AuthModule importing BudgetModule back would
|
||||
// close a module cycle — and breaking it the other way would drag
|
||||
// BudgetController, BudgetMcp and its trips.bridge/MCP `_shared` chain into
|
||||
// every graph that imports AuthModule, including the single-domain e2e
|
||||
// TestingModules. Same documented in-container exception as atlas.mcp.ts.
|
||||
// The bridge instance runs its SQL over the shared db Proxy, and the re-split
|
||||
// keeps no state, so the second BudgetService is behaviourally identical.
|
||||
import { removeUserFromBudgetItems } from '../budget/budget.bridge';
|
||||
// Injected since BudgetModule dropped its AuthModule import (BudgetMcp's demo
|
||||
// guard reads RuntimeEnvService + the users table now), which un-closed the
|
||||
// AuthModule -> BudgetModule cycle that used to force budget.bridge here.
|
||||
import { BudgetService } from '../budget/budget.service';
|
||||
import { pluginsDataRoot } from '../plugins/paths';
|
||||
|
||||
/**
|
||||
@@ -26,7 +21,10 @@ import { pluginsDataRoot } from '../plugins/paths';
|
||||
*/
|
||||
@Injectable()
|
||||
export class UserCleanupService {
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
private readonly budget: BudgetService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Erase a user's PLUGIN-held data on account deletion. Two parts:
|
||||
@@ -68,7 +66,7 @@ export class UserCleanupService {
|
||||
private cleanupUserReferences(userId: number): void {
|
||||
this.db.run('UPDATE trip_members SET invited_by = NULL WHERE invited_by = ?', userId);
|
||||
this.db.run('UPDATE budget_items SET paid_by_user_id = NULL WHERE paid_by_user_id = ?', userId);
|
||||
removeUserFromBudgetItems(userId);
|
||||
this.budget.removeUserFromBudgetItems(userId);
|
||||
this.db.run('DELETE FROM share_tokens WHERE created_by = ?', userId);
|
||||
this.db.run('DELETE FROM journey_share_tokens WHERE created_by = ?', userId);
|
||||
// Owned journeys cascade-delete their entries/contributors/share_tokens/photos via journey_id FKs
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { db } from '../../db/database';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { BudgetService } from './budget.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { ExchangeRatesService } from './exchange-rates.service';
|
||||
|
||||
/**
|
||||
* One in-container exception, and nothing else left:
|
||||
* UserCleanupService (nest/auth) takes removeUserFromBudgetItems from here
|
||||
* because BudgetModule imports AuthModule for BudgetMcp's demo guard, so
|
||||
* AuthModule cannot import BudgetModule back — see the note there. (The budget MCP
|
||||
* tools and resources moved to the DI-discovered budget.mcp.ts, the plugin
|
||||
* surface, CostsRpc, injects BudgetService directly, and the trip
|
||||
* domain folded into the DI-native TripsService, which injects BudgetService —
|
||||
* its bridge exports were pruned with it). Exports only the legacy
|
||||
* services/budgetService names still consumed outside the container, 1:1, so
|
||||
* Inside the container, inject BudgetService instead. The create_transport
|
||||
* registrar was the last outside-container consumer; it moved into
|
||||
* reservations.mcp.ts, so this file dies the moment BudgetMcp stops needing
|
||||
* AuthService.
|
||||
*
|
||||
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
|
||||
* the shared better-sqlite3 singleton, and ExchangeRatesService keeps its FX
|
||||
* cache module-scoped on purpose so this instance and the DI singleton share
|
||||
* one cache.
|
||||
*/
|
||||
const budget = new BudgetService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new ExchangeRatesService(), new RealtimeService());
|
||||
|
||||
export function removeUserFromBudgetItems(userId: number): void {
|
||||
budget.removeUserFromBudgetItems(userId);
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
demoDenied, errorResult, ok,
|
||||
} from '@trek/nest-mcp';
|
||||
import { z } from 'zod';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
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';
|
||||
@@ -69,10 +70,15 @@ export class BudgetMcp {
|
||||
private readonly budget: BudgetService,
|
||||
private readonly exchangeRates: ExchangeRatesService,
|
||||
private readonly db: DatabaseService,
|
||||
private readonly auth: AuthService,
|
||||
private readonly env: RuntimeEnvService,
|
||||
readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
/** The AuthService.isDemoUser check without the auth graph (demo-write.ts). */
|
||||
private isDemoUser(userId: number): boolean {
|
||||
return isDemoUserId(this.env, this.db, userId);
|
||||
}
|
||||
|
||||
// --- BUDGET ---
|
||||
|
||||
@Tool({
|
||||
@@ -100,7 +106,7 @@ export class BudgetMcp {
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
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);
|
||||
@@ -125,7 +131,7 @@ export class BudgetMcp {
|
||||
access: { group: 'budget', mode: 'write' },
|
||||
})
|
||||
async deleteBudgetItem({ tripId, itemId }: { tripId: number; itemId: number }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
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 deleted = this.budget.deleteBudgetItem(itemId, tripId);
|
||||
@@ -162,7 +168,7 @@ export class BudgetMcp {
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
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();
|
||||
// Freeze-then-write composite (no-op while the schema has no currency input,
|
||||
@@ -196,7 +202,7 @@ export class BudgetMcp {
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
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();
|
||||
// Omitted userIds → default to the whole trip, matching create_budget_item.
|
||||
@@ -227,7 +233,7 @@ export class BudgetMcp {
|
||||
access: { group: 'budget', mode: 'write' },
|
||||
})
|
||||
async setBudgetItemMembers({ tripId, itemId, userIds }: { tripId: number; itemId: number; userIds: number[] }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
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 result = this.budget.updateMembers(itemId, tripId, userIds);
|
||||
@@ -251,7 +257,7 @@ export class BudgetMcp {
|
||||
access: { group: 'budget', mode: 'write' },
|
||||
})
|
||||
async toggleBudgetMemberPaid({ tripId, itemId, memberId, paid }: { tripId: number; itemId: number; memberId: number; paid: boolean }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
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 member = this.budget.toggleMemberPaid(itemId, tripId, memberId, paid);
|
||||
@@ -314,7 +320,7 @@ export class BudgetMcp {
|
||||
{ tripId, from_user_id, to_user_id, amount }: { tripId: number; from_user_id: number; to_user_id: number; amount: number },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
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();
|
||||
// Freeze-then-write composite, same as the REST path (no-op while the
|
||||
@@ -342,7 +348,7 @@ export class BudgetMcp {
|
||||
{ tripId, settlementId, from_user_id, to_user_id, amount }: { tripId: number; settlementId: number; from_user_id: number; to_user_id: number; amount: number },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
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();
|
||||
// Freeze-then-write composite, same as the REST path.
|
||||
@@ -364,7 +370,7 @@ export class BudgetMcp {
|
||||
access: { group: 'budget', mode: 'write' },
|
||||
})
|
||||
async deleteSettlement({ tripId, settlementId }: { tripId: number; settlementId: number }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
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 deleted = this.budget.deleteSettlement(settlementId, tripId);
|
||||
|
||||
@@ -8,13 +8,18 @@ import { CostsRpc } from './costs.rpc';
|
||||
import { PluginGuardsModule } from '../plugins/host/plugin-guards.module';
|
||||
import { RealtimeModule } from '../realtime/realtime.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { AppConfigModule } from '../app-config/app-config.module';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
|
||||
/** Budget domain (S4 — Phase 2 trip sub-domain). Registered in AppModule.
|
||||
* BudgetMcp carries the decorator-registered MCP tools + resources. */
|
||||
* BudgetMcp carries the decorator-registered MCP tools + resources.
|
||||
* AuthModule is deliberately absent (BudgetMcp's demo guard reads
|
||||
* RuntimeEnvService + the users table, not AuthService) — that absence is
|
||||
* what lets AuthModule import BudgetModule for UserCleanupService.
|
||||
* AppConfigModule is @Global in the app graph; the explicit import keeps the
|
||||
* partial e2e TestingModules resolving RuntimeEnvService. */
|
||||
@Module({
|
||||
imports: [PermissionsModule, AuthModule, RealtimeModule, PluginGuardsModule, AddonsModule],
|
||||
imports: [PermissionsModule, AppConfigModule, RealtimeModule, PluginGuardsModule, AddonsModule],
|
||||
controllers: [BudgetController],
|
||||
providers: [BudgetService, ExchangeRatesService, BudgetMcp, ExchangeRatesRpc, CostsRpc],
|
||||
// For in-container consumers (CostsRpc, TripsService,
|
||||
|
||||
@@ -22,10 +22,9 @@ type SettlementRow = {
|
||||
* services/budgetService.ts: identical statements, the `||` falsy-coercion
|
||||
* defaults, the COALESCE / CASE WHEN sentinel conventions on update and the
|
||||
* post-write re-selects). Trip access, the 'budget_edit' permission and the
|
||||
* WebSocket broadcast keep their legacy call paths. Non-Nest consumers (the
|
||||
* legacy tripService/userCleanupService and the legacy MCP trips/transports
|
||||
* registrars) go through budget.bridge.ts instead of importing this class
|
||||
* directly.
|
||||
* WebSocket broadcast keep their legacy call paths. (budget.bridge.ts, the
|
||||
* former non-Nest entry point, is deleted — its last consumer,
|
||||
* UserCleanupService, injects this class now.)
|
||||
*
|
||||
* Quirk fixes on top of the relocated legacy behavior: every multi-statement
|
||||
* write now runs in db.transaction() ("transactions are not optional"),
|
||||
|
||||
@@ -15,9 +15,10 @@ import { Injectable } from '@nestjs/common';
|
||||
* Moved from the legacy `services/exchangeRateService.ts` (the budget-domain
|
||||
* fold): identical cache/coalescing behavior, `||` falsy-coercion defaults and
|
||||
* null degradation. The cache and inflight maps are deliberately MODULE-scoped,
|
||||
* not instance fields — the DI singleton and the budget.bridge instance wrap
|
||||
* the same upstream feed, so they must share one cache (same reasoning as
|
||||
* the permissions.service.ts cache).
|
||||
* not instance fields — the DI singleton and the out-of-container instances
|
||||
* (the trips/airtrail/auth bridges each construct their own) wrap the same
|
||||
* upstream feed, so they must share one cache (same reasoning as the
|
||||
* permissions-cache.ts cache).
|
||||
*
|
||||
* Post-migration fixes on top of the relocated legacy behavior: the outbound
|
||||
* fetch carries an AbortSignal timeout and a response-size cap, the response is
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RuntimeEnvService } from '../app-config/runtime-env.service';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { isDemoEmail } from './demo';
|
||||
|
||||
/** The exact 403 body the six upload endpoints have always returned. */
|
||||
@@ -22,3 +23,14 @@ export const DEMO_WRITE_ERROR = {
|
||||
export function isDemoWriteBlocked(env: RuntimeEnvService, email: string | null | undefined): boolean {
|
||||
return env.isDemoMode() && isDemoEmail(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* The user-id flavour of the demo guard — the AuthService.isDemoUser body,
|
||||
* relocated so surfaces that hold only a user id (the *.mcp.ts demo guards)
|
||||
* don't need the whole auth graph for one check.
|
||||
*/
|
||||
export function isDemoUserId(env: RuntimeEnvService, db: DatabaseService, userId: number): boolean {
|
||||
if (!env.isDemoMode()) return false;
|
||||
const user = db.get<{ email: string }>('SELECT email FROM users WHERE id = ?', userId);
|
||||
return isDemoEmail(user?.email);
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ const trips = new TripsService(
|
||||
realtime,
|
||||
new UnsplashService(dbs(), new RuntimeEnvService()),
|
||||
);
|
||||
const members = new TripMembersService(dbs(), budget, new UserCleanupService(dbs()), permissions, realtime, notificationsInstance());
|
||||
const members = new TripMembersService(dbs(), budget, new UserCleanupService(dbs(), budget), permissions, realtime, notificationsInstance());
|
||||
const readModel = new TripReadModelService(
|
||||
dbs(),
|
||||
members,
|
||||
|
||||
@@ -87,20 +87,20 @@ export function createMcpTestRegistry(): McpRegistry {
|
||||
// dropped and four collaborators took its place, but this call site kept the
|
||||
// old shape, so `membership` and `webauthn` held the wrong objects and
|
||||
// userCleanup/mailer/tokens were undefined.
|
||||
const realtimeService = new RealtimeService();
|
||||
const exchangeRatesService = new ExchangeRatesService();
|
||||
const budgetService = new BudgetService(dbService, permissionsService, exchangeRatesService, realtimeService);
|
||||
const authService = new AuthService(
|
||||
dbService,
|
||||
permissionsService,
|
||||
new TripMembershipService(dbService),
|
||||
new WebauthnConfigService(dbService),
|
||||
new UserCleanupService(dbService),
|
||||
new UserCleanupService(dbService, budgetService),
|
||||
new MailerService(dbService),
|
||||
new EphemeralTokenService(),
|
||||
);
|
||||
const realtimeService = new RealtimeService();
|
||||
const queryHelpersService = new QueryHelpersService(dbService);
|
||||
const daysService = new DaysService(dbService, permissionsService, realtimeService, queryHelpersService);
|
||||
const exchangeRatesService = new ExchangeRatesService();
|
||||
const budgetService = new BudgetService(dbService, permissionsService, exchangeRatesService, realtimeService);
|
||||
const todoService = new TodoService(dbService, permissionsService, realtimeService);
|
||||
const packingService = new PackingService(dbService, permissionsService, realtimeService, notificationsStub());
|
||||
const collabService = new CollabService(dbService, permissionsService, realtimeService, notificationsStub());
|
||||
@@ -121,7 +121,7 @@ export function createMcpTestRegistry(): McpRegistry {
|
||||
);
|
||||
const reservationsService = new ReservationsService(dbService, permissionsService, budgetService, realtimeService, notificationsStub());
|
||||
const accommodationsService = new AccommodationsService(dbService, permissionsService, realtimeService);
|
||||
const membersService = new TripMembersService(dbService, budgetService, new UserCleanupService(dbService), permissionsService, realtimeService, notificationsStub());
|
||||
const membersService = new TripMembersService(dbService, budgetService, new UserCleanupService(dbService, budgetService), permissionsService, realtimeService, notificationsStub());
|
||||
const tripsService = new TripsService(
|
||||
dbService,
|
||||
reservationsService,
|
||||
@@ -156,7 +156,7 @@ export function createMcpTestRegistry(): McpRegistry {
|
||||
new AuthMcp(),
|
||||
new TodoMcp(todoService, authService, addonsService),
|
||||
new PackingMcp(packingService, authService, addonsService),
|
||||
new BudgetMcp(budgetService, exchangeRatesService, dbService, authService, addonsService),
|
||||
new BudgetMcp(budgetService, exchangeRatesService, dbService, new RuntimeEnvService(), addonsService),
|
||||
new ReservationsMcp(reservationsService, daysService, budgetService, authService, assignmentsService),
|
||||
new DayNotesMcp(new DayNotesService(dbService, permissionsService, realtimeService), authService),
|
||||
new DaysMcp(daysService, authService),
|
||||
|
||||
@@ -108,7 +108,7 @@ export function createPluginRpcHostFactory(dbs: DatabaseService): PluginRpcHostF
|
||||
const oauth = new PluginOAuthService(dbs);
|
||||
const accommodations = new AccommodationsService(dbs, permissions, realtime);
|
||||
const trips = new TripsService(dbs, reservations, days, permissions, budget, vacay, realtime, unsplash);
|
||||
const members = new TripMembersService(dbs, budget, new UserCleanupService(dbs), permissions, realtime, notificationsStub());
|
||||
const members = new TripMembersService(dbs, budget, new UserCleanupService(dbs, budget), permissions, realtime, notificationsStub());
|
||||
const guards = new PluginGuards(dbs, permissions, addons);
|
||||
|
||||
const registry = createTestPluginRegistry([
|
||||
|
||||
@@ -72,6 +72,7 @@ import { PackingMcp } from '../../../src/nest/packing/packing.mcp';
|
||||
import { PackingService } from '../../../src/nest/packing/packing.service';
|
||||
import { BudgetMcp } from '../../../src/nest/budget/budget.mcp';
|
||||
import { BudgetService } from '../../../src/nest/budget/budget.service';
|
||||
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
||||
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
|
||||
import { AuthMcp } from '../../../src/nest/auth/auth.mcp';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
@@ -120,7 +121,7 @@ const budgetMcp = new BudgetMcp(
|
||||
new BudgetService(promptDbs(), new PermissionsService(promptDbs()), new ExchangeRatesService(), new RealtimeService()),
|
||||
new ExchangeRatesService(),
|
||||
promptDbs(),
|
||||
undefined as never,
|
||||
new RuntimeEnvService(),
|
||||
addonsStub,
|
||||
);
|
||||
const authMcp = new AuthMcp();
|
||||
|
||||
@@ -70,6 +70,8 @@ import { AuthService } from '../../../src/nest/auth/auth.service';
|
||||
import { PasskeyService } from '../../../src/nest/auth/passkey.service';
|
||||
import { PackingService } from '../../../src/nest/packing/packing.service';
|
||||
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
import { BudgetService } from '../../../src/nest/budget/budget.service';
|
||||
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
|
||||
import { NotificationsService } from '../../../src/nest/notifications/notifications.service';
|
||||
import { AdminService } from '../../../src/nest/admin/admin.service';
|
||||
import { VersionCheckJob } from '../../../src/nest/admin/version-check.job';
|
||||
@@ -82,7 +84,7 @@ const dbs = new DatabaseService(testDb);
|
||||
const realtime = new RealtimeService();
|
||||
const permissions = new PermissionsService(dbs);
|
||||
const webauthn = new WebauthnConfigService(dbs);
|
||||
const userCleanup = new UserCleanupService(dbs);
|
||||
const userCleanup = new UserCleanupService(dbs, new BudgetService(dbs, permissions, new ExchangeRatesService(), realtime));
|
||||
// Positional and previously wrong: an AtlasService sat in the membership slot
|
||||
// and the mailer was missing entirely, so `auth` was built with its last four
|
||||
// collaborators shifted by one. Nothing failed, because none of the cases below
|
||||
|
||||
@@ -53,6 +53,8 @@ import { AuthService } from '../../../src/nest/auth/auth.service';
|
||||
import { PasskeyService } from '../../../src/nest/auth/passkey.service';
|
||||
import { PackingService } from '../../../src/nest/packing/packing.service';
|
||||
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
import { BudgetService } from '../../../src/nest/budget/budget.service';
|
||||
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
|
||||
import { NotificationsService } from '../../../src/nest/notifications/notifications.service';
|
||||
import { AdminService } from '../../../src/nest/admin/admin.service';
|
||||
import { makeNotificationsService, makeNotificationPreferencesService } from '../../helpers/notifications';
|
||||
@@ -62,7 +64,7 @@ const dbs = new DatabaseService(testDb);
|
||||
const realtime = new RealtimeService();
|
||||
const permissions = new PermissionsService(dbs);
|
||||
const webauthn = new WebauthnConfigService(dbs);
|
||||
const userCleanup = new UserCleanupService(dbs);
|
||||
const userCleanup = new UserCleanupService(dbs, new BudgetService(dbs, permissions, new ExchangeRatesService(), realtime));
|
||||
// Positional and previously wrong: an AtlasService sat in the membership slot
|
||||
// and the mailer was missing entirely, so `auth` was built with its last four
|
||||
// collaborators shifted by one. Nothing failed, because the version-check path
|
||||
|
||||
@@ -69,6 +69,9 @@ import { AuthService } from '../../../src/nest/auth/auth.service';
|
||||
import { TokenService } from '../../../src/nest/tokens/token.service';
|
||||
import * as authBridge from '../../../src/nest/auth/auth.bridge';
|
||||
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
import { BudgetService } from '../../../src/nest/budget/budget.service';
|
||||
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
|
||||
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { verifyJwtAndLoadUser } from '../../../src/nest/auth/jwt-verify';
|
||||
import { authenticator } from 'otplib';
|
||||
@@ -98,7 +101,7 @@ const svc = new AuthService(
|
||||
new PermissionsService(new DatabaseService(testDb)),
|
||||
membershipStub,
|
||||
new WebauthnConfigService(new DatabaseService(testDb)),
|
||||
new UserCleanupService(new DatabaseService(testDb)),
|
||||
new UserCleanupService(new DatabaseService(testDb), new BudgetService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new ExchangeRatesService(), new RealtimeService())),
|
||||
mailerStub,
|
||||
new EphemeralTokenService(),
|
||||
);
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* DB-backed unit tests for BudgetService trip-scoping (BUDGET-SVC-DB-001+).
|
||||
* Uses a real in-memory SQLite DB so the SQL WHERE clauses are exercised.
|
||||
* BUDGET-SVC-DB-001 through 014 moved 1:1 from the legacy
|
||||
* tests/unit/services/budgetServiceDb.test.ts; 015–018 pin the budget.bridge
|
||||
* delegation (the bridge instance runs over the same mocked db Proxy);
|
||||
* tests/unit/services/budgetServiceDb.test.ts; 015–018 pinned the deleted
|
||||
* budget.bridge's delegation and now pin the same paths on the service;
|
||||
* 019–020 pin the post-fold quirk fixes (COALESCE(display_name) on
|
||||
* settlements, transactional multi-statement writes).
|
||||
*/
|
||||
@@ -41,9 +41,9 @@ const { RATES } = vi.hoisted(() => ({
|
||||
EUR: { EUR: 1, USD: 1.1429, RUB: 87.63 },
|
||||
} as Record<string, Record<string, number>>,
|
||||
}));
|
||||
// Constructor-injected since the fold; the class is mocked at the module path so
|
||||
// the budget.bridge instance (which news up its own ExchangeRatesService) sees
|
||||
// the same deterministic rates as the SUT.
|
||||
// Constructor-injected since the fold; the class is mocked at the module path
|
||||
// so every separately-constructed instance sees the same deterministic rates
|
||||
// as the SUT.
|
||||
vi.mock('../../../src/nest/budget/exchange-rates.service', () => ({
|
||||
ExchangeRatesService: class {
|
||||
async getRates(base: string) {
|
||||
@@ -60,7 +60,6 @@ import { BudgetService } from '../../../src/nest/budget/budget.service';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
|
||||
import * as bridge from '../../../src/nest/budget/budget.bridge';
|
||||
import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service';
|
||||
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||||
import { TripMembersService } from '../../../src/nest/trip-members/trip-members.service';
|
||||
@@ -89,7 +88,7 @@ const dbs = () => new DatabaseService(testDb);
|
||||
const membersSvc = new TripMembersService(
|
||||
dbs(),
|
||||
budget,
|
||||
new UserCleanupService(dbs()),
|
||||
new UserCleanupService(dbs(), budget),
|
||||
new PermissionsService(dbs()),
|
||||
new RealtimeService(),
|
||||
notificationsStub(),
|
||||
@@ -401,12 +400,10 @@ describe('rebaseTripCurrency', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('budget.bridge delegation', () => {
|
||||
// Every bridge export except removeUserFromBudgetItems has been pruned as its
|
||||
// last outside-container consumer migrated (legacy tripService, the trips MCP
|
||||
// registrar, services/userCleanupService, and the create_transport registrar,
|
||||
// which moved into reservations.mcp.ts). 015-018 pin the same behavior on the
|
||||
// service.
|
||||
describe('composite service paths (ex budget.bridge delegation)', () => {
|
||||
// budget.bridge is deleted — UserCleanupService injects BudgetService now
|
||||
// that BudgetModule no longer imports AuthModule. 015-018 kept their IDs and
|
||||
// pin the same behavior directly on the service.
|
||||
it('BUDGET-SVC-DB-015: listBudgetItems returns the hydrated list', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
* coverage gate. 001–012 pin the fetch/cache behavior (including the parity
|
||||
* quirks kept on purpose: `|| 'EUR'` falsy coercion, stale-cache fallback, the
|
||||
* `>1 keys` failure heuristic); 019 pins the module-scoped cache shared across
|
||||
* instances (the DI singleton and the budget.bridge instance wrap one feed —
|
||||
* originally pinned via the exchange-rates.bridge, deleted with the budget
|
||||
* fold); 021–022 pin the post-fold quirk fixes (AbortSignal timeout,
|
||||
* instances (the DI singleton and the out-of-container bridge instances wrap
|
||||
* one feed — originally pinned via the exchange-rates.bridge, deleted with the
|
||||
* budget fold); 021–022 pin the post-fold quirk fixes (AbortSignal timeout,
|
||||
* response-size cap, logged failures). 013–018 and 020 covered the dead
|
||||
* convertWithRates export and were removed with it in the quirk fixes.
|
||||
*
|
||||
@@ -140,12 +140,12 @@ describe('ExchangeRatesService.getRates', () => {
|
||||
});
|
||||
|
||||
describe('cross-instance cache sharing', () => {
|
||||
it('FX-SVC-019: a second instance (the budget.bridge shape) serves the module-scoped cache', async () => {
|
||||
it('FX-SVC-019: a second instance (the out-of-container bridge shape) serves the module-scoped cache', async () => {
|
||||
// Prime the cache through the DI-style instance…
|
||||
const primed = await svc.getRates('AUD');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
// …then a separately-constructed instance (as budget.bridge news up) must
|
||||
// serve the same cached feed instead of refetching.
|
||||
// …then a separately-constructed instance (as the trips/airtrail/auth
|
||||
// bridges new up) must serve the same cached feed instead of refetching.
|
||||
expect(await new ExchangeRatesService().getRates('AUD')).toBe(primed);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -83,6 +83,9 @@ import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip } from '../../helpers/factories';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
import { BudgetService } from '../../../src/nest/budget/budget.service';
|
||||
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
|
||||
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||||
import { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service';
|
||||
import { AuthService } from '../../../src/nest/auth/auth.service';
|
||||
import { WebauthnConfigService } from '../../../src/nest/auth/webauthn-config.service';
|
||||
@@ -105,7 +108,7 @@ const auth = new AuthService(
|
||||
new PermissionsService(new DatabaseService(testDb)),
|
||||
membership,
|
||||
new WebauthnConfigService(new DatabaseService(testDb)),
|
||||
new UserCleanupService(new DatabaseService(testDb)),
|
||||
new UserCleanupService(new DatabaseService(testDb), new BudgetService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new ExchangeRatesService(), new RealtimeService())),
|
||||
mailerStub,
|
||||
new EphemeralTokenService(),
|
||||
);
|
||||
|
||||
@@ -75,6 +75,9 @@ import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser } from '../../helpers/factories';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||||
import { BudgetService } from '../../../src/nest/budget/budget.service';
|
||||
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
|
||||
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||||
import { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service';
|
||||
import { AuthService } from '../../../src/nest/auth/auth.service';
|
||||
import { PasskeyService } from '../../../src/nest/auth/passkey.service';
|
||||
@@ -107,7 +110,7 @@ const auth = new AuthService(
|
||||
new PermissionsService(new DatabaseService(testDb)),
|
||||
new TripMembershipService(new DatabaseService(testDb)),
|
||||
new WebauthnConfigService(new DatabaseService(testDb)),
|
||||
new UserCleanupService(new DatabaseService(testDb)),
|
||||
new UserCleanupService(new DatabaseService(testDb), new BudgetService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new ExchangeRatesService(), new RealtimeService())),
|
||||
mailerStub,
|
||||
new EphemeralTokenService(),
|
||||
);
|
||||
|
||||
@@ -68,7 +68,7 @@ import { notificationsStub } from '../../helpers/notifications';
|
||||
|
||||
const dbs = () => new DatabaseService(testDb);
|
||||
const budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs()), new ExchangeRatesService(), new RealtimeService());
|
||||
const roster = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs()), new PermissionsService(dbs()), new RealtimeService(), notificationsStub(notifySend));
|
||||
const roster = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc), new PermissionsService(dbs()), new RealtimeService(), notificationsStub(notifySend));
|
||||
|
||||
/**
|
||||
* A roster whose connection reports "no such row" for the first result of every
|
||||
@@ -98,7 +98,7 @@ function rosterWithMissingRow(match: string) {
|
||||
},
|
||||
});
|
||||
const frozen = { connection: conn, canAccessTrip: dbMock.canAccessTrip, isOwner: dbMock.isOwner } as unknown as DatabaseService;
|
||||
return new TripMembersService(frozen, budgetSvc, new UserCleanupService(dbs()), new PermissionsService(dbs()), new RealtimeService(), notificationsStub(notifySend));
|
||||
return new TripMembersService(frozen, budgetSvc, new UserCleanupService(dbs(), budgetSvc), new PermissionsService(dbs()), new RealtimeService(), notificationsStub(notifySend));
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
|
||||
@@ -90,7 +90,7 @@ const placesSvc = new PlacesService(
|
||||
new JourneyDomainService(dbs(), new RealtimeService(), new TrekPhotosRepository(dbs())),
|
||||
);
|
||||
const accommodationsSvc = new AccommodationsService(dbs(), new PermissionsService(dbs()), new RealtimeService());
|
||||
const membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs()), new PermissionsService(dbs()), new RealtimeService(), notificationsStub());
|
||||
const membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc), new PermissionsService(dbs()), new RealtimeService(), notificationsStub());
|
||||
|
||||
const buildReadModel = (database: DatabaseService, roster: TripMembersService = membersSvc) =>
|
||||
new TripReadModelService(
|
||||
|
||||
@@ -120,7 +120,7 @@ const svc = new TripsService(
|
||||
new RealtimeService(),
|
||||
undefined as never, // unsplash — not exercised here
|
||||
);
|
||||
const membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs()), new PermissionsService(dbs()), new RealtimeService(), notificationsStub());
|
||||
const membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc), new PermissionsService(dbs()), new RealtimeService(), notificationsStub());
|
||||
const readModelSvc = new TripReadModelService(
|
||||
dbs(), membersSvc, daysSvc, accommodationsSvc, budgetSvc,
|
||||
new PackingService(dbs(), new PermissionsService(dbs()), new RealtimeService(), notificationsStub()),
|
||||
@@ -953,7 +953,7 @@ describe('quirk fixes', () => {
|
||||
/** Same frozen connection, for the guest deletion that now lives on the roster. */
|
||||
function failingMembers(match: string) {
|
||||
return new TripMembersService(
|
||||
failingConnection(match), budgetSvc, new UserCleanupService(dbs()),
|
||||
failingConnection(match), budgetSvc, new UserCleanupService(dbs(), budgetSvc),
|
||||
new PermissionsService(dbs()), new RealtimeService(),
|
||||
notificationsStub(),
|
||||
);
|
||||
|
||||
@@ -52,7 +52,7 @@ import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service'
|
||||
|
||||
const dbs = new DatabaseService(testDb);
|
||||
const budget = new BudgetService(dbs, new PermissionsService(dbs), new ExchangeRatesService(), new RealtimeService());
|
||||
const svc = new UserCleanupService(dbs);
|
||||
const svc = new UserCleanupService(dbs, budget);
|
||||
|
||||
const installPlugin = (id: string, permissions: string[] | null) => {
|
||||
testDb.prepare('INSERT INTO plugins (id, name, version, permissions) VALUES (?, ?, ?, ?)')
|
||||
@@ -146,7 +146,7 @@ describe('erasePluginUserData', () => {
|
||||
const slim = new (require('better-sqlite3'))(':memory:');
|
||||
slim.exec('CREATE TABLE users (id INTEGER PRIMARY KEY)');
|
||||
slim.prepare('INSERT INTO users (id) VALUES (1)').run();
|
||||
const slimSvc = new UserCleanupService(new DatabaseService(slim));
|
||||
const slimSvc = new UserCleanupService(new DatabaseService(slim), budget);
|
||||
|
||||
expect(() => slimSvc.erasePluginUserData(1)).not.toThrow();
|
||||
|
||||
@@ -215,7 +215,7 @@ describe('deleteUserCompletely', () => {
|
||||
return realRun(sql, ...params);
|
||||
});
|
||||
|
||||
expect(() => new UserCleanupService(failing).deleteUserCompletely(victim.id)).toThrow('boom');
|
||||
expect(() => new UserCleanupService(failing, budget).deleteUserCompletely(victim.id)).toThrow('boom');
|
||||
|
||||
expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(victim.id)).toBeDefined();
|
||||
expect((testDb.prepare('SELECT invited_by FROM trip_members WHERE user_id = ?').get(owner.id) as { invited_by: number | null }).invited_by).toBe(victim.id);
|
||||
|
||||
Reference in New Issue
Block a user