From ce8d1e5a0ade2add990f96a530a05bfe372b8952 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 18 Oct 2026 18:31:26 +0200 Subject: [PATCH] refactor(trips): leaving a trip reaches the journal link through a port, not an import TripMembersService called TripJournalLinkService directly, which made trip-members and journey depend on each other (lint:boundaries). The roster now declares TRIP_MEMBER_DEPARTURE and calls memberLeft in the transaction that removes the member; the journey domain binds it globally (TripMemberDepartureModule), the way JourneyAccessModule binds the gateway's JOURNEY_ACCESS. Suites that build TripMembersModule alone import MemberDepartureStubModule. --- server/src/nest/app.module.ts | 2 ++ .../nest/journey/trip-journal-link.module.ts | 9 +++--- .../nest/journey/trip-journal-link.service.ts | 5 ++-- .../journey/trip-member-departure.module.ts | 18 ++++++++++++ .../trip-member-departure.types.ts | 15 ++++++++++ .../nest/trip-members/trip-members.module.ts | 5 ++-- .../nest/trip-members/trip-members.service.ts | 8 +++--- server/tests/helpers/journey-share-repos.ts | 28 +++++++++++++------ .../unit/nest/budget.former-members.test.ts | 3 +- .../nest/trip-members.guest-departure.test.ts | 3 +- 10 files changed, 72 insertions(+), 24 deletions(-) create mode 100644 server/src/nest/journey/trip-member-departure.module.ts create mode 100644 server/src/nest/trip-members/trip-member-departure.types.ts diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index e32e3ee6f..14c041ef9 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -53,6 +53,7 @@ import { DawarichModule } from './integrations/dawarich.module'; import { JourneyAccessModule } from './journey/journey-access.module'; import { JourneyModule } from './journey/journey.module'; import { TripJournalLinkModule } from './journey/trip-journal-link.module'; +import { TripMemberDepartureModule } from './journey/trip-member-departure.module'; import { LlmParseModule } from './llm-parse/llm-parse.module'; import { ManagedExtModule } from './managed/managed-ext.module'; import { MapsModule } from './maps/maps.module'; @@ -168,6 +169,7 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; DawarichModule, JourneyModule, TripJournalLinkModule, + TripMemberDepartureModule, CollectionsModule, ShareModule, TripInviteModule, diff --git a/server/src/nest/journey/trip-journal-link.module.ts b/server/src/nest/journey/trip-journal-link.module.ts index 0d685f0fa..90ed5e4b0 100644 --- a/server/src/nest/journey/trip-journal-link.module.ts +++ b/server/src/nest/journey/trip-journal-link.module.ts @@ -14,11 +14,10 @@ import { Module } from '@nestjs/common'; /** * A trip showing one journal from the Journey addon: the link, its routes and - * its MCP tools. Its own module rather than part of JourneyModule, because the - * trip roster needs `TripJournalLinkService.dropMember` when someone leaves, - * and TripMembersModule must not pull in the whole journey surface (uploads, - * memories, both photo providers) for that. It imports nothing from the trip - * roster in return, so the edge runs one way. + * its MCP tools. Its own module rather than part of JourneyModule, so the + * binding of the trip roster's departure port (TripMemberDepartureModule) does + * not pull in the whole journey surface (uploads, memories, both photo + * providers). */ @Module({ imports: [ diff --git a/server/src/nest/journey/trip-journal-link.service.ts b/server/src/nest/journey/trip-journal-link.service.ts index b93ba4407..1ce2bf83c 100644 --- a/server/src/nest/journey/trip-journal-link.service.ts +++ b/server/src/nest/journey/trip-journal-link.service.ts @@ -7,6 +7,7 @@ import { forbidden, notFound } from '../common/domain-error'; import { UnitOfWork } from '../database/unit-of-work'; import { PermissionsService } from '../permissions/permissions.service'; import { RealtimeService } from '../realtime/realtime.service'; +import type { TripMemberDeparture } from '../trip-members/trip-member-departure.types'; import { TripAccessService } from '../trip-membership/trip-access.service'; import { JourneyDomainService } from './journey-domain.service'; import { InjectRepository } from '@mikro-orm/nestjs'; @@ -41,7 +42,7 @@ const NOT_LINKED = 'No journal is linked to this trip'; * alone or the journal's owner alone is enough. */ @Injectable() -export class TripJournalLinkService { +export class TripJournalLinkService implements TripMemberDeparture { constructor( @InjectRepository(TripJournalLinks) private readonly links: TripJournalLinksRepository, @InjectRepository(JourneyContributors) private readonly contributors: JourneyContributorsRepository, @@ -212,7 +213,7 @@ export class TripJournalLinkService { * later does not quietly reopen the journal. Their access already ended with * the membership; this only tidies the row. Runs in the caller's transaction. */ - async dropMember(tripId: number, userId: number): Promise { + async memberLeft(tripId: number, userId: number): Promise { await this.links.deleteMember(tripId, userId); } } diff --git a/server/src/nest/journey/trip-member-departure.module.ts b/server/src/nest/journey/trip-member-departure.module.ts new file mode 100644 index 000000000..1d232b349 --- /dev/null +++ b/server/src/nest/journey/trip-member-departure.module.ts @@ -0,0 +1,18 @@ +import { TRIP_MEMBER_DEPARTURE } from '../trip-members/trip-member-departure.types'; +import { TripJournalLinkModule } from './trip-journal-link.module'; +import { TripJournalLinkService } from './trip-journal-link.service'; +import { Global, Module } from '@nestjs/common'; + +/** + * Binds the trip roster's TRIP_MEMBER_DEPARTURE port to TripJournalLinkService, + * so someone leaving a trip also leaves its journal link. Global so + * TripMembersModule resolves it without importing the journey domain; + * AppModule imports it next to the roster. + */ +@Global() +@Module({ + imports: [TripJournalLinkModule], + providers: [{ provide: TRIP_MEMBER_DEPARTURE, useExisting: TripJournalLinkService }], + exports: [TRIP_MEMBER_DEPARTURE], +}) +export class TripMemberDepartureModule {} diff --git a/server/src/nest/trip-members/trip-member-departure.types.ts b/server/src/nest/trip-members/trip-member-departure.types.ts new file mode 100644 index 000000000..6fa6dc5f1 --- /dev/null +++ b/server/src/nest/trip-members/trip-member-departure.types.ts @@ -0,0 +1,15 @@ +/** + * What the trip roster tells the rest of the app when someone leaves a trip. + * + * A port rather than the journal link service itself: the journey domain + * already depends on the trip roster through others, so reaching into it from + * here closed a cycle. The journey domain binds the token + * (TripMemberDepartureModule), and the roster calls it inside the transaction + * that removes the member. + */ +export const TRIP_MEMBER_DEPARTURE = Symbol('TRIP_MEMBER_DEPARTURE'); + +export interface TripMemberDeparture { + /** `userId` is off the trip now; whatever they held through it goes too. Runs in the caller's transaction. */ + memberLeft(tripId: number, userId: number): Promise; +} diff --git a/server/src/nest/trip-members/trip-members.module.ts b/server/src/nest/trip-members/trip-members.module.ts index db6a3c5ac..666c557e0 100644 --- a/server/src/nest/trip-members/trip-members.module.ts +++ b/server/src/nest/trip-members/trip-members.module.ts @@ -4,7 +4,6 @@ import { Users } from '../../db/entities/Users.entity'; import { AuditModule } from '../audit/audit.module'; import { AuthModule } from '../auth/auth.module'; import { BudgetModule } from '../budget/budget.module'; -import { TripJournalLinkModule } from '../journey/trip-journal-link.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { PermissionsModule } from '../permissions/permissions.module'; import { RealtimeModule } from '../realtime/realtime.module'; @@ -26,6 +25,9 @@ import { Module } from '@nestjs/common'; * `DatabaseModule` is gone (Plan 4 Task 4) — `TripMembersService` is fully * repository-backed (`getTripForViewer` → `TripsRepository.findForViewer`, * `canAccessTrip` → `TripsRepository.findAccessible`). + * + * Leaving a trip also leaves its journal link, through the TRIP_MEMBER_DEPARTURE + * port the journey domain binds globally (TripMemberDepartureModule). */ @Module({ imports: [ @@ -35,7 +37,6 @@ import { Module } from '@nestjs/common'; AuditModule, AuthModule, BudgetModule, - TripJournalLinkModule, MikroOrmModule.forFeature([Trips, TripMembers, Users]), ], controllers: [TripMembersController], diff --git a/server/src/nest/trip-members/trip-members.service.ts b/server/src/nest/trip-members/trip-members.service.ts index 1655a9a6d..d9866e94d 100644 --- a/server/src/nest/trip-members/trip-members.service.ts +++ b/server/src/nest/trip-members/trip-members.service.ts @@ -11,12 +11,12 @@ import { BudgetService } from '../budget/budget.service'; import { avatarUrl } from '../common/avatarUrl'; import { NotFoundError, ValidationError } from '../common/domain-errors'; import { UnitOfWork } from '../database/unit-of-work'; -import { TripJournalLinkService } from '../journey/trip-journal-link.service'; import { NotificationsService } from '../notifications/notifications.service'; import { PermissionsService } from '../permissions/permissions.service'; import { RealtimeService } from '../realtime/realtime.service'; +import { TRIP_MEMBER_DEPARTURE, type TripMemberDeparture } from './trip-member-departure.types'; import { InjectRepository } from '@mikro-orm/nestjs'; -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared'; import { randomUUID } from 'crypto'; @@ -80,7 +80,7 @@ export class TripMembersService { private readonly permissions: PermissionsService, private readonly realtime: RealtimeService, private readonly notifications: NotificationsService, - private readonly journalLinks: TripJournalLinkService, + @Inject(TRIP_MEMBER_DEPARTURE) private readonly departure: TripMemberDeparture, private readonly uow: UnitOfWork, @InjectRepository(Trips) private readonly tripsRepo: TripsRepository, @InjectRepository(TripMembers) private readonly tripMembersRepo: TripMembersRepository, @@ -203,7 +203,7 @@ export class TripMembersService { // must not quietly reopen it. await this.uow.transactional(async () => { await this.tripMembersRepo.remove(tripId, targetUserId); - await this.journalLinks.dropMember(tripId, targetUserId); + await this.departure.memberLeft(tripId, targetUserId); }); } diff --git a/server/tests/helpers/journey-share-repos.ts b/server/tests/helpers/journey-share-repos.ts index 1757b44a8..1fd12ee27 100644 --- a/server/tests/helpers/journey-share-repos.ts +++ b/server/tests/helpers/journey-share-repos.ts @@ -2,8 +2,12 @@ import { JourneyBooks } from '../../src/db/entities/JourneyBooks.entity'; import { JourneyShareTokens } from '../../src/db/entities/JourneyShareTokens.entity'; import type { JourneyBooksRepository } from '../../src/db/repositories/JourneyBooks.repository'; import type { JourneyShareTokensRepository } from '../../src/db/repositories/JourneyShareTokens.repository'; -import type { TripJournalLinkService } from '../../src/nest/journey/trip-journal-link.service'; +import { + TRIP_MEMBER_DEPARTURE, + type TripMemberDeparture, +} from '../../src/nest/trip-members/trip-member-departure.types'; import { sharedTestOrm } from './test-uow'; +import { Global, Module } from '@nestjs/common'; import type Database from 'better-sqlite3'; @@ -28,13 +32,19 @@ export function createTestJourneyBooksRepo(db: Database.Database): Promise {}, -): TripJournalLinkService { - return { dropMember } as unknown as TripJournalLinkService; +export function journalLinksStub(memberLeft: TripMemberDeparture['memberLeft'] = async () => {}): TripMemberDeparture { + return { memberLeft }; } + +/** The same stub bound globally, for a `createTestModule` that imports TripMembersModule. */ +@Global() +@Module({ + providers: [{ provide: TRIP_MEMBER_DEPARTURE, useValue: journalLinksStub() }], + exports: [TRIP_MEMBER_DEPARTURE], +}) +export class MemberDepartureStubModule {} diff --git a/server/tests/unit/nest/budget.former-members.test.ts b/server/tests/unit/nest/budget.former-members.test.ts index b59aa0b09..61f6af1c5 100644 --- a/server/tests/unit/nest/budget.former-members.test.ts +++ b/server/tests/unit/nest/budget.former-members.test.ts @@ -14,6 +14,7 @@ import { createSnapshotTestDb } from '../../helpers/db-mock'; import { findRow } from '../../helpers/factories/rows'; import { addTripMember, makeTrip } from '../../helpers/factories/trips'; import { makeUser } from '../../helpers/factories/users'; +import { MemberDepartureStubModule } from '../../helpers/journey-share-repos'; import { createTestModule, type TestModule } from '../../helpers/test-module'; import { MikroORM } from '@mikro-orm/core'; @@ -26,7 +27,7 @@ let membersSvc: TripMembersService; const orm = () => t.get(MikroORM); beforeAll(async () => { - t = await createTestModule({ db, imports: [TripMembersModule] }); + t = await createTestModule({ db, imports: [TripMembersModule, MemberDepartureStubModule] }); budget = t.get(BudgetService); membersSvc = t.get(TripMembersService); }); diff --git a/server/tests/unit/nest/trip-members.guest-departure.test.ts b/server/tests/unit/nest/trip-members.guest-departure.test.ts index 31ea3ce4b..f533ba7bf 100644 --- a/server/tests/unit/nest/trip-members.guest-departure.test.ts +++ b/server/tests/unit/nest/trip-members.guest-departure.test.ts @@ -19,6 +19,7 @@ import { createSnapshotTestDb } from '../../helpers/db-mock'; import { countRows, findRow, findRows, updateRows } from '../../helpers/factories/rows'; import { addTripMember, makeTrip } from '../../helpers/factories/trips'; import { makeUser } from '../../helpers/factories/users'; +import { MemberDepartureStubModule } from '../../helpers/journey-share-repos'; import { createTestModule, type TestModule } from '../../helpers/test-module'; import { MikroORM } from '@mikro-orm/core'; @@ -31,7 +32,7 @@ let budget: BudgetService; const orm = () => t.get(MikroORM); beforeAll(async () => { - t = await createTestModule({ db, imports: [TripMembersModule] }); + t = await createTestModule({ db, imports: [TripMembersModule, MemberDepartureStubModule] }); members = t.get(TripMembersService); budget = t.get(BudgetService); });