refactor(server): let user-cleanup inject BudgetService and delete budget.bridge

This commit is contained in:
jubnl
2026-08-11 16:42:49 +02:00
parent 2b2c6462ec
commit 8b7b91d814
24 changed files with 117 additions and 109 deletions
+6 -2
View File
@@ -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(),
);
+6 -2
View File
@@ -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],
+9 -11
View File
@@ -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
-32
View File
@@ -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);
}
+17 -11
View File
@@ -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 -3
View File
@@ -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,
+3 -4
View File
@@ -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
+12
View File
@@ -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);
}
+1 -1
View File
@@ -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,