mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +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,
|
||||
|
||||
Reference in New Issue
Block a user