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.
This commit is contained in:
Maurice
2026-10-18 18:31:26 +02:00
parent 340d429415
commit ce8d1e5a0a
10 changed files with 72 additions and 24 deletions
+2
View File
@@ -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,
@@ -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: [
@@ -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<void> {
async memberLeft(tripId: number, userId: number): Promise<void> {
await this.links.deleteMember(tripId, userId);
}
}
@@ -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 {}
@@ -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<void>;
}
@@ -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],
@@ -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);
});
}
+19 -9
View File
@@ -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<Journ
}
/**
* TripJournalLinkService for the suites that hand-build TripMembersService:
* leaving a trip only has to drop the member from a linked journal, and what
* that does to the journal's access is pinned by the TJL integration tests
* against the real service.
* The trip roster's departure port for the suites that build TripMembersService
* without the journey domain: leaving a trip only has to tell the port, and what
* that does to a linked journal is pinned by the TJL integration tests against
* the real binding.
*/
export function journalLinksStub(
dropMember: TripJournalLinkService['dropMember'] = async () => {},
): 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 {}
@@ -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);
});
@@ -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);
});