refactor(trips): leaving a trip tidies its journal link in the roster's own repository

The departure port needed a global binding that every suite building TripMembersModule without AppModule lacked. The roster's repository now deletes the leaver's row on the trip's journal link itself (leaveJournalLink), inside the transaction that removes the member: no edge into journey, no DI, no test stub, and the eight suites the stub touched are back as they were.

The schema inventory tests count the two new tables and their NOT NULL columns, the legacy step map lists the migration as post-legacy, and the TJL scenes give their users numbered names so cases sharing one database do not collide.
This commit is contained in:
Maurice
2026-10-18 20:02:09 +02:00
parent 33c5c7decc
commit 48d573c60d
21 changed files with 51 additions and 114 deletions
@@ -214,15 +214,6 @@ export class TripJournalLinksRepository extends TrekRepository<TripJournalLinks>
.execute();
}
/** Takes one person off the trip's link, as when they leave the trip. */
async deleteMember(tripId: number, userId: number): Promise<void> {
await this.kysely<TripJournalLinkKyselyDB>()
.deleteFrom('trip_journal_link_members')
.where('link_id', 'in', this.linkIdOf(tripId))
.where('user_id', '=', userId)
.execute();
}
/** Everyone a journal's links let in, for its live updates. */
async linkRecipientUserIds(journeyId: number): Promise<number[]> {
const rows = await linkReach(this.kysely<TripJournalLinkKyselyDB>())
@@ -1,6 +1,7 @@
import { caseWhenEquals, coalesce } from '../dialect/sql-functions';
import type { TripMembers } from '../entities/TripMembers.entity';
import { Trips } from '../entities/Trips.entity';
import type { DB } from '../kysely/db';
import { TrekRepository } from './_shared/trek-repository';
/**
@@ -266,6 +267,22 @@ export class TripMembersRepository extends TrekRepository<TripMembers> {
await this.qb('m').delete().where({ trip: trip_id }).andWhere('m.user_id = ?', [user_id]).execute('run');
}
/**
* Takes someone who left the trip off its journal link, so coming back later
* does not quietly reopen the journal. Their access already ended with the
* membership (the link's reach requires it); this tidies the row. Here rather
* than in the journey domain because leaving is the roster's to finish, and
* reaching into journey from the roster closed a cycle between the two.
*/
async leaveJournalLink(tripId: number, userId: number): Promise<void> {
const db = this.kysely<Pick<DB, 'trip_journal_links' | 'trip_journal_link_members'>>();
await db
.deleteFrom('trip_journal_link_members')
.where('user_id', '=', userId)
.where('link_id', 'in', db.selectFrom('trip_journal_links').select('id').where('trip_id', '=', tripId))
.execute();
}
// ---------------------------------------------------------------------------
// Plan 3c Task 7 (`trips.rpc.ts::members`) — additive: no other repository
// is rooted at `trip_members` joined to `users`, and RP3's shape is
-2
View File
@@ -53,7 +53,6 @@ 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';
@@ -169,7 +168,6 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
DawarichModule,
JourneyModule,
TripJournalLinkModule,
TripMemberDepartureModule,
CollectionsModule,
ShareModule,
TripInviteModule,
@@ -1,16 +0,0 @@
/**
* 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
* the roster closed a cycle. It lives in common/ because either domain owning
* it would draw that edge again. 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>;
}
@@ -14,10 +14,7 @@ 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, so the
* binding of the trip roster's departure port (TripMemberDepartureModule) does
* not pull in the whole journey surface (uploads, memories, both photo
* providers).
* its MCP tools.
*/
@Module({
imports: [
@@ -4,7 +4,6 @@ import type { JourneyContributorsRepository } from '../../db/repositories/Journe
import type { TripJournalLinksRepository, TripPersonRow } from '../../db/repositories/TripJournalLinks.repository';
import { avatarUrl } from '../common/avatarUrl';
import { forbidden, notFound } from '../common/domain-error';
import type { TripMemberDeparture } from '../common/trip-member-departure.types';
import { UnitOfWork } from '../database/unit-of-work';
import { PermissionsService } from '../permissions/permissions.service';
import { RealtimeService } from '../realtime/realtime.service';
@@ -42,7 +41,7 @@ const NOT_LINKED = 'No journal is linked to this trip';
* alone or the journal's owner alone is enough.
*/
@Injectable()
export class TripJournalLinkService implements TripMemberDeparture {
export class TripJournalLinkService {
constructor(
@InjectRepository(TripJournalLinks) private readonly links: TripJournalLinksRepository,
@InjectRepository(JourneyContributors) private readonly contributors: JourneyContributorsRepository,
@@ -207,13 +206,4 @@ export class TripJournalLinkService implements TripMemberDeparture {
this.realtime.broadcast(tripId, 'trip:journal:changed', {}, socketId);
return this.get(tripId, actor);
}
/**
* Someone left the trip: their place on its link goes too, so coming back
* 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 memberLeft(tripId: number, userId: number): Promise<void> {
await this.links.deleteMember(tripId, userId);
}
}
@@ -1,18 +0,0 @@
import { TRIP_MEMBER_DEPARTURE } from '../common/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 {}
@@ -25,9 +25,6 @@ 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: [
@@ -10,13 +10,12 @@ import { UserCleanupService } from '../auth/user-cleanup.service';
import { BudgetService } from '../budget/budget.service';
import { avatarUrl } from '../common/avatarUrl';
import { NotFoundError, ValidationError } from '../common/domain-errors';
import { TRIP_MEMBER_DEPARTURE, type TripMemberDeparture } from '../common/trip-member-departure.types';
import { UnitOfWork } from '../database/unit-of-work';
import { NotificationsService } from '../notifications/notifications.service';
import { PermissionsService } from '../permissions/permissions.service';
import { RealtimeService } from '../realtime/realtime.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Inject, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { randomUUID } from 'crypto';
@@ -80,7 +79,6 @@ export class TripMembersService {
private readonly permissions: PermissionsService,
private readonly realtime: RealtimeService,
private readonly notifications: NotificationsService,
@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 +201,7 @@ export class TripMembersService {
// must not quietly reopen it.
await this.uow.transactional(async () => {
await this.tripMembersRepo.remove(tripId, targetUserId);
await this.departure.memberLeft(tripId, targetUserId);
await this.tripMembersRepo.leaveJournalLink(tripId, targetUserId);
});
}
@@ -2,9 +2,7 @@ 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 { TRIP_MEMBER_DEPARTURE, type TripMemberDeparture } from '../../src/nest/common/trip-member-departure.types';
import { sharedTestOrm } from './test-uow';
import { Global, Module } from '@nestjs/common';
import type Database from 'better-sqlite3';
@@ -27,21 +25,3 @@ export function createTestJourneyShareTokensRepo(db: Database.Database): Promise
export function createTestJourneyBooksRepo(db: Database.Database): Promise<JourneyBooksRepository> {
return sharedTestOrm(db).then((t) => t.repo(JourneyBooks));
}
/**
* 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(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 {}
-2
View File
@@ -119,7 +119,6 @@ import {
createTestJourneyPhotosRepo,
createTestJourneyEntryPhotosRepo,
} from './journey-repos';
import { journalLinksStub } from './journey-share-repos';
import { createTestJourneyShareTokensRepo } from './journey-share-repos';
import { buildMapsService } from './maps-service';
import { makeNotificationsService } from './notifications';
@@ -533,7 +532,6 @@ export async function createPluginRpcHostParts(
permissions,
realtime,
notificationsStub(),
journalLinksStub(),
await createTestUnitOfWork(db),
await createTestTripsRepo(db),
await createTestTripMembersRepo(db),
@@ -45,13 +45,20 @@ beforeEach(async () => {
const http = () => request(app.getHttpServer());
/** A trip owned by `owner` with three members, and a journal of the owner's. */
let scenes = 0;
/**
* A trip owned by `owner` with three members, and a journal of the owner's.
* The names carry the scene's number, since usernames are unique and every
* case builds its own scene on the one database.
*/
async function scene() {
const { user: owner } = await makeUser(orm, { username: 'Owner' });
const { user: lena } = await makeUser(orm, { username: 'Lena' });
const { user: tom } = await makeUser(orm, { username: 'Tom' });
const { user: mia } = await makeUser(orm, { username: 'Mia' });
const { user: stranger } = await makeUser(orm, { username: 'Stranger' });
const n = ++scenes;
const { user: owner } = await makeUser(orm, { username: `Owner ${n}` });
const { user: lena } = await makeUser(orm, { username: `Lena ${n}` });
const { user: tom } = await makeUser(orm, { username: `Tom ${n}` });
const { user: mia } = await makeUser(orm, { username: `Mia ${n}` });
const { user: stranger } = await makeUser(orm, { username: `Stranger ${n}` });
const trip = await makeTrip(orm, owner.id);
await Promise.all([lena, tom, mia].map((m) => addTripMember(orm, trip.id, m.id)));
const journal = await makeJourney(orm, owner.id, { title: 'New York, the long weekend' });
@@ -86,8 +93,8 @@ describe('linking a journal to a trip', () => {
my_role: 'owner',
});
expect(res.body.link.members.map((m: { username: string; role: string }) => [m.username, m.role])).toEqual([
['Lena', 'viewer'],
['Tom', 'editor'],
[s.lena.username, 'viewer'],
[s.tom.username, 'editor'],
]);
expect(res.body).toMatchObject({ can_link: true, can_unlink: true, can_edit_members: true });
expect(broadcast).toHaveBeenCalledWith(s.trip.id, 'trip:journal:changed', {}, undefined);
@@ -200,7 +207,7 @@ describe('who a journal would be opened to', () => {
const byName = Object.fromEntries(
res.body.candidates.map((c: { username: string; access: string | null }) => [c.username, c.access]),
);
expect(byName).toEqual({ Lena: null, Mia: null, Tom: 'editor' });
expect(byName).toEqual({ [s.lena.username]: null, [s.mia.username]: null, [s.tom.username]: 'editor' });
});
it('TJL-011: refuses a journey id that is not a number', async () => {
+4 -3
View File
@@ -37,14 +37,15 @@ describe('ALL_ENTITIES', () => {
* + scheduler_leases from the cron lease migration
* + user_sessions, the session rows behind revocable session tokens
* + ferrata_tiles, the stored via ferrata grid cells
* + paddle_regions, paddle_features and tour_obstacle_reviews, the paddle obstacle data and decisions)
* + paddle_regions, paddle_features and tour_obstacle_reviews, the paddle obstacle data and decisions
* + trip_journal_links and trip_journal_link_members, the journal a trip shows and who it is open to)
* is registered, and no two share a class name (a silent duplicate would
* shadow one of them in `orm.getMetadata()`, exactly the kind of drift
* `entity-schema-parity.test.ts` can't see — it iterates the metadata map,
* which de-duplicates by class name).
*/
it('ENT-003: exactly 138 entities, every class name unique', () => {
expect(ALL_ENTITIES.length).toBe(138);
it('ENT-003: exactly 140 entities, every class name unique', () => {
expect(ALL_ENTITIES.length).toBe(140);
const names = ALL_ENTITIES.map((s) => s.meta.className);
expect(new Set(names).size).toBe(names.length);
});
@@ -29,7 +29,9 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
// + paddle_regions: id, name; paddle_features: id, region_id, osm_ref, kind, geometry, tags, tag_hash and
// its bounding box (a feature is only ever written whole, from one read of its region).
// + tour_obstacle_reviews: place_id, obstacle_key, decision, both fingerprints, obstacle_snapshot, decided_at.
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 475;
// + trip_journal_links: id, trip_id, journey_id, created_at; trip_journal_link_members: link_id, user_id,
// added_at (a link and its members are always written whole).
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 482;
describe('entity class fields never carry a NaN initialiser (task-4-review-shape.md, Important 1)', () => {
it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 133 entities', () => {
@@ -54,6 +54,7 @@ const UNNUMBERED = [
'Migration20200101044000_a_paddle_tour_plans_its_portages_and_locks',
'Migration20200101044100_paddle_obstacles_from_openstreetmap',
'Migration20200101044200_a_paddle_tour_records_its_obstacle_decisions',
'Migration20200101044300_a_trip_links_a_journal',
];
// The last step the positional runner ever had; planLegacyBaseline refuses a
// schema_version past it.
@@ -14,7 +14,6 @@ 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';
@@ -27,7 +26,7 @@ let membersSvc: TripMembersService;
const orm = () => t.get(MikroORM);
beforeAll(async () => {
t = await createTestModule({ db, imports: [TripMembersModule, MemberDepartureStubModule] });
t = await createTestModule({ db, imports: [TripMembersModule] });
budget = t.get(BudgetService);
membersSvc = t.get(TripMembersService);
});
@@ -61,7 +61,7 @@ import { budgetRepoArgs, createTestBudgetItemMembersRepo, createTestBudgetItemPa
import { createTestShareTokensRepo, createTestPluginsRepo, createTestPluginUserErasureQueueRepo } from '../../helpers/share-repos';
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
import { createTestJourneysRepo, createTestJourneyEntriesRepo, createTestJourneyContributorsRepo } from '../../helpers/journey-repos';
import { createTestJourneyShareTokensRepo, journalLinksStub } from '../../helpers/journey-share-repos';
import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-repos';
import { BudgetItemMembers } from '../../../src/db/entities/BudgetItemMembers.entity';
import { MaintenanceRepository } from '../../../src/db/repositories/MaintenanceRepository';
import { BudgetCategoryOrder } from '../../../src/db/entities/BudgetCategoryOrder.entity';
@@ -160,7 +160,7 @@ beforeAll(async () => {
new UserCleanupService(new MaintenanceRepository(dbsEm!), budget, await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestTripMembersRepo(testDb), await createTestBudgetItemsRepo(testDb), await createTestBudgetSettlementsRepo(testDb), await createTestJourneyShareTokensRepo(testDb), await createTestJourneysRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestShareTokensRepo(testDb), await createTestPluginsRepo(testDb), await createTestPluginUserErasureQueueRepo(testDb)),
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
new RealtimeService(),
notificationsStub(), journalLinksStub(),
notificationsStub(),
await createTestUnitOfWork(testDb),
await createTestTripsRepo(testDb),
await createTestTripMembersRepo(testDb),
@@ -19,7 +19,6 @@ 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';
@@ -32,7 +31,7 @@ let budget: BudgetService;
const orm = () => t.get(MikroORM);
beforeAll(async () => {
t = await createTestModule({ db, imports: [TripMembersModule, MemberDepartureStubModule] });
t = await createTestModule({ db, imports: [TripMembersModule] });
members = t.get(TripMembersService);
budget = t.get(BudgetService);
});
@@ -42,7 +42,6 @@ import {
createTestJourneyEntriesRepo,
createTestJourneyContributorsRepo,
} from '../../helpers/journey-repos';
import { journalLinksStub } from '../../helpers/journey-share-repos';
import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-repos';
import { notificationsStub } from '../../helpers/notifications';
import {
@@ -144,7 +143,6 @@ beforeAll(async () => {
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
realtime,
notificationsStub(notifySend),
journalLinksStub(),
await createTestUnitOfWork(testDb),
tripsRepo,
tripMembersRepo,
@@ -65,7 +65,6 @@ import {
createTestJourneyPhotosRepo,
createTestJourneyEntryPhotosRepo,
} from '../../helpers/journey-repos';
import { journalLinksStub } from '../../helpers/journey-share-repos';
import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-repos';
import { buildMapsService } from '../../helpers/maps-service';
import { notificationsStub } from '../../helpers/notifications';
@@ -259,7 +258,6 @@ beforeAll(async () => {
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
realtime,
notificationsStub(),
journalLinksStub(),
await createTestUnitOfWork(testDb),
await createTestTripsRepo(testDb),
await createTestTripMembersRepo(testDb),
+2 -2
View File
@@ -108,7 +108,7 @@ import {
createTestJourneysRepo, createTestJourneyContributorsRepo, createTestJourneyTripsRepo, createTestJourneyEntriesRepo,
createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo,
} from '../../helpers/journey-repos';
import { createTestJourneyShareTokensRepo, journalLinksStub } from '../../helpers/journey-share-repos';
import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-repos';
import { createTestShareTokensRepo, createTestPluginsRepo, createTestPluginUserErasureQueueRepo } from '../../helpers/share-repos';
import { createTestCollectionPlacesRepo } from '../../helpers/test-uow';
import { SettingsService } from '../../../src/nest/settings/settings.service';
@@ -305,7 +305,7 @@ beforeAll(async () => {
(await sharedTestOrm(testDb)).em,
new SettingsService(await createTestUnitOfWork(testDb), await createTestAppSettingsRepo(testDb), await createTestSettingsRepo(testDb), membership.get(TripMembershipService)),
);
membersSvc = new TripMembersService(budgetSvc, new UserCleanupService(new MaintenanceRepository(dbsEm!), budgetSvc, await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestTripMembersRepo(testDb), await createTestBudgetItemsRepo(testDb), await createTestBudgetSettlementsRepo(testDb), await createTestJourneyShareTokensRepo(testDb), await createTestJourneysRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestShareTokensRepo(testDb), await createTestPluginsRepo(testDb), await createTestPluginUserErasureQueueRepo(testDb)), new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), realtime, notificationsStub(), journalLinksStub(), await createTestUnitOfWork(testDb), await createTestTripsRepo(testDb), await createTestTripMembersRepo(testDb), await createTestUsersRepo(testDb));
membersSvc = new TripMembersService(budgetSvc, new UserCleanupService(new MaintenanceRepository(dbsEm!), budgetSvc, await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestTripMembersRepo(testDb), await createTestBudgetItemsRepo(testDb), await createTestBudgetSettlementsRepo(testDb), await createTestJourneyShareTokensRepo(testDb), await createTestJourneysRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestShareTokensRepo(testDb), await createTestPluginsRepo(testDb), await createTestPluginUserErasureQueueRepo(testDb)), new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), realtime, notificationsStub(), await createTestUnitOfWork(testDb), await createTestTripsRepo(testDb), await createTestTripMembersRepo(testDb), await createTestUsersRepo(testDb));
readModelSvc = new TripReadModelService(
await createTestTripsRepo(testDb), membersSvc, daysSvc, accommodationsSvc, budgetSvc,
new PackingService(