diff --git a/server/scripts/size-baseline.json b/server/scripts/size-baseline.json index c6f4db854..117d206ed 100644 --- a/server/scripts/size-baseline.json +++ b/server/scripts/size-baseline.json @@ -11,11 +11,11 @@ "src/nest/collections/collections.service.ts": 1478, "src/nest/doc-sync/doc-sync.service.ts": 1060, "src/nest/doc-sync/providers/synology-drive.client.ts": 1160, - "src/nest/journey/journey-domain.service.ts": 1785, + "src/nest/journey/journey-domain.service.ts": 1770, "src/nest/places/places.service.ts": 1592, "src/nest/plugins/plugin-runtime.service.ts": 1463, "src/nest/plugins/runtime/plugin-sdk.ts": 1093, "src/nest/reservations/reservations.service.ts": 1237, "src/nest/trips/trips.service.ts": 1006, - "src/nest/vacay/vacay.service.ts": 1420 + "src/nest/vacay/vacay.service.ts": 1417 } diff --git a/server/scripts/tx-writes-baseline.json b/server/scripts/tx-writes-baseline.json index 6ca4efb2c..6a97aa93b 100644 --- a/server/scripts/tx-writes-baseline.json +++ b/server/scripts/tx-writes-baseline.json @@ -32,10 +32,7 @@ "src/nest/integrations/dawarich-sync.service.ts#DawarichSyncService.runSync": 2, "src/nest/integrations/dawarich-sync.service.ts#DawarichSyncService.syncTripWindow": 3, "src/nest/integrations/dawarich-sync.service.ts#DawarichSyncService.syncUserOnce": 3, - "src/nest/journey/journey.controller.ts#JourneyController.galleryProviderPhotos": 2, - "src/nest/journey/journey.controller.ts#JourneyController.providerPhotos": 2, "src/nest/journey/journey.controller.ts#JourneyController.uploadEntryPhotos": 4, - "src/nest/journey/journey.mcp.ts#JourneyMcp.addJourneyProviderPhotos": 2, "src/nest/memories/photo-resolver.service.ts#PhotoResolverService.streamCachedThumbnail": 3, "src/nest/memories/photo-resolver.service.ts#PhotoResolverService.streamPhoto": 2, "src/nest/memories/synology.service.ts#SynologyService._fetchAllSynologyAlbums": 2, @@ -90,6 +87,5 @@ "src/nest/plugins/plugins.controller.ts#PluginsController.install": 2, "src/nest/plugins/plugins.controller.ts#PluginsController.update": 2, "src/nest/plugins/plugins.controller.ts#PluginsController.updateConfig": 3, - "src/nest/reservations/reservations.controller.ts#ReservationsController.update": 2, "src/nest/vacay/vacay.service.ts#VacayService.updatePlan": 2 } diff --git a/server/src/nest/atlas/atlas-writes.service.ts b/server/src/nest/atlas/atlas-writes.service.ts new file mode 100644 index 000000000..a36037c46 --- /dev/null +++ b/server/src/nest/atlas/atlas-writes.service.ts @@ -0,0 +1,66 @@ +import { RealtimeService } from '../realtime/realtime.service'; +import { AtlasService, type CreateBucketData, type UpdateBucketData } from './atlas.service'; +import { Injectable } from '@nestjs/common'; + +/** + * The atlas writes as the REST routes and the MCP tools make them: AtlasService + * writes the rows, then every tab of the user hears `atlas:changed` and reads + * its atlas again. REST leaves out the tab that asked, which updates from its + * own answer; an MCP call leaves out none, so the tab the assistant runs in + * shows the change too. + * + * The rows are the user's own, so the event goes to the user rather than to a + * trip. AtlasService itself stays quiet: the Dawarich confirmations write + * through it inside their own transactions and announce once, after the commit, + * through `announce`. + */ +@Injectable() +export class AtlasWritesService { + constructor( + private readonly atlas: AtlasService, + private readonly realtime: RealtimeService, + ) {} + + /** Tells the user's tabs their countries, regions or bucket list changed. */ + announce(userId: number, socketId?: string): void { + this.realtime.broadcastToUser(userId, { type: 'atlas:changed' }, socketId); + } + + async markCountry(userId: number, code: string, socketId?: string): Promise { + await this.atlas.markCountry(userId, code); + this.announce(userId, socketId); + } + + async unmarkCountry(userId: number, code: string, socketId?: string): Promise { + await this.atlas.unmarkCountry(userId, code); + this.announce(userId, socketId); + } + + async markRegion(userId: number, code: string, name: string, countryCode: string, socketId?: string): Promise { + await this.atlas.markRegion(userId, code, name, countryCode); + this.announce(userId, socketId); + } + + async unmarkRegion(userId: number, code: string, socketId?: string): Promise { + await this.atlas.unmarkRegion(userId, code); + this.announce(userId, socketId); + } + + async createBucketItem(userId: number, data: CreateBucketData, socketId?: string) { + const item = await this.atlas.createBucketItem(userId, data); + this.announce(userId, socketId); + return item; + } + + async updateBucketItem(userId: number, itemId: string | number, data: UpdateBucketData, socketId?: string) { + const item = await this.atlas.updateBucketItem(userId, itemId, data); + if (item) this.announce(userId, socketId); + return item; + } + + async deleteBucketItem(userId: number, itemId: string | number, socketId?: string): Promise { + const deleted = await this.atlas.deleteBucketItem(userId, itemId); + if (deleted) this.announce(userId, socketId); + return deleted; + } +} diff --git a/server/src/nest/atlas/atlas.controller.ts b/server/src/nest/atlas/atlas.controller.ts index da7a46b38..45e04cf36 100644 --- a/server/src/nest/atlas/atlas.controller.ts +++ b/server/src/nest/atlas/atlas.controller.ts @@ -1,6 +1,7 @@ import type { User } from '../../types'; import { CurrentUser } from '../auth-core/current-user.decorator'; import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; +import { AtlasWritesService } from './atlas-writes.service'; import { AtlasMarkRegionDto, AtlasCreateBucketItemDto, AtlasUpdateBucketItemDto } from './atlas.dto'; import { AtlasService, BucketItemExistsError } from './atlas.service'; import { @@ -9,6 +10,7 @@ import { Delete, Get, Header, + Headers, HttpCode, HttpException, Param, @@ -36,12 +38,16 @@ import type { Response } from 'express'; * No addon gate — the legacy route has none, so adding one would break * clients when the addon is off. The one status the legacy route never sent is * the bucket-list 409: adding the same wish twice used to append a second row - * (#1898). + * (#1898). The writes go through AtlasWritesService, which tells the user's + * other tabs (`atlas:changed`); X-Socket-Id leaves out the tab that asked. */ @Controller('api/addons/atlas') @UseGuards(JwtAuthGuard) export class AtlasController { - constructor(private readonly atlas: AtlasService) {} + constructor( + private readonly atlas: AtlasService, + private readonly writes: AtlasWritesService, + ) {} @Get('stats') async stats(@CurrentUser() user: User) { @@ -113,14 +119,22 @@ export class AtlasController { @Post('country/:code/mark') @HttpCode(200) - async markCountry(@CurrentUser() user: User, @Param('code') code: string): Promise<{ success: boolean }> { - await this.atlas.markCountry(user.id, code.toUpperCase()); + async markCountry( + @CurrentUser() user: User, + @Param('code') code: string, + @Headers('x-socket-id') socketId?: string, + ): Promise<{ success: boolean }> { + await this.writes.markCountry(user.id, code.toUpperCase(), socketId); return { success: true }; } @Delete('country/:code/mark') - async unmarkCountry(@CurrentUser() user: User, @Param('code') code: string): Promise<{ success: boolean }> { - await this.atlas.unmarkCountry(user.id, code.toUpperCase()); + async unmarkCountry( + @CurrentUser() user: User, + @Param('code') code: string, + @Headers('x-socket-id') socketId?: string, + ): Promise<{ success: boolean }> { + await this.writes.unmarkCountry(user.id, code.toUpperCase(), socketId); return { success: true }; } @@ -130,14 +144,19 @@ export class AtlasController { @CurrentUser() user: User, @Param('code') code: string, @Body() body: AtlasMarkRegionDto, + @Headers('x-socket-id') socketId?: string, ): Promise<{ success: boolean }> { - await this.atlas.markRegion(user.id, code.toUpperCase(), body.name, body.country_code.toUpperCase()); + await this.writes.markRegion(user.id, code.toUpperCase(), body.name, body.country_code.toUpperCase(), socketId); return { success: true }; } @Delete('region/:code/mark') - async unmarkRegion(@CurrentUser() user: User, @Param('code') code: string): Promise<{ success: boolean }> { - await this.atlas.unmarkRegion(user.id, code.toUpperCase()); + async unmarkRegion( + @CurrentUser() user: User, + @Param('code') code: string, + @Headers('x-socket-id') socketId?: string, + ): Promise<{ success: boolean }> { + await this.writes.unmarkRegion(user.id, code.toUpperCase(), socketId); return { success: true }; } @@ -150,6 +169,7 @@ export class AtlasController { async createBucketItem( @CurrentUser() user: User, @Body() body: AtlasCreateBucketItemDto, + @Headers('x-socket-id') socketId?: string, ): Promise<{ item: unknown }> { // The schema's min(1) admits whitespace-only names — this trim guard keeps // the legacy 400 for those (missing/empty names 400 in the pipe envelope). @@ -159,15 +179,11 @@ export class AtlasController { const { name, lat, lng, country_code, notes, target_date, region_code } = body; try { return { - item: await this.atlas.createBucketItem(user.id, { - name, - lat, - lng, - country_code, - notes, - target_date, - region_code, - }), + item: await this.writes.createBucketItem( + user.id, + { name, lat, lng, country_code, notes, target_date, region_code }, + socketId, + ), }; } catch (err) { // #1898: the same wish twice is a conflict, not a server error. Bespoke @@ -184,11 +200,13 @@ export class AtlasController { @CurrentUser() user: User, @Param('id') id: string, @Body() body: AtlasUpdateBucketItemDto, + @Headers('x-socket-id') socketId?: string, ): Promise<{ item: unknown }> { const { name, notes, lat, lng, country_code, target_date } = body; let item: unknown; try { - item = await this.atlas.updateBucketItem(user.id, id, { name, notes, lat, lng, country_code, target_date }); + const data = { name, notes, lat, lng, country_code, target_date }; + item = await this.writes.updateBucketItem(user.id, id, data, socketId); } catch (err) { // #1898: editing a wish onto an existing one conflicts the same way a // duplicate create does. @@ -204,8 +222,12 @@ export class AtlasController { } @Delete('bucket-list/:id') - async deleteBucketItem(@CurrentUser() user: User, @Param('id') id: string): Promise<{ success: boolean }> { - if (!(await this.atlas.deleteBucketItem(user.id, id))) { + async deleteBucketItem( + @CurrentUser() user: User, + @Param('id') id: string, + @Headers('x-socket-id') socketId?: string, + ): Promise<{ success: boolean }> { + if (!(await this.writes.deleteBucketItem(user.id, id, socketId))) { throw new HttpException({ error: 'Item not found' }, 404); } return { success: true }; diff --git a/server/src/nest/atlas/atlas.mcp.ts b/server/src/nest/atlas/atlas.mcp.ts index ac84bbddf..db34882fe 100644 --- a/server/src/nest/atlas/atlas.mcp.ts +++ b/server/src/nest/atlas/atlas.mcp.ts @@ -13,6 +13,7 @@ import { } from '../../nest-mcp'; import { addonGate } from '../addons/addon-gate'; import { AddonsService } from '../addons/addons.service'; +import { AtlasWritesService } from './atlas-writes.service'; import { AtlasService, BucketItemExistsError } from './atlas.service'; import { idSchema } from '@trek/shared'; @@ -48,7 +49,9 @@ function jsonContent(uri: string, data: unknown) { * checks map to the declarative atlas read/write access markers (resolved by * trekMcpAccessPolicy) and the registration-time addon early-return becomes * the `when:` gate. Atlas rows are uid-scoped, so there are no trip-access - * checks and no broadcasts; writes deny demo users, reads do not. The legacy + * checks; the writes go through AtlasWritesService, which tells every tab of + * the user (`atlas:changed`), the assistant's own included. Writes deny demo + * users, reads do not. The legacy * registrar's casing divergence from REST (mark_region_visited / * get_country_atlas_places passed codes through un-uppercased) was fixed in * the trailing quirk commit — both sides now uppercase. The one input schema @@ -66,6 +69,7 @@ function jsonContent(uri: string, data: unknown) { export class AtlasMcp { constructor( private readonly atlas: AtlasService, + private readonly writes: AtlasWritesService, readonly addons: AddonsService, ) {} @@ -122,7 +126,7 @@ export class AtlasMcp { return errorResult('region_code must belong to country_code.'); } try { - const item = await this.atlas.createBucketItem(ctx.userId, { + const item = await this.writes.createBucketItem(ctx.userId, { name, lat, lng, @@ -149,7 +153,7 @@ export class AtlasMcp { access: { group: 'atlas', mode: 'write' }, }) async deleteBucketListItem({ itemId }: { itemId: number }, ctx: McpContext) { - const deleted = await this.atlas.deleteBucketItem(ctx.userId, itemId); + const deleted = await this.writes.deleteBucketItem(ctx.userId, itemId); if (!deleted) return { content: [{ type: 'text' as const, text: 'Bucket list item not found.' }], isError: true }; return ok({ success: true }); } @@ -167,7 +171,7 @@ export class AtlasMcp { access: { group: 'atlas', mode: 'write' }, }) async markCountryVisited({ country_code }: { country_code: string }, ctx: McpContext) { - await this.atlas.markCountry(ctx.userId, country_code.toUpperCase()); + await this.writes.markCountry(ctx.userId, country_code.toUpperCase()); return ok({ success: true, country_code: country_code.toUpperCase() }); } @@ -182,7 +186,7 @@ export class AtlasMcp { access: { group: 'atlas', mode: 'write' }, }) async unmarkCountryVisited({ country_code }: { country_code: string }, ctx: McpContext) { - await this.atlas.unmarkCountry(ctx.userId, country_code.toUpperCase()); + await this.writes.unmarkCountry(ctx.userId, country_code.toUpperCase()); return ok({ success: true, country_code: country_code.toUpperCase() }); } @@ -272,7 +276,7 @@ export class AtlasMcp { // (the legacy registrar passed them through verbatim, so a lowercase mark // created a row REST's uppercased unmark could never hit). const upperRegion = regionCode.toUpperCase(); - await this.atlas.markRegion(ctx.userId, upperRegion, regionName, countryCode.toUpperCase()); + await this.writes.markRegion(ctx.userId, upperRegion, regionName, countryCode.toUpperCase()); const row = (await this.atlas.listManuallyVisitedRegions(ctx.userId)).find((r) => r.region_code === upperRegion); // Echo in the client-facing shape ({ code, name, ... }) rather than raw DB columns. const region = row @@ -294,7 +298,7 @@ export class AtlasMcp { }) async unmarkRegionVisited({ regionCode }: { regionCode: string }, ctx: McpContext) { // Post-fold quirk fix: uppercase, matching the REST controller. - await this.atlas.unmarkRegion(ctx.userId, regionCode.toUpperCase()); + await this.writes.unmarkRegion(ctx.userId, regionCode.toUpperCase()); return ok({ success: true }); } @@ -353,7 +357,7 @@ export class AtlasMcp { ) { let item: unknown; try { - item = await this.atlas.updateBucketItem(ctx.userId, itemId, { + item = await this.writes.updateBucketItem(ctx.userId, itemId, { name, notes, lat, diff --git a/server/src/nest/atlas/atlas.module.ts b/server/src/nest/atlas/atlas.module.ts index dd703d3d1..b740bb23c 100644 --- a/server/src/nest/atlas/atlas.module.ts +++ b/server/src/nest/atlas/atlas.module.ts @@ -12,8 +12,10 @@ import { PluginGuardsModule } from '../../nest-rpc/plugin-guards.module'; import { AddonsModule } from '../addons/addons.module'; import { RateLimitModule } from '../common/rate-limit.module'; import { ApiTokenGuard } from '../public-api/api-token.guard'; +import { RealtimeModule } from '../realtime/realtime.module'; import { SchedulingModule } from '../scheduling/scheduling.module'; import { TokensModule } from '../tokens/tokens.module'; +import { AtlasWritesService } from './atlas-writes.service'; import { AtlasController } from './atlas.controller'; import { AtlasMcp } from './atlas.mcp'; import { AtlasRpc } from './atlas.rpc'; @@ -50,6 +52,11 @@ import { Module } from '@nestjs/common'; * * PlaceRegionsRepairJob puts right, once, the place_regions rows cached before #2527; * `AppSettings` is in the feature list for the marker row it reads and writes. + * + * AtlasWritesService holds the writes the routes and the tools make, each + * followed by `atlas:changed` to the user; it is exported for the Dawarich + * confirmations, which announce through it after their own commit. RealtimeModule + * is imported for it, so the module boots on its own (atlas.e2e) as in the app. */ @Module({ imports: [ @@ -58,6 +65,7 @@ import { Module } from '@nestjs/common'; TokensModule, RateLimitModule, SchedulingModule, + RealtimeModule, MikroOrmModule.forFeature([ BucketList, HiddenCountries, @@ -72,7 +80,7 @@ import { Module } from '@nestjs/common'; ]), ], controllers: [AtlasController, TravelStatsController, PublicStatsController], - providers: [AtlasService, AtlasMcp, AtlasRpc, ApiTokenGuard, PlaceRegionsRepairJob], - exports: [AtlasService], + providers: [AtlasService, AtlasWritesService, AtlasMcp, AtlasRpc, ApiTokenGuard, PlaceRegionsRepairJob], + exports: [AtlasService, AtlasWritesService], }) export class AtlasModule {} diff --git a/server/src/nest/atlas/atlas.service.ts b/server/src/nest/atlas/atlas.service.ts index 6d9499f8b..b9e95a3e4 100644 --- a/server/src/nest/atlas/atlas.service.ts +++ b/server/src/nest/atlas/atlas.service.ts @@ -166,7 +166,7 @@ function markedSource(row: { source?: string | null } | undefined): string | nul * module so the multi-MB caches stay process-global across the container * instance and test helpers (#1576). The country lookup on the admin-0 borders sits in utils/country-borders.ts. * Returns native service shapes; the client-facing contracts live in - * @trek/shared. No broadcasts: atlas rows are uid-scoped, not trip-scoped. + * @trek/shared. Sends nothing itself: AtlasWritesService tells the user's tabs. */ @Injectable() export class AtlasService { diff --git a/server/src/nest/collections/collections.controller.ts b/server/src/nest/collections/collections.controller.ts index 5b2cbc431..c9870d514 100644 --- a/server/src/nest/collections/collections.controller.ts +++ b/server/src/nest/collections/collections.controller.ts @@ -98,14 +98,18 @@ export class CollectionsController { } @Post() - create(@CurrentUser() user: User, @Body() body: CollectionCreateDto) { - return this.collections.createCollection(user.id, body); + create(@CurrentUser() user: User, @Body() body: CollectionCreateDto, @Headers('x-socket-id') socketId?: string) { + return this.collections.createCollection(user.id, body, socketId); } @Post('reorder') @HttpCode(200) - async reorder(@CurrentUser() user: User, @Body() body: CollectionReorderDto) { - await this.collections.reorderCollections(user.id, body.orderedIds); + async reorder( + @CurrentUser() user: User, + @Body() body: CollectionReorderDto, + @Headers('x-socket-id') socketId?: string, + ) { + await this.collections.reorderCollections(user.id, body.orderedIds, socketId); return { success: true }; } @@ -253,8 +257,12 @@ export class CollectionsController { * are @HttpCode(200) because they act on one that already exists. */ @Post('import') - importCollection(@CurrentUser() user: User, @Body() body: CollectionImportDto) { - return this.collections.importCollection(user.id, body); + importCollection( + @CurrentUser() user: User, + @Body() body: CollectionImportDto, + @Headers('x-socket-id') socketId?: string, + ) { + return this.collections.importCollection(user.id, body, socketId); } /** @@ -277,8 +285,12 @@ export class CollectionsController { @Post('copy-to-trip') @HttpCode(200) - copyToTrip(@CurrentUser() user: User, @Body() body: CollectionCopyToTripDto) { - return this.collections.copyToTrip(user.id, body); + copyToTrip( + @CurrentUser() user: User, + @Body() body: CollectionCopyToTripDto, + @Headers('x-socket-id') socketId?: string, + ) { + return this.collections.copyToTrip(user.id, body, this.collections.tripEvents(body.trip_id, socketId)); } // ── Labels (per-collection custom labels; static prefixes before /:id) ─────── @@ -350,12 +362,17 @@ export class CollectionsController { // ── Fusion invitations ────────────────────────────────────────────────────── @Post('invite') @HttpCode(200) - async invite(@CurrentUser() user: User, @Body() body: CollectionInviteDto) { + async invite( + @CurrentUser() user: User, + @Body() body: CollectionInviteDto, + @Headers('x-socket-id') socketId?: string, + ) { await this.collections.assertAccess(user.id, body.collection_id); // 404 if not visible (no enumeration) if (!(await this.collections.isOwner(user.id, body.collection_id))) { throw new HttpException({ error: 'Only the owner can invite' }, 403); } - await this.collections.sendInvite(body.collection_id, user.id, user.username, user.email, body.user_id, body.role); + const { collection_id, user_id, role } = body; + await this.collections.sendInvite(collection_id, user.id, user.username, user.email, user_id, role, socketId); return { success: true }; } @@ -383,12 +400,16 @@ export class CollectionsController { @Post('invite/cancel') @HttpCode(200) - async cancelInvite(@CurrentUser() user: User, @Body() body: CollectionInviteCancelDto) { + async cancelInvite( + @CurrentUser() user: User, + @Body() body: CollectionInviteCancelDto, + @Headers('x-socket-id') socketId?: string, + ) { await this.collections.assertAccess(user.id, body.collection_id); // 404 if not visible if (!(await this.collections.isOwner(user.id, body.collection_id))) { throw new HttpException({ error: 'Only the owner can cancel invites' }, 403); } - await this.collections.cancelInvite(body.collection_id, user.id, body.user_id); + await this.collections.cancelInvite(body.collection_id, user.id, body.user_id, socketId); return { success: true }; } @@ -517,8 +538,8 @@ export class CollectionsController { } @Delete(':id') - async remove(@CurrentUser() user: User, @Param('id') id: string) { - await this.collections.deleteCollection(user.id, Number(id)); + async remove(@CurrentUser() user: User, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) { + await this.collections.deleteCollection(user.id, Number(id), socketId); return { success: true }; } } diff --git a/server/src/nest/collections/collections.mcp.ts b/server/src/nest/collections/collections.mcp.ts index 5efcab16e..4643f050f 100644 --- a/server/src/nest/collections/collections.mcp.ts +++ b/server/src/nest/collections/collections.mcp.ts @@ -13,6 +13,7 @@ import { } from '../../nest-mcp'; import { addonGate } from '../addons/addon-gate'; import { AddonsService } from '../addons/addons.service'; +import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { CollectionsService } from './collections.service'; import { InjectRepository } from '@mikro-orm/nestjs'; import { @@ -84,6 +85,7 @@ export class CollectionsMcp { private readonly collections: CollectionsService, @InjectRepository(Users) private readonly users: UsersRepository, readonly addons: AddonsService, + private readonly guards: McpToolGuardsService, ) {} // ── Read ────────────────────────────────────────────────────────────── @@ -405,7 +407,8 @@ export class CollectionsMcp { }) async copyCollectionPlacesToTrip(body: CollectionCopyToTripRequest, ctx: McpContext) { try { - return ok(await this.collections.copyToTrip(ctx.userId, body)); + // The copies reach every tab of the trip, the asking one included. + return ok(await this.collections.copyToTrip(ctx.userId, body, this.guards.tripEvents(body.trip_id))); } catch (err) { return fail(err); } diff --git a/server/src/nest/collections/collections.module.ts b/server/src/nest/collections/collections.module.ts index 898c1cc18..cd9cb88af 100644 --- a/server/src/nest/collections/collections.module.ts +++ b/server/src/nest/collections/collections.module.ts @@ -13,9 +13,11 @@ import { Users } from '../../db/entities/Users.entity'; import { PluginGuardsModule } from '../../nest-rpc/plugin-guards.module'; import { AddonsModule } from '../addons/addons.module'; import { AppConfigModule } from '../app-config/app-config.module'; +import { McpSharedModule } from '../mcp-shared/mcp-shared.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { PermissionsModule } from '../permissions/permissions.module'; import { PlaceImportModule } from '../place-import/place-import.module'; +import { PlacesModule } from '../places/places.module'; import { buildStorageUploadOptions } from '../storage/storage-upload.factory'; import { StorageModule } from '../storage/storage.module'; import { StorageService } from '../storage/storage.service'; @@ -54,6 +56,10 @@ import { MulterModule } from '@nestjs/platform-express'; AppConfigModule, PluginGuardsModule, PlaceImportModule, + // Copying saved places into a trip runs the places domain's journey hook, and + // the copy tool sends its place events the way every MCP tool does. + PlacesModule, + McpSharedModule, MikroOrmModule.forFeature([ Collections, CollectionMembers, diff --git a/server/src/nest/collections/collections.rpc.ts b/server/src/nest/collections/collections.rpc.ts index 131e0d702..847460080 100644 --- a/server/src/nest/collections/collections.rpc.ts +++ b/server/src/nest/collections/collections.rpc.ts @@ -81,7 +81,9 @@ export class CollectionsRpc { if (!parsed.success) throw new BadParams(`invalid copy request: ${schemaMessage(parsed.error)}`); const userId = this.requireCollectionsUser(ctx, 'writes'); await this.requireCollectionsAddon(); - return this.mapCollectionError(() => this.collections.copyToTrip(userId, parsed.data as never)); + // A plugin has no socket: every open planner of the trip hears the copied places. + const events = this.collections.tripEvents(parsed.data.trip_id, undefined); + return this.mapCollectionError(() => this.collections.copyToTrip(userId, parsed.data as never, events)); } @PluginMethod('collections.deletePlace', { permission: 'db:write:collections' }) diff --git a/server/src/nest/collections/collections.service.ts b/server/src/nest/collections/collections.service.ts index 4a347826f..06e2954ec 100644 --- a/server/src/nest/collections/collections.service.ts +++ b/server/src/nest/collections/collections.service.ts @@ -1,10 +1,12 @@ import { DomainError } from '../common/domain-error'; +import { restTripEvents, type TripBroadcast, type TripEventSink } from '../common/trip-writer'; import path from 'path'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@mikro-orm/nestjs'; import { PermissionsService } from '../permissions/permissions.service'; import { RealtimeService } from '../realtime/realtime.service'; import { isUploadedPlaceImage } from '../places/place-image'; +import { PlacesService } from '../places/places.service'; import { StorageService } from '../storage/storage.service'; import { Collections } from '../../db/entities/Collections.entity'; import { CollectionsRepository, type CollectionPlaceRow } from '../../db/repositories/Collections.repository'; @@ -127,6 +129,7 @@ export class CollectionsService { @InjectRepository(Tags) private readonly tags: TagsRepository, @InjectRepository(Users) private readonly users: UsersRepository, private readonly placeImport: PlaceImportService, + private readonly places: PlacesService, // the journey hook of every place copyToTrip adds ) {} /** @@ -417,21 +420,19 @@ export class CollectionsService { * carries no duplicate count. Adding to a list that already exists is * importIntoCollection below. */ - async importCollection(userId: number, body: CollectionImportRequest): Promise { + async importCollection(userId: number, body: CollectionImportRequest, sid?: string): Promise { const file = body.file; const name = (body.name ?? file.name).trim().slice(0, 120) || file.name; const result = await this.uow.transactional(async () => { const collection = await this.createCollection(userId, { - name, - description: file.description ?? null, - color: file.color ?? undefined, - icon: file.icon ?? undefined, - }); + name, description: file.description ?? null, color: file.color ?? undefined, icon: file.icon ?? undefined, + }, undefined, false); const labels = await this.labelIdsForFile(collection.id, file.labels); const counts = await this.writeFilePlaces(collection.id, userId, file, labels.byName, { skipDuplicates: false }); return { collectionId: collection.id, ...counts }; }); + await this.notifyCollectionUsers(result.collectionId, sid, 'collections:updated'); // after the commit const collection = await this.getCollectionRow(result.collectionId); return { @@ -583,7 +584,8 @@ export class CollectionsService { }); } - async createCollection(userId: number, body: CollectionCreateRequest): Promise { + /** `announce: false` leaves the event to a caller whose own transaction is still open (importCollection). */ + async createCollection(userId: number, body: CollectionCreateRequest, socketId?: string, announce = true): Promise { // The MAX read and the insert in one transaction: two lists created at once // cannot take the same position. const id = await this.uow.transactional(async () => this.collectionsRepo.insertCollection({ @@ -596,8 +598,8 @@ export class CollectionsService { links: serializeLinks(body.links), sort_order: (await this.collectionsRepo.maxSortOrder(userId)) + 1, })); - const col = await this.getCollectionRow(id); - return { ...col, is_owner: true }; + if (announce) await this.notifyCollectionUsers(id, socketId, 'collections:updated'); // the owner's tabs list it + return { ...(await this.getCollectionRow(id)), is_owner: true }; } async updateCollection(userId: number, id: number, body: CollectionUpdateRequest, socketId?: string): Promise { @@ -627,7 +629,7 @@ export class CollectionsService { return { ...col, is_owner: col.owner_id === userId }; } - async deleteCollection(userId: number, id: number): Promise { + async deleteCollection(userId: number, id: number, socketId?: string): Promise { await this.assertAccess(userId, id); if (!(await this.isOwner(userId, id))) httpError(403, 'Only the owner can delete this list'); @@ -637,20 +639,16 @@ export class CollectionsService { await this.collectionsRepo.deleteById(id); // CASCADE drops members + places + tags - [...new Set([...accepted, ...pending])] - .filter(uid => uid !== userId) - .forEach(uid => this.realtime.broadcastToUser(uid, { type: 'collections:deleted', collectionId: id })); + [...new Set([userId, ...accepted, ...pending])] // the owner's tabs too, all but the one that asked + .forEach(uid => this.realtime.broadcastToUser(uid, { type: 'collections:deleted', collectionId: id }, socketId)); } - async reorderCollections(userId: number, orderedIds: number[]): Promise { + async reorderCollections(userId: number, orderedIds: number[], socketId?: string): Promise { const visible = new Set(await this.accessibleCollectionIds(userId)); - await this.uow.transactional(async () => { - await this.collectionsRepo.setSortOrders( - orderedIds - .map((cid, index) => ({ id: cid, sortOrder: index })) - .filter(({ id }) => visible.has(id)), - ); - }); + const orders = orderedIds.map((cid, index) => ({ id: cid, sortOrder: index })).filter(({ id }) => visible.has(id)); + await this.uow.transactional(async () => this.collectionsRepo.setSortOrders(orders)); + const moved = orders[0]?.id; // the order is the user's own, so only their tabs hear it + if (moved) this.realtime.broadcastToUser(userId, { type: 'collections:updated', collectionId: moved }, socketId); } // ------------------------------------------------------------------------- @@ -1125,10 +1123,13 @@ export class CollectionsService { // Copy to trip // ------------------------------------------------------------------------- - async copyToTrip(userId: number, body: CollectionCopyToTripRequest): Promise<{ copied: number; skipped: { id: number; name: string }[] }> { - // R6's OPPOSITE order from `savePlace`: trip access (AP6, this method's own - // entry guard) FIRST, then the `place_edit` permission check SECOND — each - // method preserves its OWN legacy order independently, never forced to match. + /** The sink a copy's events take: REST skips its X-Socket-Id, a plugin (no socket) reaches every tab. */ + tripEvents = (tripId: number, socketId: string | undefined): TripEventSink => + restTripEvents((this.realtime.broadcast as TripBroadcast).bind(this.realtime), tripId, socketId); + + /** Copy saved places into a trip; after the commit each copy goes out as `place:created`, as in the planner. */ + async copyToTrip(userId: number, body: CollectionCopyToTripRequest, events: TripEventSink): Promise<{ copied: number; skipped: { id: number; name: string }[] }> { + // R6's opposite order from `savePlace`: trip access (AP6) first, then `place_edit`; each keeps its legacy order. const trip = await this.trips.findAccessible(body.trip_id, userId); if (!trip) httpError(404, 'Trip not found'); const role = (await this.users.getRole(userId)) ?? 'user'; @@ -1136,13 +1137,8 @@ export class CollectionsService { httpError(403, 'Not allowed to edit this trip'); } - // Votes only travel for users who are members of THIS trip (owner + members), - // so copying never surfaces a collection member with no tie to the trip. - // Symmetric with copyTripRatings' collection-member filter (#1435). - const tripMemberIds = new Set([trip.user_id]); - for (const memberId of await this.tripMembers.listUserIdsByTrip(body.trip_id)) { - tripMemberIds.add(memberId); - } + // Votes travel only for the trip's owner and members, as in copyTripRatings (#1435). + const tripMemberIds = new Set([trip.user_id, ...(await this.tripMembers.listUserIdsByTrip(body.trip_id))]); // Visibility on every SOURCE place — no cross-user exfiltration via copy. const sources: CollectionPlaceCopyRow[] = []; @@ -1153,8 +1149,7 @@ export class CollectionsService { sources.push(row); } - // Trip dedup set — same helpers the importers use, so a place renamed in the - // trip is still recognised by its provider id when it is copied again (#1550). + // The importers' dedup set: a place renamed in the trip is still known by its provider id (#1550). const existing = await this.tripPlaces.dedupCandidatesForTrip(body.trip_id); const dedup: DedupSet = { names: new Set(), coords: [], externalIds: new Set() }; for (const r of existing) { @@ -1163,7 +1158,7 @@ export class CollectionsService { else if (r.lat != null && r.lng != null) dedup.coords.push({ lat: r.lat, lng: r.lng }); } - let copied = 0; + const copiedIds: number[] = []; const skipped: { id: number; name: string }[] = []; // The whole copy is one logical write — atomic since the post-fold quirk pass. await this.uow.transactional(async () => { @@ -1175,9 +1170,7 @@ export class CollectionsService { skipped.push({ id: s.id, name: s.name }); continue; } - // CL64 — cross-domain WRITE into 3c's `places`, gated by the trip-access + - // permission checks above (the cross-tenant guard: a caller who can see - // the collection place but not the target trip never reaches this write). + // CL64: a cross-domain write into `places`, behind the trip-access and permission checks above. const newPlaceId = await this.tripPlaces.insertFromCollectionPlace({ trip_id: body.trip_id, name: s.name, description: s.description, lat: s.lat, lng: s.lng, address: s.address, category_id: s.category_id, price: s.price, currency: s.currency, @@ -1186,17 +1179,19 @@ export class CollectionsService { }); const tagIds = await this.collectionPlaces.tagIdsFor(s.id); await this.tags.insertIgnore(newPlaceId, tagIds); - // Ratings travel into the trip too (#1435), but only for trip members — a - // collection voter who isn't on the trip stays out of it. Trip members keep - // voting there; nothing is mirrored back. + // Ratings of trip members travel along (#1435); nothing is mirrored back. const votes = await this.collectionPlaceRatings.listForPlace(s.id); for (const v of votes) if (tripMemberIds.has(v.user_id)) await this.tripPlaceRatings.insertIgnore(newPlaceId, v.user_id, v.rating); - trackInsertedInDedupSet(s, dedup); - copied++; + copiedIds.push(newPlaceId); } }); - return { copied, skipped }; + for (const placeId of copiedIds) { + const place = await this.tripPlaces.findWithTagsAndRatings(placeId); // as the place routes send it + if (place) events.emit('place:created', { place }); + await this.places.onCreated(String(body.trip_id), placeId); // a no-op on no day, kept for parity + } + return { copied: copiedIds.length, skipped }; } // ------------------------------------------------------------------------- @@ -1358,7 +1353,7 @@ export class CollectionsService { async sendInvite( collectionId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number, - role: 'viewer' | 'editor' | 'admin' = 'editor', + role: 'viewer' | 'editor' | 'admin' = 'editor', socketId?: string, ): Promise { if (!(await this.isOwner(inviterId, collectionId))) throw new DomainError(403, 'Not allowed'); if (targetUserId === inviterId) throw new DomainError(400, 'Cannot invite yourself'); @@ -1375,6 +1370,7 @@ export class CollectionsService { await this.members.insertInvite(collectionId, targetUserId, role); this.realtime.broadcastToUser(targetUserId, { type: 'collections:invite', from: { id: inviterId, username: inviterUsername }, collectionId }); + await this.notifyCollectionUsers(collectionId, socketId); // the inviter's tabs list the pending invite // Injected, not a lazy import of the old notifications bridge. The laziness bought // nothing the module graph does not already give — NotificationsModule @@ -1393,18 +1389,21 @@ export class CollectionsService { async declineInvite(userId: number, collectionId: number, socketId: string | undefined): Promise { await this.members.deletePending(collectionId, userId); await this.notifyCollectionUsers(collectionId, socketId, 'collections:declined'); + this.realtime.broadcastToUser(userId, { type: 'collections:declined', collectionId }, socketId); // the decliner too } - async cancelInvite(collectionId: number, ownerId: number, targetUserId: number): Promise { + async cancelInvite(collectionId: number, ownerId: number, targetUserId: number, socketId?: string): Promise { if (!(await this.isOwner(ownerId, collectionId))) httpError(403, 'Not allowed'); await this.members.deletePending(collectionId, targetUserId); this.realtime.broadcastToUser(targetUserId, { type: 'collections:cancelled', collectionId }); + await this.notifyCollectionUsers(collectionId, socketId); // the owner's tabs drop the pending invite } async leaveCollection(userId: number, collectionId: number, socketId: string | undefined): Promise { if (await this.isOwner(userId, collectionId)) httpError(400, 'Owner cannot leave; delete the list'); await this.members.deleteAccepted(collectionId, userId); await this.notifyCollectionUsers(collectionId, socketId, 'collections:left'); + this.realtime.broadcastToUser(userId, { type: 'collections:removed', collectionId }, socketId); // as if removed } /** Owner removes an already-accepted member (a "kick"). */ diff --git a/server/src/nest/integrations/dawarich-suggestions.service.ts b/server/src/nest/integrations/dawarich-suggestions.service.ts index ee1799816..6329b53ef 100644 --- a/server/src/nest/integrations/dawarich-suggestions.service.ts +++ b/server/src/nest/integrations/dawarich-suggestions.service.ts @@ -14,6 +14,7 @@ import { TripsRepository } from '../../db/repositories/Trips.repository'; import { UsersRepository } from '../../db/repositories/Users.repository'; import { AssignmentsService } from '../assignments/assignments.service'; import { NAME_TO_CODE } from '../atlas/atlas-geo'; +import { AtlasWritesService } from '../atlas/atlas-writes.service'; import { AtlasService } from '../atlas/atlas.service'; import { UnitOfWork } from '../database/unit-of-work'; import { JourneyDomainService } from '../journey/journey-domain.service'; @@ -88,6 +89,8 @@ export class DawarichSuggestionsService { @InjectRepository(BucketList) private readonly bucketList: BucketListRepository, @InjectRepository(Users) private readonly users: UsersRepository, private readonly uow: UnitOfWork, + // Tells the user's tabs once a tick or a country landed in their atlas. + private readonly atlasWrites: AtlasWritesService, ) {} /** @@ -174,7 +177,7 @@ export class DawarichSuggestionsService { case 'journal': return this.acceptAsJournalEntry(userId, row, body, sid); case 'bucket_list': - return this.acceptAsBucketTick(userId, row, body); + return this.acceptAsBucketTick(userId, row, body, sid); } } @@ -249,14 +252,18 @@ export class DawarichSuggestionsService { // broadcast of a write that then rolls back is a lie every other client // believes, and the journey mirror writes on its own. // - // The same two side effects the places controller performs, because this IS - // a place somebody added to the trip. Skipping them is why an accepted stay - // used to need a page reload to show up. + // The same side effects the place and assignment routes perform, because + // this IS a place somebody added to the trip. Skipping them is why an + // accepted stay used to need a page reload to show up. this.places.broadcast(String(tripId), 'place:created', { place: created }, sid); - await this.places.onCreated(String(tripId), placeId); if (assignment) { this.assignments.broadcast(String(tripId), 'assignment:created', { assignment }, sid); + // What the planner runs once a stop is on a day: the journeys the trip is + // linked to gain its skeleton and hear about it. The place hook below + // would add the skeleton too, but without a word to anyone. + await this.assignments.reconcile(tripId, sid); } + await this.places.onCreated(String(tripId), placeId); return { suggestion: (await this.getOne(userId, row.id))!, @@ -292,10 +299,9 @@ export class DawarichSuggestionsService { // failure between them leaves an entry in the journal with the stay still // listed as unhandled — and the next acceptance writes a second copy of it. // - // The journey domain broadcasts the new entry from inside `createEntry`, so - // that one notification can still outrun a rollback. Moving it out means - // changing that domain's contract, which this integration has no business - // doing; the write itself is what had to stop being two. + // `createEntry` stays quiet in here (its last argument) and the entry is + // announced below, after the commit, as the day take-over does it: an event + // sent from inside could outrun a rollback. const entry = await this.uow.transactional(async () => { const created = await this.journey.createEntry( body.journalId, @@ -311,6 +317,7 @@ export class DawarichSuggestionsService { location_lng: body.lng ?? row.lng ?? undefined, }, sid, + false, ); if (!created) throw new AcceptError('journal_forbidden', 'Journey not found', 404); // Recorded on the entry like a stay of a day taken over whole (JRN-02), @@ -329,6 +336,7 @@ export class DawarichSuggestionsService { await this.markAccepted(row.id, 'journal', { journalEntryId: created.id }); return created; }); + await this.journey.broadcastJourneyEvent(entry.journey_id, 'journey:entry:created', { entry }, sid); return { suggestion: (await this.getOne(userId, row.id))!, createdPlaceId: null, @@ -342,6 +350,7 @@ export class DawarichSuggestionsService { userId: number, row: SuggestionRow, body: DawarichAccept, + sid?: string, ): Promise { const itemId = body.bucketListItemId ?? row.matched_bucket_list_item_id; if (!itemId) { @@ -354,6 +363,7 @@ export class DawarichSuggestionsService { await this.bucketList.markVisited(itemId, userId, row.started_at); await this.markAccepted(row.id, 'bucket_list', { bucketItemId: itemId }); }); + this.atlasWrites.announce(userId, sid); return { suggestion: (await this.getOne(userId, row.id))!, @@ -502,7 +512,7 @@ export class DawarichSuggestionsService { } /** Tick off wishes the user confirmed after reading a scan. */ - async confirmBucketVisits(userId: number, itemIds: number[], visitedAt?: string): Promise { + async confirmBucketVisits(userId: number, itemIds: number[], visitedAt?: string, sid?: string): Promise { const now = new Date().toISOString(); let updated = 0; await this.uow.transactional(async () => { @@ -516,6 +526,7 @@ export class DawarichSuggestionsService { updated += await this.bucketList.markVisitedIfUnset(itemId, userId, stamp); } }); + if (updated > 0) this.atlasWrites.announce(userId, sid); return updated; } @@ -525,8 +536,10 @@ export class DawarichSuggestionsService { } /** Undo a tick. Clears the source with it, so the entry reads as untouched again. */ - async clearBucketVisit(userId: number, itemId: number): Promise { - return this.bucketList.clearVisited(itemId, userId); + async clearBucketVisit(userId: number, itemId: number, sid?: string): Promise { + const cleared = await this.bucketList.clearVisited(itemId, userId); + if (cleared) this.atlasWrites.announce(userId, sid); + return cleared; } // ── Atlas ────────────────────────────────────────────────────────────────── @@ -598,7 +611,7 @@ export class DawarichSuggestionsService { * `INSERT OR IGNORE` underneath means a country already marked by hand keeps * its own provenance — confirming it again does not relabel it as imported. */ - async acceptAtlasCountries(userId: number, codes: string[]): Promise { + async acceptAtlasCountries(userId: number, codes: string[], sid?: string): Promise { let marked = 0; await this.uow.transactional(async () => { for (const raw of codes) { @@ -609,6 +622,8 @@ export class DawarichSuggestionsService { if (await this.atlas.markCountry(userId, code, 'dawarich')) marked++; } }); + // Once, after the commit, rather than per country from inside the transaction. + if (marked > 0) this.atlasWrites.announce(userId, sid); return marked; } } diff --git a/server/src/nest/integrations/dawarich.controller.ts b/server/src/nest/integrations/dawarich.controller.ts index 0825d2b82..9327dc387 100644 --- a/server/src/nest/integrations/dawarich.controller.ts +++ b/server/src/nest/integrations/dawarich.controller.ts @@ -161,14 +161,16 @@ export class DawarichController { @Post('bucket-list/confirm') @HttpCode(200) - async confirmBucketVisits(@CurrentUser() user: User, @Body() body: DawarichBucketConfirmDto) { - return { updated: await this.suggestions.confirmBucketVisits(user.id, body.itemIds, body.visitedAt) }; + async confirmBucketVisits(@CurrentUser() user: User, @Body() body: DawarichBucketConfirmDto, @Req() req: Request) { + return { + updated: await this.suggestions.confirmBucketVisits(user.id, body.itemIds, body.visitedAt, socketId(req)), + }; } @Delete('bucket-list/:itemId/visit') @HttpCode(200) - async clearBucketVisit(@CurrentUser() user: User, @Param('itemId') itemId: string) { - const cleared = await this.suggestions.clearBucketVisit(user.id, parseId(itemId)); + async clearBucketVisit(@CurrentUser() user: User, @Param('itemId') itemId: string, @Req() req: Request) { + const cleared = await this.suggestions.clearBucketVisit(user.id, parseId(itemId), socketId(req)); if (!cleared) throw new HttpException({ error: 'Bucket-list entry not found' }, 404); return { success: true }; } @@ -183,8 +185,8 @@ export class DawarichController { @Post('atlas/accept') @HttpCode(200) - async acceptAtlasCountries(@CurrentUser() user: User, @Body() body: DawarichAtlasAcceptDto) { - return { marked: await this.suggestions.acceptAtlasCountries(user.id, body.countryCodes) }; + async acceptAtlasCountries(@CurrentUser() user: User, @Body() body: DawarichAtlasAcceptDto, @Req() req: Request) { + return { marked: await this.suggestions.acceptAtlasCountries(user.id, body.countryCodes, socketId(req)) }; } // ── Track overlay ────────────────────────────────────────────────────────── diff --git a/server/src/nest/journey/journey-domain.service.ts b/server/src/nest/journey/journey-domain.service.ts index f4a922182..a362cb71b 100644 --- a/server/src/nest/journey/journey-domain.service.ts +++ b/server/src/nest/journey/journey-domain.service.ts @@ -9,7 +9,7 @@ import type { JourneyStats, JourneyTrack, TrekWsUserEventName } from '@trek/shar import { todayUtc } from '@trek/shared'; import { TrekPhotoRegistrationService } from '../photos/trek-photo-registration.service'; import { getCountryFromCoords } from '../atlas/atlas-geo'; -import { computeJourneyStats, type StatsInputPoint } from './journey-stats'; +import { computeJourneyStats, countryNamesFor, type StatsInputPoint } from './journey-stats'; import { newEntrySortOrder, placeMovedEntry } from './journey-entry-order'; import { Journeys } from '../../db/entities/Journeys.entity'; import type { JourneysRepository } from '../../db/repositories/Journeys.repository'; @@ -30,28 +30,6 @@ import type { PlacesRepository } from '../../db/repositories/Places.repository'; import { presenceSet } from '../../db/repositories/_shared/presence-set'; import { parseRouteGeometry } from './journey-geometry'; -/** - * English country names for whatever codes a journey turned up. - * - * `Intl.DisplayNames` rather than a bundled table or the admin-0 properties: - * Node has the CLDR data already, the names it gives are the ones every other - * piece of software shows, and parsing 30MB of boundary GeoJSON to read a - * `NAME` field would be an absurd way to learn that IS is Iceland. - * - * English on purpose — the client re-resolves these into the reader's language - * when it places the element, and a book needs the country's name in the - * language the book is written in, not the language of whoever's server it is. - */ -function regionNames(): Intl.DisplayNames | null { - try { - return new Intl.DisplayNames(['en'], { type: 'region' }); - } catch { - // A Node built without full ICU has no region names. Codes are a poor - // label but a working one, and a book page is not worth a 500 over. - return null; - } -} - /** * What makes a journal skeleton unique: the place *and* the day assignment it * stands on (#2329). Keyed on the place alone, a stop kept across two days — the @@ -66,20 +44,6 @@ function skeletonKey(placeId: number, assignmentId: number | null | undefined): return `${placeId}:${assignmentId ?? ''}`; } -function countryNamesFor(points: { country: string | null }[]): Record { - const out: Record = {}; - const display = regionNames(); - for (const p of points) { - if (!p.country) continue; - const code = p.country.toUpperCase(); - if (out[code]) continue; - out[code] = (display?.of(code) ?? code) || code; - } - return out; -} - - - // Per-journey gallery view: journey_photos → trek_photos (no entry context). // Per-entry photo view: join journey_entry_photos → journey_photos (gallery) → trek_photos. // id = gp.id (gallery photo id) — used by clients for linkPhoto/updatePhoto/unlink/delete. @@ -159,11 +123,13 @@ export class JourneyDomainService { event: TrekWsUserEventName, data: Record, excludeSocketId?: string | number, + // Read before a write that takes people off the journey, so they still hear about it. + audience?: number[], ): Promise { // JG1/JG2 — one composed recipient-set read (`JourneysRepository // .listRecipientUserIds`), public because Task 2/3 reuse it for their // own `broadcastJourneyEvent` calls too. - const userIds = await this.journeysRepo.listRecipientUserIds(journeyId); + const userIds = audience ?? (await this.journeysRepo.listRecipientUserIds(journeyId)); for (const uid of userIds) { this.realtime.broadcastToUser(uid, { type: event, journeyId, ...data }, excludeSocketId); @@ -207,6 +173,7 @@ export class JourneyDomainService { subtitle?: string; trip_ids?: number[]; }, + sid?: string, ): Promise { const now = this.ts(); // The journey, its owner row, its trips and their skeletons and the cover are @@ -232,6 +199,8 @@ export class JourneyDomainService { return { journeyId: id, linked: done }; }); for (const tripId of linked) await this.broadcastJourneyEvent(journeyId, 'journey:trip:synced', { tripId }); + // Every tab of the owner lists the new journey, the one that asked over REST aside. + await this.broadcastJourneyEvent(journeyId, 'journey:updated', {}, sid); return (await this.journeysRepo.findById(journeyId)) as Journey; } @@ -332,6 +301,7 @@ export class JourneyDomainService { show_weather: boolean | number; auto_prepare_recordings: boolean | number; }>, + sid?: string, ): Promise { // Journey-level settings (title, cover, status) are owner-only — editors // may only edit entries and photos, not reshape the journey itself. @@ -376,6 +346,7 @@ export class JourneyDomainService { if (Object.keys(patch).length === 0) return ((await this.journeysRepo.findById(journeyId)) as Journey) ?? null; await this.journeysRepo.updateFields(journeyId, { ...patch, updated_at: this.ts() }); + await this.broadcastJourneyEvent(journeyId, 'journey:updated', {}, sid); return ((await this.journeysRepo.findById(journeyId)) as Journey) ?? null; } @@ -412,12 +383,14 @@ export class JourneyDomainService { await this.entriesRepo.nameLocationIfUnnamed(entryId, name); // JG124 } - async updateJourneyPreferences(journeyId: number, userId: number, data: { hide_skeletons?: boolean }) { + async updateJourneyPreferences(journeyId: number, userId: number, data: { hide_skeletons?: boolean }, sid?: string) { if (!(await this.canAccessJourney(journeyId, userId))) return null; if (data.hide_skeletons !== undefined) { await this.contributorsRepo.setHideSkeletons(journeyId, userId, data.hide_skeletons ? 1 : 0); } const hideSkeletons = await this.contributorsRepo.getHideSkeletons(journeyId, userId); + // The preference is this user's own, so only their tabs read the journey again. + this.realtime.broadcastToUser(userId, { type: 'journey:updated', journeyId }, sid); return { hide_skeletons: hideSkeletons }; } @@ -429,16 +402,19 @@ export class JourneyDomainService { * things you have already said you did not want is not a screen worth * building. The count comes back so the caller can say what happened. */ - async restoreDismissedSuggestions(journeyId: number, userId: number): Promise<{ restored: number } | null> { + async restoreDismissedSuggestions(journeyId: number, userId: number, sid?: string): Promise<{ restored: number } | null> { if (!(await this.canEdit(journeyId, userId))) return null; const restored = await this.entriesRepo.restoreDismissed(journeyId); - if (restored > 0) await this.broadcastJourneyEvent(journeyId, 'journey:entry:updated', { restored }); + if (restored > 0) await this.broadcastJourneyEvent(journeyId, 'journey:updated', {}, sid); return { restored }; } - async deleteJourney(journeyId: number, userId: number): Promise { + async deleteJourney(journeyId: number, userId: number, sid?: string): Promise { if (!(await this.isOwner(journeyId, userId))) return false; + // Read first: the delete takes the contributor rows with it. + const audience = await this.journeysRepo.listRecipientUserIds(journeyId); await this.journeysRepo.deleteById(journeyId); + await this.broadcastJourneyEvent(journeyId, 'journey:deleted', {}, sid, audience); return true; } @@ -479,7 +455,7 @@ export class JourneyDomainService { return true; } - async removeTripFromJourney(journeyId: number, tripId: number, userId: number): Promise { + async removeTripFromJourney(journeyId: number, tripId: number, userId: number, sid?: string): Promise { if (!(await this.isOwner(journeyId, userId))) return false; await this.uow.transactional(async () => { // Unfilled skeletons go, filled entries stay but let go of the trip. @@ -487,6 +463,7 @@ export class JourneyDomainService { await this.entriesRepo.detachFilledForTrip(journeyId, tripId); await this.journeyTripsRepo.deleteLink(journeyId, tripId); }); + await this.broadcastJourneyEvent(journeyId, 'journey:updated', {}, sid); return true; } @@ -1742,10 +1719,16 @@ export class JourneyDomainService { async removeContributor(journeyId: number, userId: number, targetUserId: number): Promise { if (!(await this.isOwner(journeyId, userId))) return false; + // Read first, so the one taken off hears it too and leaves the journey. + const audience = await this.journeysRepo.listRecipientUserIds(journeyId); // JG119 — R5: the owner-protection guard stays INSIDE the repository's // own statement (`deleteNonOwner`'s `role != 'owner'` condition, Task // 1's build), never split into a find-then-conditional-delete. - await this.contributorsRepo.deleteNonOwner(journeyId, targetUserId); + const removed = await this.contributorsRepo.deleteNonOwner(journeyId, targetUserId); + // The owner, or someone who was never on the journey, leaves nothing to announce. + if (removed > 0) { + await this.broadcastJourneyEvent(journeyId, 'journey:contributor:changed', { targetUserId, role: null }, undefined, audience); + } return true; } diff --git a/server/src/nest/journey/journey-photo-capture.service.ts b/server/src/nest/journey/journey-photo-capture.service.ts index e7ba7342b..87701d1ca 100644 --- a/server/src/nest/journey/journey-photo-capture.service.ts +++ b/server/src/nest/journey/journey-photo-capture.service.ts @@ -1,4 +1,5 @@ import { withRequestContext } from '../database/request-context'; +import { UnitOfWork } from '../database/unit-of-work'; import { MapsService } from '../maps/maps.service'; import { PhotoCaptureBackfillService } from '../memories/photo-capture-backfill.service'; import { JourneyDomainService } from './journey-domain.service'; @@ -12,9 +13,25 @@ function trekPhotoIdsOf(photos: readonly unknown[]): number[] { .filter((id): id is number => typeof id === 'number'); } +/** Where a batch of provider photos goes: one entry (and with it the journey gallery), or the gallery alone. */ +export type ProviderPhotoTarget = { entryId: number } | { journeyId: number }; + +/** A batch of photos from one library, as every surface hands it over. */ +export interface ProviderPhotoBatch { + provider: string; + assetIds: readonly string[]; + /** Parallel to assetIds: 'video' marks a clip, anything else is an image. */ + mediaTypes?: readonly unknown[]; + /** Kept on an entry's photos only; the gallery add has never recorded one. */ + caption?: string; + /** For an album someone shared from Synology Photos. */ + passphrase?: string; +} + /** - * Capture times for photos that just landed on a journey, and the refresh that - * has to follow them (#1587). + * Provider photos landing on a journey: the add itself, the event that tells + * everyone on the journey, and the capture times and refresh that follow them + * (#1587). * * The add answers before anyone has asked the provider when each photo was * taken (#1614 keeps that on the server rather than trusting the client). So @@ -44,40 +61,70 @@ export class JourneyPhotoCaptureService { private readonly journey: JourneyDomainService, private readonly maps: MapsService, private readonly orm: MikroORM, + private readonly uow: UnitOfWork, ) {} + /** + * Adds photos from a connected library, the one way the REST routes and the + * MCP tool do it. One add per asset, all in one transaction; an asset the + * caller may not add, or one already there, answers null and is left out of + * what comes back. Once they are in, everyone on the journey is told it + * changed (REST leaves out the tab that asked, which reads its own answer), + * and the provider is asked when and where each was taken, detached. + */ + async addProviderPhotos( + target: ProviderPhotoTarget, + userId: number, + batch: ProviderPhotoBatch, + sid?: string, + ): Promise { + const { provider, caption, passphrase } = batch; + const added = await this.uow.transactional(async () => { + const photos: unknown[] = []; + for (const [i, assetId] of batch.assetIds.entries()) { + const mediaType = batch.mediaTypes?.[i] === 'video' ? 'video' : 'image'; + const photo = + 'entryId' in target + ? await this.journey.addProviderPhoto( + target.entryId, + userId, + provider, + assetId, + caption, + passphrase, + mediaType, + ) + : await this.journey.addProviderPhotoToGallery( + target.journeyId, + userId, + provider, + assetId, + undefined, + passphrase, + mediaType, + ); + if (photo) photos.push(photo); + } + return photos; + }); + if (added.length === 0) return added; + const journeyId = 'entryId' in target ? await this.journey.journeyIdOfEntry(target.entryId) : target.journeyId; + // An entry whose journey has gone since still gets its capture times, with no one to tell. + if (journeyId !== null) await this.journey.broadcastJourneyEvent(journeyId, 'journey:updated', {}, sid); + this.scheduleForJourney(journeyId, added, userId); + return added; + } + /** * Provider photos just added to a journey, to its gallery or to one of its entries. Detached. * @txStandalone a detached refresh after the add has committed. */ - scheduleForJourney(journeyId: number, photos: readonly unknown[], userId: number): void { + scheduleForJourney(journeyId: number | null, photos: readonly unknown[], userId: number): void { const ids = trekPhotoIdsOf(photos); if (!ids.length) return; void withRequestContext(this.orm, () => this.fill(journeyId, ids, userId)); } - /** - * Provider photos just added to an entry, for a caller that only knows the entry. Detached. - * @txStandalone a detached refresh after the add has committed. - */ - scheduleForEntry(entryId: number, photos: readonly unknown[], userId: number): void { - const ids = trekPhotoIdsOf(photos); - if (!ids.length) return; - void withRequestContext(this.orm, () => this.fillForEntry(entryId, ids, userId)); - } - - /** scheduleForEntry's detached half: find the entry's journey, then fill. Never rejects. */ - private async fillForEntry(entryId: number, trekPhotoIds: number[], userId: number): Promise { - let journeyId: number | null; - try { - journeyId = await this.journey.journeyIdOfEntry(entryId); - } catch (err) { - console.error(`[Journey] capture refresh failed for entry ${entryId}:`, err instanceof Error ? err.message : err); - return; - } - await this.fill(journeyId, trekPhotoIds, userId); - } - /** * Photos uploaded from a device: read their capture times, and nothing more. * Detached. diff --git a/server/src/nest/journey/journey-stats.ts b/server/src/nest/journey/journey-stats.ts index 121a22167..0547a679d 100644 --- a/server/src/nest/journey/journey-stats.ts +++ b/server/src/nest/journey/journey-stats.ts @@ -158,6 +158,41 @@ export function collectCountries(points: StatsInputPoint[], names: Record { + const out: Record = {}; + const display = regionNames(); + for (const p of points) { + if (!p.country) continue; + const code = p.country.toUpperCase(); + if (out[code]) continue; + out[code] = (display?.of(code) ?? code) || code; + } + return out; +} + export interface StatsInput { journeyId: number; /** The route, already in the order it should be drawn. */ diff --git a/server/src/nest/journey/journey.controller.ts b/server/src/nest/journey/journey.controller.ts index 2b5dfb612..3f89a8a19 100644 --- a/server/src/nest/journey/journey.controller.ts +++ b/server/src/nest/journey/journey.controller.ts @@ -26,7 +26,7 @@ import { StorageService } from '../storage/storage.service'; import { journeyThumbName } from '../memories/thumbnail.service'; import { JourneyService } from './journey.service'; import { JourneyBookService } from './journey-book.service'; -import { JourneyPhotoCaptureService } from './journey-photo-capture.service'; +import { JourneyPhotoCaptureService, type ProviderPhotoBatch, type ProviderPhotoTarget } from './journey-photo-capture.service'; import { AddonGuard } from '../addons/addon.guard'; import { RequireAddon } from '../addons/require-addon.decorator'; import { ADDON_IDS } from '../../addons'; @@ -212,7 +212,7 @@ export class JourneyController { @Post() @ResponseContract(journeySchema) - create(@CurrentUser() user: User, @Body() body: JourneyCreateDto) { + create(@CurrentUser() user: User, @Body() body: JourneyCreateDto, @Headers('x-socket-id') socketId?: string) { if (!body.title || typeof body.title !== 'string' || !body.title.trim()) { throw new HttpException({ error: 'Title is required' }, 400); } @@ -222,7 +222,7 @@ export class JourneyController { title: (body.title as string).trim(), subtitle: body.subtitle as string | undefined, trip_ids: Array.isArray(body.trip_ids) ? body.trip_ids.map(Number) : [], - }); + }, socketId); } @Get('suggestions') @@ -361,27 +361,33 @@ export class JourneyController { } @Post('entries/:entryId/provider-photos') - async providerPhotos(@CurrentUser() user: User, @Param('entryId') entryId: string, @Body() body: JourneyProviderPhotosDto) { - const pp = body.passphrase && typeof body.passphrase === 'string' ? body.passphrase : undefined; + async providerPhotos(@CurrentUser() user: User, @Param('entryId') entryId: string, @Body() body: JourneyProviderPhotosDto, @Headers('x-socket-id') socketId?: string) { + return this.addProviderPhotos({ entryId: Number(entryId) }, user, body, socketId); + } + + /** + * Both provider-photo routes: a batch (`asset_ids`) answers what landed, a + * single asset answers the photo or 403 when it did not land. The adds, the + * event and the capture refresh are JourneyPhotoCaptureService's, as for MCP. + */ + private async addProviderPhotos(target: ProviderPhotoTarget, user: User, body: JourneyProviderPhotosDto, socketId?: string) { + const passphrase = body.passphrase && typeof body.passphrase === 'string' ? body.passphrase : undefined; + // The gallery add has never stored a caption. + const caption = 'entryId' in target ? (body.caption as string | undefined) : undefined; if (Array.isArray(body.asset_ids) && body.provider) { - const added: unknown[] = []; - for (let i = 0; i < body.asset_ids.length; i++) { - const id = body.asset_ids[i]; - const mt = Array.isArray(body.media_types) && body.media_types[i] === 'video' ? 'video' : 'image'; - const photo = await this.journey.addProviderPhoto(Number(entryId), user.id, String(body.provider), String(id), body.caption as string | undefined, pp, mt); - if (photo) added.push(photo); - } - this.photoCapture.scheduleForEntry(Number(entryId), added, user.id); + const mediaTypes = Array.isArray(body.media_types) ? body.media_types : undefined; + const batch: ProviderPhotoBatch = { provider: String(body.provider), assetIds: body.asset_ids.map(String), mediaTypes, caption, passphrase }; + const added = await this.photoCapture.addProviderPhotos(target, user.id, batch, socketId); return { photos: added, added: added.length }; } if (!body.provider || !body.asset_id) { throw new HttpException({ error: 'provider and asset_id required' }, 400); } - const photo = await this.journey.addProviderPhoto(Number(entryId), user.id, String(body.provider), String(body.asset_id), body.caption as string | undefined, pp, body.media_type === 'video' ? 'video' : 'image'); + const batch: ProviderPhotoBatch = { provider: String(body.provider), assetIds: [String(body.asset_id)], mediaTypes: [body.media_type], caption, passphrase }; + const [photo] = await this.photoCapture.addProviderPhotos(target, user.id, batch, socketId); if (!photo) { throw new HttpException({ error: 'Not allowed or duplicate' }, 403); } - this.photoCapture.scheduleForEntry(Number(entryId), [photo], user.id); return photo; } @@ -522,28 +528,8 @@ export class JourneyController { } @Post(':id/gallery/provider-photos') - async galleryProviderPhotos(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyProviderPhotosDto) { - const pp = body.passphrase && typeof body.passphrase === 'string' ? body.passphrase : undefined; - if (Array.isArray(body.asset_ids) && body.provider) { - const added: unknown[] = []; - for (let i = 0; i < body.asset_ids.length; i++) { - const aid = body.asset_ids[i]; - const mt = Array.isArray(body.media_types) && body.media_types[i] === 'video' ? 'video' : 'image'; - const photo = await this.journey.addProviderPhotoToGallery(Number(id), user.id, String(body.provider), String(aid), undefined, pp, mt); - if (photo) added.push(photo); - } - this.photoCapture.scheduleForJourney(Number(id), added, user.id); - return { photos: added, added: added.length }; - } - if (!body.provider || !body.asset_id) { - throw new HttpException({ error: 'provider and asset_id required' }, 400); - } - const photo = await this.journey.addProviderPhotoToGallery(Number(id), user.id, String(body.provider), String(body.asset_id), undefined, pp, body.media_type === 'video' ? 'video' : 'image'); - if (!photo) { - throw new HttpException({ error: 'Not allowed or duplicate' }, 403); - } - this.photoCapture.scheduleForJourney(Number(id), [photo], user.id); - return photo; + async galleryProviderPhotos(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyProviderPhotosDto, @Headers('x-socket-id') socketId?: string) { + return this.addProviderPhotos({ journeyId: Number(id) }, user, body, socketId); } @Delete(':id/gallery/:journeyPhotoId') @@ -569,8 +555,8 @@ export class JourneyController { } @Patch(':id') - async update(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyUpdateDto) { - const result = await this.journey.updateJourney(Number(id), user.id, body); + async update(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyUpdateDto, @Headers('x-socket-id') socketId?: string) { + const result = await this.journey.updateJourney(Number(id), user.id, body, socketId); if (!result) { throw new HttpException({ error: 'Journey not found' }, 404); } @@ -580,12 +566,12 @@ export class JourneyController { @Post(':id/cover') @HttpCode(200) // Express answers cover with res.json (200). @UseInterceptors(FileInterceptor('cover')) - async cover(@CurrentUser() user: User, @Param('id') id: string, @UploadedFile() file: Express.Multer.File | undefined) { + async cover(@CurrentUser() user: User, @Param('id') id: string, @UploadedFile() file: Express.Multer.File | undefined, @Headers('x-socket-id') socketId?: string) { if (!file) { throw new HttpException({ error: 'No file uploaded' }, 400); } await this.commitJourneyUploads([file]); - const result = await this.journey.updateJourney(Number(id), user.id, { cover_image: `journey/${file.filename}` }); + const result = await this.journey.updateJourney(Number(id), user.id, { cover_image: `journey/${file.filename}` }, socketId); if (!result) { await this.discardJourneyUploads([file]); throw new HttpException({ error: 'Journey not found' }, 404); @@ -595,8 +581,8 @@ export class JourneyController { @Delete(':id') @ResponseContract(successResponseSchema) - async remove(@CurrentUser() user: User, @Param('id') id: string) { - if (!(await this.journey.deleteJourney(Number(id), user.id))) { + async remove(@CurrentUser() user: User, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) { + if (!(await this.journey.deleteJourney(Number(id), user.id, socketId))) { throw new HttpException({ error: 'Journey not found' }, 404); } return { success: true }; @@ -618,8 +604,8 @@ export class JourneyController { @Delete(':id/trips/:tripId') @ResponseContract(successResponseSchema) - async removeTrip(@CurrentUser() user: User, @Param('id') id: string, @Param('tripId') tripId: string) { - if (!(await this.journey.removeTripFromJourney(Number(id), Number(tripId), user.id))) { + async removeTrip(@CurrentUser() user: User, @Param('id') id: string, @Param('tripId') tripId: string, @Headers('x-socket-id') socketId?: string) { + if (!(await this.journey.removeTripFromJourney(Number(id), Number(tripId), user.id, socketId))) { throw new HttpException({ error: 'Not allowed' }, 403); } return { success: true }; @@ -806,8 +792,8 @@ export class JourneyController { // ── Suggestions ───────────────────────────────────────────────────────── @Post(':id/suggestions/restore') @HttpCode(200) - async restoreSuggestions(@CurrentUser() user: User, @Param('id') id: string) { - const result = await this.journey.restoreDismissedSuggestions(Number(id), user.id); + async restoreSuggestions(@CurrentUser() user: User, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) { + const result = await this.journey.restoreDismissedSuggestions(Number(id), user.id, socketId); if (!result) { throw new HttpException({ error: 'Not allowed' }, 403); } @@ -816,8 +802,8 @@ export class JourneyController { // ── User Preferences ──────────────────────────────────────────────────── @Patch(':id/preferences') - async preferences(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyPreferencesDto) { - const result = await this.journey.updateJourneyPreferences(Number(id), user.id, body); + async preferences(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyPreferencesDto, @Headers('x-socket-id') socketId?: string) { + const result = await this.journey.updateJourneyPreferences(Number(id), user.id, body, socketId); if (!result) { throw new HttpException({ error: 'Not allowed' }, 403); } diff --git a/server/src/nest/journey/journey.mcp.ts b/server/src/nest/journey/journey.mcp.ts index e156949cf..25a023aca 100644 --- a/server/src/nest/journey/journey.mcp.ts +++ b/server/src/nest/journey/journey.mcp.ts @@ -730,30 +730,20 @@ export class JourneyMcp { return notFound('Entry not found in this journey.'); } - const photos: unknown[] = []; - for (let i = 0; i < asset_ids.length; i++) { - const assetId = asset_ids[i]; - const mediaType = media_types?.[i] === 'video' ? 'video' : 'image'; - const photo = - entryId === undefined - ? await this.journey.addProviderPhotoToGallery( - journeyId, - ctx.userId, - provider, - assetId, - undefined, - passphrase, - mediaType, - ) - : await this.journey.addProviderPhoto(entryId, ctx.userId, provider, assetId, caption, passphrase, mediaType); - if (photo) photos.push(photo); - } - - // Detached, exactly as the REST routes schedule it: the provider is asked - // when and where each photo was taken, and without that answer an attached - // photo can never appear on the journey map (#1614). Open clients are told - // once it lands, so the gallery re-sorts without a reload (#1587). - this.photoCapture.scheduleForJourney(journeyId, photos, ctx.userId); + // The same use case as the REST routes: the adds, the journey:updated every + // open tab hears, then, detached, the provider is asked when and where each + // photo was taken, without which it never appears on the journey map (#1614). + const photos = await this.photoCapture.addProviderPhotos( + entryId === undefined ? { journeyId } : { entryId }, + ctx.userId, + { + provider, + assetIds: asset_ids, + mediaTypes: media_types, + caption: entryId === undefined ? undefined : caption, + passphrase, + }, + ); // `skipped` is what tells a caller that a shortfall was duplicates rather // than a failure; the REST body carries only photos and added. return ok({ photos, added: photos.length, skipped: asset_ids.length - photos.length }); diff --git a/server/src/nest/journey/journey.service.ts b/server/src/nest/journey/journey.service.ts index 6f1049f6d..592bdf812 100644 --- a/server/src/nest/journey/journey.service.ts +++ b/server/src/nest/journey/journey.service.ts @@ -38,17 +38,17 @@ export class JourneyService { listJourneys(userId: number) { return this.journey.listJourneys(userId); } - createJourney(userId: number, data: Parameters[1]) { - return this.journey.createJourney(userId, data); + createJourney(userId: number, data: Parameters[1], sid?: string) { + return this.journey.createJourney(userId, data, sid); } getJourneyFull(id: number, userId: number) { return this.journey.getJourneyFull(id, userId); } - updateJourney(id: number, userId: number, data: Parameters[2]) { - return this.journey.updateJourney(id, userId, data); + updateJourney(id: number, userId: number, data: Parameters[2], sid?: string) { + return this.journey.updateJourney(id, userId, data, sid); } - deleteJourney(id: number, userId: number) { - return this.journey.deleteJourney(id, userId); + deleteJourney(id: number, userId: number, sid?: string) { + return this.journey.deleteJourney(id, userId, sid); } getSuggestions(userId: number) { return this.journey.getSuggestions(userId); @@ -60,19 +60,20 @@ export class JourneyService { id: number, userId: number, data: Parameters[2], + sid?: string, ) { - return this.journey.updateJourneyPreferences(id, userId, data); + return this.journey.updateJourneyPreferences(id, userId, data, sid); } - restoreDismissedSuggestions(id: number, userId: number) { - return this.journey.restoreDismissedSuggestions(id, userId); + restoreDismissedSuggestions(id: number, userId: number, sid?: string) { + return this.journey.restoreDismissedSuggestions(id, userId, sid); } // Trips addTripToJourney(id: number, tripId: number, userId: number) { return this.journey.addTripToJourney(id, tripId, userId); } - removeTripFromJourney(id: number, tripId: number, userId: number) { - return this.journey.removeTripFromJourney(id, tripId, userId); + removeTripFromJourney(id: number, tripId: number, userId: number, sid?: string) { + return this.journey.removeTripFromJourney(id, tripId, userId, sid); } // Entries @@ -117,17 +118,6 @@ export class JourneyService { setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) { return this.journey.setPhotoProvider(photoId, provider, assetId, ownerId); } - addProviderPhoto( - entryId: number, - userId: number, - provider: string, - assetId: string, - caption?: string, - passphrase?: string, - mediaType?: string, - ) { - return this.journey.addProviderPhoto(entryId, userId, provider, assetId, caption, passphrase, mediaType); - } linkPhotoToEntry(entryId: number, journeyPhotoId: number, userId: number) { return this.journey.linkPhotoToEntry(entryId, journeyPhotoId, userId); } @@ -143,17 +133,6 @@ export class JourneyService { uploadGalleryPhotos(id: number, userId: number, filePaths: Parameters[2]) { return this.journey.uploadGalleryPhotos(id, userId, filePaths); } - addProviderPhotoToGallery( - id: number, - userId: number, - provider: string, - assetId: string, - caption?: string, - passphrase?: string, - mediaType?: string, - ) { - return this.journey.addProviderPhotoToGallery(id, userId, provider, assetId, caption, passphrase, mediaType); - } deleteGalleryPhoto(journeyPhotoId: number, userId: number) { return this.journey.deleteGalleryPhoto(journeyPhotoId, userId); } diff --git a/server/src/nest/vacay/vacay.controller.ts b/server/src/nest/vacay/vacay.controller.ts index 1f6b3f8e3..db3283324 100644 --- a/server/src/nest/vacay/vacay.controller.ts +++ b/server/src/nest/vacay/vacay.controller.ts @@ -155,12 +155,12 @@ export class VacayController { @Post('invite') @HttpCode(200) @ResponseContract(successResponseSchema) - async invite(@CurrentUser() user: User, @Body() body: VacayInviteDto) { + async invite(@CurrentUser() user: User, @Body() body: VacayInviteDto, @Headers('x-socket-id') socketId?: string) { if (!body.user_id) { throw new HttpException({ error: 'user_id required' }, 400); } const plan = await this.vacay.getActivePlan(user.id); - await this.vacay.sendInvite(plan.id, user.id, user.username, user.email, body.user_id as number); + await this.vacay.sendInvite(plan.id, user.id, user.username, user.email, body.user_id as number, socketId); return { success: true }; } @@ -191,9 +191,13 @@ export class VacayController { @Post('invite/cancel') @HttpCode(200) @ResponseContract(successResponseSchema) - async cancelInvite(@CurrentUser() user: User, @Body() body: VacayInviteDto) { + async cancelInvite( + @CurrentUser() user: User, + @Body() body: VacayInviteDto, + @Headers('x-socket-id') socketId?: string, + ) { const plan = await this.vacay.getActivePlan(user.id); - await this.vacay.cancelInvite(plan.id, body.user_id as number); + await this.vacay.cancelInvite(plan.id, body.user_id as number, socketId); return { success: true }; } diff --git a/server/src/nest/vacay/vacay.service.ts b/server/src/nest/vacay/vacay.service.ts index af55b2068..82dde95e9 100644 --- a/server/src/nest/vacay/vacay.service.ts +++ b/server/src/nest/vacay/vacay.service.ts @@ -687,7 +687,7 @@ export class VacayService { // Invitations // ------------------------------------------------------------------------- - async sendInvite(planId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number): Promise { + async sendInvite(planId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number, socketId?: string): Promise { if (targetUserId === inviterId) throw new DomainError(400, 'Cannot invite yourself'); // The picker no longer offers guests, but the id arrives from the client, so the @@ -706,13 +706,11 @@ export class VacayService { await this.members.insertPending(planId, targetUserId); - try { - this.realtime.broadcastToUser(targetUserId, { - type: 'vacay:invite', - from: { id: inviterId, username: inviterUsername }, - planId, - }); - } catch { /* websocket not available */ } + const from = { id: inviterId, username: inviterUsername }; + this.realtime.broadcastToUser(targetUserId, { type: 'vacay:invite', from, planId }); + // The plan's own tabs list the pending invite too: vacay:settings reads the plan again. + const planUserIds = (await this.getPlanUsers(planId)).map(u => u.id); + planUserIds.forEach(id => this.realtime.broadcastToUser(id, { type: 'vacay:settings' }, socketId)); // Notify invited user // Injected, not a lazy import of the old notifications bridge. The laziness bought @@ -777,14 +775,14 @@ export class VacayService { async declineInvite(userId: number, planId: number, socketId: string | undefined): Promise { await this.members.deletePending(planId, userId); await this.notifyPlanUsers(planId, socketId, 'vacay:declined'); + this.realtime.broadcastToUser(userId, { type: 'vacay:declined' }, socketId); // the decliner's tabs too } - async cancelInvite(planId: number, targetUserId: number): Promise { + async cancelInvite(planId: number, targetUserId: number, socketId?: string): Promise { await this.members.deletePending(planId, targetUserId); - - try { - this.realtime.broadcastToUser(targetUserId, { type: 'vacay:cancelled' }); - } catch { /* */ } + // The invitee's tabs drop the invite, the plan's own tabs the pending row. + const userIds = [targetUserId, ...(await this.getPlanUsers(planId)).map(u => u.id)]; + userIds.forEach(id => this.realtime.broadcastToUser(id, { type: 'vacay:cancelled' }, socketId)); } // ------------------------------------------------------------------------- @@ -822,9 +820,8 @@ export class VacayService { return userIds; }); - try { - allUserIds.filter(id => id !== userId).forEach(id => this.realtime.broadcastToUser(id, { type: 'vacay:dissolved' })); - } catch { /* */ } + // Everyone on the plan, the one dissolving it included: all their tabs but the one that asked. + allUserIds.forEach(id => this.realtime.broadcastToUser(id, { type: 'vacay:dissolved' }, socketId)); // Everyone's entries just moved back to their own plans — refresh read-only viewers. await this.notifyShareViewers(allUserIds, socketId); } @@ -929,7 +926,8 @@ export class VacayService { await this.shares.insertShare(ownerId, targetUserId); try { - this.realtime.broadcastToUser(targetUserId, { type: 'vacay:share', from: { id: ownerId } }); + // The asking socket is the owner's, so leaving it out costs the viewer nothing. + this.realtime.broadcastToUser(targetUserId, { type: 'vacay:share', from: { id: ownerId } }, socketId); // The owner's other devices refresh their outgoing list too. this.realtime.broadcastToUser(ownerId, { type: 'vacay:share', from: { id: ownerId } }, socketId); } catch { /* websocket not available */ } diff --git a/server/tests/e2e/journey.e2e.test.ts b/server/tests/e2e/journey.e2e.test.ts index 3605efa1b..f3b1fbd53 100644 --- a/server/tests/e2e/journey.e2e.test.ts +++ b/server/tests/e2e/journey.e2e.test.ts @@ -493,6 +493,8 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => { expect(res.status).toBe(201); expect(res.body).toEqual({ photos: [{ id: 5, journey_id: 9, photo_id: 77 }], added: 1 }); + // The add itself goes out at once; no X-Socket-Id here, so no tab is left out. + expect(jsvc.broadcastJourneyEvent).toHaveBeenCalledWith(9, 'journey:updated', {}, undefined); await vi.waitFor(() => expect(jsvc.broadcastJourneyEvent).toHaveBeenCalledWith(9, 'journey:photos:updated', {})); expect(backfillRun).toHaveBeenCalledWith([77], 1); }); @@ -513,16 +515,18 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => { await vi.waitFor(() => expect(jsvc.broadcastJourneyEvent).toHaveBeenCalledWith(9, 'journey:photos:updated', {})); expect(jsvc.journeyIdOfEntry).toHaveBeenCalledWith(3); - // Nothing learned: the photos are where they already were, so no refresh. + // Nothing learned: the photos are where they already were, so no refresh + // follows. The add is still told, to every tab but the one that asked. jsvc.broadcastJourneyEvent.mockReset(); backfillRun.mockReset().mockResolvedValue(false); await request(server) .post('/api/journeys/entries/3/provider-photos') .set('Cookie', sessionCookie(1)) + .set('X-Socket-Id', 'sock-3') .send({ provider: 'immich', asset_ids: ['a3'] }); await vi.waitFor(() => expect(backfillRun).toHaveBeenCalled()); await new Promise((resolve) => setTimeout(resolve, 10)); - expect(jsvc.broadcastJourneyEvent).not.toHaveBeenCalled(); + expect(jsvc.broadcastJourneyEvent.mock.calls).toEqual([[9, 'journey:updated', {}, 'sock-3']]); }); it('a gallery upload gets its capture time read, and no journey refresh: the client sends one file per request (#1587)', async () => { diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index 2406d16bd..92162906f 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -347,33 +347,6 @@ export async function createPluginRpcHostParts( await createTestJourneyEntryPhotosRepo(db), await createTestPlacesRepo(db), ); - const collections = new CollectionsService( - permissions, - realtime, - notificationsStub(), - generalStorage, - await createTestUnitOfWork(db), - // Plan 3h Task 1 — the constructor-ripple fix: collections part A's own - // repositories, first cut (`CollectionMembersRepository` extended by - // Task 2), plus the already-DONE `CategoriesRepository` (CL21 reuse). - await createTestCollectionsRepo(db), - await createTestCollectionMembersRepo(db), - await createTestCollectionLabelsRepo(db), - await createTestCategoriesRepo(db), - // Plan 3h Task 2 — part B's own repositories: `DatabaseService` dropped - // entirely (this service's last use of it), the trip/place/tag/user - // repositories AP1-AP6's `TripsRepository.findAccessible` calls and the - // cross-domain writes need, injected directly. - await createTestCollectionPlacesRepo(db), - await createTestCollectionPlaceRatingsRepo(db), - await createTestTripsRepo(db), - await createTestTripMembersRepo(db), - await createTestPlacesRepo(db), - await createTestPlaceRatingsRepo(db), - await createTestTagsRepo(db), - usersRepo, - buildPlaceImportService(), - ); const atlas = new AtlasService( await createTestBucketListRepo(db), await createTestHiddenCountriesRepo(db), @@ -476,6 +449,35 @@ export async function createPluginRpcHostParts( await createTestCollectionPlacesRepo(db), buildPlaceImportService(), ); + // After places: copying saved places into a trip runs the places journey hook. + const collections = new CollectionsService( + permissions, + realtime, + notificationsStub(), + generalStorage, + await createTestUnitOfWork(db), + // Plan 3h Task 1, the constructor-ripple fix: collections part A's own + // repositories, first cut (`CollectionMembersRepository` extended by + // Task 2), plus the already-DONE `CategoriesRepository` (CL21 reuse). + await createTestCollectionsRepo(db), + await createTestCollectionMembersRepo(db), + await createTestCollectionLabelsRepo(db), + await createTestCategoriesRepo(db), + // Plan 3h Task 2, part B's own repositories: `DatabaseService` dropped + // entirely (this service's last use of it), the trip/place/tag/user + // repositories AP1-AP6's `TripsRepository.findAccessible` calls and the + // cross-domain writes need, injected directly. + await createTestCollectionPlacesRepo(db), + await createTestCollectionPlaceRatingsRepo(db), + await createTestTripsRepo(db), + await createTestTripMembersRepo(db), + await createTestPlacesRepo(db), + await createTestPlaceRatingsRepo(db), + await createTestTagsRepo(db), + usersRepo, + buildPlaceImportService(), + places, + ); // After accommodations: a hotel booking writes the stay's day stop through it. const reservations = new ReservationsService( permissions, diff --git a/server/tests/integration/broadcast-parity/pending-ceiling.ts b/server/tests/integration/broadcast-parity/pending-ceiling.ts index 7cdc7b774..825eddf99 100644 --- a/server/tests/integration/broadcast-parity/pending-ceiling.ts +++ b/server/tests/integration/broadcast-parity/pending-ceiling.ts @@ -7,206 +7,4 @@ * fails as well. The list goes once `pending` leaves the type (plan section * 2.10, closing phase B). */ -export const PENDING_CEILING: readonly string[] = [ - 'accept_atlas_countries', - 'accept_collection_invite', - 'accept_dawarich_suggestion_as_journal_entry', - 'accept_dawarich_suggestion_as_place', - 'accept_vacay_invite', - 'add_holiday_calendar', - 'add_journey_contributor', - 'add_journey_provider_photos', - 'add_journey_trip', - 'add_route_via', - 'add_route_vias', - 'add_trip_member', - 'add_vacay_year', - 'apply_packing_template', - 'assign_collection_labels', - 'assign_place_to_day', - 'assign_tour_track', - 'bulk_delete_places', - 'bulk_import_packing', - 'bulk_update_places', - 'cancel_collection_invite', - 'cancel_vacay_invite', - 'clear_bucket_list_visit', - 'clear_day_assignments', - 'close_collab_poll', - 'confirm_bucket_list_visits', - 'copy_collection_places_to_trip', - 'copy_trip', - 'create_and_assign_place', - 'create_bucket_list_item', - 'create_budget_item', - 'create_budget_item_with_members', - 'create_city_walk', - 'create_collab_note', - 'create_collab_poll', - 'create_collection', - 'create_collection_label', - 'create_day', - 'create_day_note', - 'create_hike', - 'create_journey', - 'create_journey_entry', - 'create_packing_bag', - 'create_packing_item', - 'create_reservation', - 'create_settlement', - 'create_tag', - 'create_todo', - 'create_transit_journey', - 'create_transport', - 'create_trip', - 'create_trip_guest', - 'decline_collection_invite', - 'decline_vacay_invite', - 'delete_accommodation', - 'delete_bucket_list_item', - 'delete_budget_item', - 'delete_collab_message', - 'delete_collab_note', - 'delete_collab_poll', - 'delete_collection', - 'delete_collection_label', - 'delete_collection_place', - 'delete_collection_tour', - 'delete_day', - 'delete_day_note', - 'delete_holiday_calendar', - 'delete_journey', - 'delete_journey_entry', - 'delete_journey_recorded_day', - 'delete_packing_bag', - 'delete_packing_item', - 'delete_place', - 'delete_reservation', - 'delete_settlement', - 'delete_tag', - 'delete_todo', - 'delete_transport', - 'delete_trip', - 'delete_trip_guest', - 'delete_vacay_year', - 'dismiss_dawarich_suggestion', - 'dissolve_vacay_plan', - 'freeze_budget_rates', - 'import_airtrail_flights', - 'import_google_maps_route', - 'import_places_from_url', - 'import_tour_gpx', - 'import_trip_gpx', - 'invite_to_collection', - 'leave_collection', - 'leave_trip', - 'link_hotel_accommodation', - 'link_tour_packing', - 'link_trip_file', - 'link_trip_journal', - 'mark_all_notifications_read', - 'mark_bucket_list_item_visited_from_dawarich', - 'mark_country_visited', - 'mark_notification_read', - 'mark_notification_unread', - 'mark_region_visited', - 'move_assignment', - 'rate_collection_place', - 'rate_place', - 'react_collab_message', - 'reanchor_route_vias', - 'remove_collection_member', - 'remove_journey_contributor', - 'remove_journey_trip', - 'remove_route_via', - 'remove_trip_member', - 'rename_trip_guest', - 'reorder_collections', - 'reorder_day_assignments', - 'reorder_days', - 'reorder_journey_entries', - 'reorder_journey_entry_photos', - 'reorder_packing_items', - 'reorder_reservations', - 'reorder_todos', - 'restore_journey_suggestions', - 'save_journey_recorded_day', - 'save_place_to_collection', - 'save_tour_change', - 'save_tour_to_collection', - 'save_trip_places_to_collection', - 'send_collab_message', - 'send_vacay_invite', - 'set_assignment_end_day', - 'set_assignment_participants', - 'set_assignment_route_excluded', - 'set_bag_members', - 'set_budget_item_members', - 'set_collection_member_role', - 'set_collection_place_status', - 'set_collection_place_status_from_trip', - 'set_day_boundary', - 'set_day_default_transport_mode', - 'set_leg_transport_mode', - 'set_packing_category_assignees', - 'set_packing_item_sharing', - 'set_place_image_from_file', - 'set_reservation_travelers', - 'set_todo_category_assignees', - 'set_tour_activity_excluded', - 'set_trip_journal_members', - 'set_vacay_color', - 'share_vacay_calendar', - 'sync_trip_documents', - 'take_over_collection_tour', - 'take_over_journey_day_stays', - 'take_over_tour', - 'toggle_budget_member_paid', - 'toggle_company_holiday', - 'toggle_packing_item', - 'toggle_todo', - 'toggle_vacay_entry', - 'unassign_place', - 'unassign_tour_track', - 'undo_tour_change', - 'unlink_tour_packing', - 'unlink_trip_file', - 'unlink_trip_journal', - 'unmark_country_visited', - 'unmark_region_visited', - 'unshare_vacay_calendar', - 'update_accommodation', - 'update_assignment_notes', - 'update_assignment_time', - 'update_bucket_list_item', - 'update_budget_item', - 'update_collab_note', - 'update_collection', - 'update_collection_label', - 'update_collection_place', - 'update_day', - 'update_day_note', - 'update_display_settings', - 'update_holiday_calendar', - 'update_journey', - 'update_journey_contributor_role', - 'update_journey_entry', - 'update_journey_preferences', - 'update_packing_bag', - 'update_packing_item', - 'update_place', - 'update_reservation', - 'update_roadtrip_settings', - 'update_route_via', - 'update_settlement', - 'update_tag', - 'update_todo', - 'update_tour', - 'update_transport', - 'update_trip', - 'update_trip_file', - 'update_vacay_plan', - 'update_vacay_stats', - 'upload_trip_file', - 'vote_collab_poll', -]; +export const PENDING_CEILING: readonly string[] = []; diff --git a/server/tests/integration/broadcast-parity/user.cases.ts b/server/tests/integration/broadcast-parity/user.cases.ts index 65b055e63..13a8d4381 100644 --- a/server/tests/integration/broadcast-parity/user.cases.ts +++ b/server/tests/integration/broadcast-parity/user.cases.ts @@ -1,16 +1,954 @@ +import { BucketList } from '../../../src/db/entities/BucketList.entity'; +import { CollectionLabels } from '../../../src/db/entities/CollectionLabels.entity'; +import { DawarichVisitSuggestions } from '../../../src/db/entities/DawarichVisitSuggestions.entity'; +import { JourneyEntries } from '../../../src/db/entities/JourneyEntries.entity'; +import { TripJournalLinks } from '../../../src/db/entities/TripJournalLinks.entity'; +import { VacayHolidayCalendars } from '../../../src/db/entities/VacayHolidayCalendars.entity'; +import { VacayShares } from '../../../src/db/entities/VacayShares.entity'; +import { VacayYears } from '../../../src/db/entities/VacayYears.entity'; +import { withRequestContext } from '../../../src/nest/database/request-context'; +import { DawarichTracksService } from '../../../src/nest/integrations/dawarich-tracks.service'; +import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; +import { JourneyRecordedDaysService } from '../../../src/nest/journey/journey-recorded-days.service'; +import { PhotoCaptureBackfillService } from '../../../src/nest/memories/photo-capture-backfill.service'; +import { NotificationsService } from '../../../src/nest/notifications/notifications.service'; +import { addCollectionMember } from '../../helpers/factories/collections'; +import { inContext } from '../../helpers/factories/context'; +import { addJourneyContributor, linkTripToJourney } from '../../helpers/factories/journeys'; +import { createRow, updateRows } from '../../helpers/factories/rows'; +import { addVacayPlanMember } from '../../helpers/factories/vacay'; import { holidayCode } from './harness'; -import type { CaseGroup, Scene } from './types'; +import type { CaseGroup, ParityCase, Scene } from './types'; +import { MikroORM } from '@mikro-orm/core'; +import type { INestApplication } from '@nestjs/common'; +import type { DawarichTrackSegment } from '@trek/shared'; -/** A user's own addons: journey, atlas, collections, vacay, dawarich and the school holiday catalog. */ +import { vi } from 'vitest'; + +/** + * A user's own addons: journey, atlas, collections, vacay, dawarich and the + * school holiday catalog. Their events go to users rather than to a trip room, + * and every write reaches the writer's own tabs: REST leaves out only the tab + * that asked, MCP none, so the tab the assistant runs in shows the change. + */ const HOLIDAY_CATALOG = "the admin's school holiday catalog is global: no room, and the registry has no event for it"; const TOKEN_ONLY = 'manages a public link token: nothing on any screen follows it live, so there is nothing to send'; const onJourney = (s: Scene) => ({ journeyId: s.journey.id }); const WINTER = { name: 'Winter', startDate: '2027-02-01', endDate: '2027-02-12' }; +/** The scene's first day, which the Dawarich stays below fall on. */ +const DAY = '2027-05-03'; +const VACAY = '/api/addons/vacay'; +const ATLAS = '/api/addons/atlas'; +const COLLECTIONS = '/api/addons/collections'; +const DAWARICH = '/api/integrations/dawarich'; + +const ormOf = (app: INestApplication) => app.get(MikroORM); + +/** + * Rows a case's setup makes beyond the scene's needs, by scene, so its + * arguments and REST paths can point at them. Each scene makes its own, so + * the MCP run and the REST run never share one. + */ +const made = new Map(); +const keep = (s: Scene, key: string, id: number) => made.set(`${s.n}:${key}`, id); +function kept(s: Scene, key: string): number { + const id = made.get(`${s.n}:${key}`); + if (id === undefined) throw new Error(`the setup of scene ${s.n} made no ${key}`); + return id; +} + +/** A stay Dawarich recorded for the owner on the scene's first day, waiting for review. */ +async function recordStay(s: Scene, app: INestApplication): Promise { + const row = await createRow(ormOf(app), DawarichVisitSuggestions, { + user: s.owner.id, + source_visit_id: `visit-${s.n}`, + trip: s.trip.id, + name: `Café de Flore ${s.n}`, + lat: 48.854, + lng: 2.3326, + started_at: `${DAY}T10:00:00+02:00`, + ended_at: `${DAY}T11:30:00+02:00`, + duration_minutes: 90, + local_date: DAY, + source_hash: `stay-${s.n}`, + }); + keep(s, 'stay', row.id); +} + +/** + * The in-app notification an invite or a share sends the other user runs + * detached, so it would land in whichever case comes next. Both surfaces + * call the same service method for it; the cases here are about the domain + * events, so the notification is held back while they run. + */ +function quietNotifications(app: INestApplication): () => void { + const spy = vi.spyOn(app.get(NotificationsService), 'send').mockResolvedValue({ attempted: 0, delivered: 0 }); + return () => spy.mockRestore(); +} + +/** One recorded day as Dawarich would answer it, so no instance is asked. */ +const SEGMENTS: DawarichTrackSegment[] = [ + { + points: [ + [48.8566, 2.3522], + [48.8606, 2.3376], + ], + mode: 'walking', + startedAt: `${DAY}T09:00:00Z`, + endedAt: `${DAY}T09:40:00Z`, + distanceMeters: 1200, + }, +]; +const DAY_WINDOW = { from: `${DAY}T00:00:00Z`, to: `${DAY}T23:59:59Z` }; +/** The hash the preview would have shown for SEGMENTS, worked out by the service in the stub. */ +let recordedHash = ''; + +const atlasCases: ParityCase[] = [ + { + tool: 'create_bucket_list_item', + args: (s) => ({ name: `Lisbon ${s.n}` }), + rest: { method: 'post', path: () => `${ATLAS}/bucket-list`, body: (s) => ({ name: `Lisbon ${s.n}` }) }, + expect: [{ event: 'atlas:changed', user: 'actor' }], + }, + { + tool: 'update_bucket_list_item', + needs: ['bucket'], + args: (s) => ({ itemId: s.bucket.id, notes: 'In spring' }), + rest: { method: 'put', path: (s) => `${ATLAS}/bucket-list/${s.bucket.id}`, body: () => ({ notes: 'In spring' }) }, + expect: [{ event: 'atlas:changed', user: 'actor' }], + }, + { + tool: 'delete_bucket_list_item', + needs: ['bucket'], + args: (s) => ({ itemId: s.bucket.id }), + rest: { method: 'delete', path: (s) => `${ATLAS}/bucket-list/${s.bucket.id}` }, + expect: [{ event: 'atlas:changed', user: 'actor' }], + }, + { + tool: 'mark_country_visited', + args: () => ({ country_code: 'PT' }), + rest: { method: 'post', path: () => `${ATLAS}/country/PT/mark` }, + expect: [{ event: 'atlas:changed', user: 'actor' }], + }, + { + tool: 'unmark_country_visited', + args: () => ({ country_code: 'PT' }), + rest: { method: 'delete', path: () => `${ATLAS}/country/PT/mark` }, + expect: [{ event: 'atlas:changed', user: 'actor' }], + }, + { + tool: 'mark_region_visited', + args: () => ({ regionCode: 'PT-11', regionName: 'Lisboa', countryCode: 'PT' }), + rest: { + method: 'post', + path: () => `${ATLAS}/region/PT-11/mark`, + body: () => ({ name: 'Lisboa', country_code: 'PT' }), + }, + expect: [{ event: 'atlas:changed', user: 'actor' }], + }, + { + tool: 'unmark_region_visited', + args: () => ({ regionCode: 'PT-11' }), + rest: { method: 'delete', path: () => `${ATLAS}/region/PT-11/mark` }, + expect: [{ event: 'atlas:changed', user: 'actor' }], + }, +]; + +const dawarichCases: ParityCase[] = [ + { + // The planner's way: once the stop is on a day, the journey the trip is + // linked to gains its skeleton and hears about it (G7). + tool: 'accept_dawarich_suggestion_as_place', + needs: ['journey'], + setup: async (s, app) => { + await recordStay(s, app); + await linkTripToJourney(ormOf(app), s.journey.id, s.trip.id); + }, + args: (s) => ({ suggestionId: kept(s, 'stay'), tripId: s.trip.id, dayId: s.days[0].id }), + rest: { + method: 'post', + path: (s) => `${DAWARICH}/suggestions/${kept(s, 'stay')}/accept`, + body: (s) => ({ target: 'place', tripId: s.trip.id, dayId: s.days[0].id }), + }, + expect: ['place:created', 'assignment:created', { event: 'journey:trip:synced', user: 'actor' }], + }, + { + // Announced after the commit, no longer from inside the transaction (G35). + tool: 'accept_dawarich_suggestion_as_journal_entry', + needs: ['journey'], + setup: recordStay, + args: (s) => ({ suggestionId: kept(s, 'stay'), journalId: s.journey.id }), + rest: { + method: 'post', + path: (s) => `${DAWARICH}/suggestions/${kept(s, 'stay')}/accept`, + body: (s) => ({ target: 'journal', journalId: s.journey.id }), + }, + expect: [{ event: 'journey:entry:created', user: 'actor' }], + }, + { + tool: 'mark_bucket_list_item_visited_from_dawarich', + needs: ['bucket'], + setup: recordStay, + args: (s) => ({ suggestionId: kept(s, 'stay'), bucketListItemId: s.bucket.id }), + rest: { + method: 'post', + path: (s) => `${DAWARICH}/suggestions/${kept(s, 'stay')}/accept`, + body: (s) => ({ target: 'bucket_list', bucketListItemId: s.bucket.id }), + }, + expect: [{ event: 'atlas:changed', user: 'actor' }], + }, + { + tool: 'confirm_bucket_list_visits', + needs: ['bucket'], + args: (s) => ({ itemIds: [s.bucket.id] }), + rest: { method: 'post', path: () => `${DAWARICH}/bucket-list/confirm`, body: (s) => ({ itemIds: [s.bucket.id] }) }, + expect: [{ event: 'atlas:changed', user: 'actor' }], + }, + { + tool: 'clear_bucket_list_visit', + needs: ['bucket'], + setup: async (s, app) => { + await updateRows(ormOf(app), BucketList, { id: s.bucket.id }, { visited_at: `${DAY}T10:00:00Z` }); + }, + args: (s) => ({ itemId: s.bucket.id }), + rest: { method: 'delete', path: (s) => `${DAWARICH}/bucket-list/${s.bucket.id}/visit` }, + expect: [{ event: 'atlas:changed', user: 'actor' }], + }, + { + tool: 'accept_atlas_countries', + args: () => ({ countryCodes: ['PT'] }), + rest: { method: 'post', path: () => `${DAWARICH}/atlas/accept`, body: () => ({ countryCodes: ['PT'] }) }, + expect: [{ event: 'atlas:changed', user: 'actor' }], + }, +]; + +const journeyCases: ParityCase[] = [ + { + tool: 'create_journey', + args: (s) => ({ title: `Portugal ${s.n}` }), + rest: { method: 'post', path: () => '/api/journeys', body: (s) => ({ title: `Portugal ${s.n}` }) }, + expect: [{ event: 'journey:updated', user: 'actor' }], + }, + { + tool: 'update_journey', + needs: ['journey'], + args: (s) => ({ journeyId: s.journey.id, title: `Algarve ${s.n}` }), + rest: { + method: 'patch', + path: (s) => `/api/journeys/${s.journey.id}`, + body: (s) => ({ title: `Algarve ${s.n}` }), + }, + expect: [{ event: 'journey:updated', user: 'actor' }], + }, + { + // Was journey:entry:updated with a payload the registry refuses. + tool: 'restore_journey_suggestions', + needs: ['journey'], + setup: async (s, app) => { + await updateRows(ormOf(app), JourneyEntries, { id: s.journeyEntry.id }, { dismissed: 1 }); + }, + args: onJourney, + rest: { method: 'post', path: (s) => `/api/journeys/${s.journey.id}/suggestions/restore` }, + expect: [{ event: 'journey:updated', user: 'actor' }], + }, + { + tool: 'delete_journey', + needs: ['journey'], + args: onJourney, + rest: { method: 'delete', path: (s) => `/api/journeys/${s.journey.id}` }, + expect: [{ event: 'journey:deleted', user: 'actor' }], + }, + { + tool: 'add_journey_trip', + needs: ['journey'], + args: (s) => ({ journeyId: s.journey.id, tripId: s.trip.id }), + rest: { + method: 'post', + path: (s) => `/api/journeys/${s.journey.id}/trips`, + body: (s) => ({ trip_id: s.trip.id }), + }, + // The route has never read X-Socket-Id here; the asking tab reloads on its own echo. + expect: [{ event: 'journey:trip:synced', user: 'actor', all: true }], + }, + { + tool: 'remove_journey_trip', + needs: ['journey'], + setup: async (s, app) => { + await linkTripToJourney(ormOf(app), s.journey.id, s.trip.id); + }, + args: (s) => ({ journeyId: s.journey.id, tripId: s.trip.id }), + rest: { method: 'delete', path: (s) => `/api/journeys/${s.journey.id}/trips/${s.trip.id}` }, + expect: [{ event: 'journey:updated', user: 'actor' }], + }, + { + tool: 'create_journey_entry', + needs: ['journey'], + args: (s) => ({ journeyId: s.journey.id, entry_date: DAY, title: 'Arrival' }), + rest: { + method: 'post', + path: (s) => `/api/journeys/${s.journey.id}/entries`, + body: () => ({ entry_date: DAY, title: 'Arrival' }), + }, + expect: [{ event: 'journey:entry:created', user: 'actor' }], + }, + { + tool: 'update_journey_entry', + needs: ['journey'], + args: (s) => ({ entryId: s.journeyEntry.id, title: 'Sunset at the river' }), + rest: { + method: 'patch', + path: (s) => `/api/journeys/entries/${s.journeyEntry.id}`, + body: () => ({ title: 'Sunset at the river' }), + }, + expect: [{ event: 'journey:entry:updated', user: 'actor' }], + }, + { + tool: 'delete_journey_entry', + needs: ['journey'], + args: (s) => ({ entryId: s.journeyEntry.id }), + rest: { method: 'delete', path: (s) => `/api/journeys/entries/${s.journeyEntry.id}` }, + expect: [{ event: 'journey:entry:deleted', user: 'actor' }], + }, + { + tool: 'reorder_journey_entries', + needs: ['journey'], + args: (s) => ({ journeyId: s.journey.id, orderedIds: [s.journeyEntry.id] }), + rest: { + method: 'put', + path: (s) => `/api/journeys/${s.journey.id}/entries/reorder`, + body: (s) => ({ orderedIds: [s.journeyEntry.id] }), + }, + expect: [{ event: 'journey:entries:reordered', user: 'actor' }], + }, + { + tool: 'reorder_journey_entry_photos', + needs: ['journey'], + setup: async (s, app) => { + const photo = await withRequestContext(ormOf(app), () => + app.get(JourneyDomainService).addProviderPhoto(s.journeyEntry.id, s.owner.id, 'immich', `asset-${s.n}`), + ); + if (!photo) throw new Error('the entry took no photo'); + keep(s, 'photo', photo.id); + }, + args: (s) => ({ entryId: s.journeyEntry.id, orderedIds: [kept(s, 'photo')] }), + rest: { + method: 'put', + path: (s) => `/api/journeys/entries/${s.journeyEntry.id}/photos/reorder`, + body: (s) => ({ orderedIds: [kept(s, 'photo')] }), + }, + expect: [{ event: 'journey:entry:updated', user: 'actor' }], + }, + { + tool: 'add_journey_contributor', + needs: ['journey', 'member'], + args: (s) => ({ journeyId: s.journey.id, targetUserId: s.member.id, role: 'viewer' }), + rest: { + method: 'post', + path: (s) => `/api/journeys/${s.journey.id}/contributors`, + body: (s) => ({ user_id: s.member.id, role: 'viewer' }), + }, + // To the owner and the new contributor alike; the contributor routes read no X-Socket-Id. + expect: [{ event: 'journey:contributor:changed', user: 'actor', all: true }], + }, + { + tool: 'update_journey_contributor_role', + needs: ['journey', 'member'], + setup: async (s, app) => { + await addJourneyContributor(ormOf(app), s.journey.id, s.member.id, 'viewer'); + }, + args: (s) => ({ journeyId: s.journey.id, targetUserId: s.member.id, role: 'editor' }), + rest: { + method: 'patch', + path: (s) => `/api/journeys/${s.journey.id}/contributors/${s.member.id}`, + body: () => ({ role: 'editor' }), + }, + expect: [{ event: 'journey:contributor:changed', user: 'actor', all: true }], + }, + { + // The one taken off is told too, from the audience read before the delete. + tool: 'remove_journey_contributor', + needs: ['journey', 'member'], + setup: async (s, app) => { + await addJourneyContributor(ormOf(app), s.journey.id, s.member.id, 'viewer'); + }, + args: (s) => ({ journeyId: s.journey.id, targetUserId: s.member.id }), + rest: { method: 'delete', path: (s) => `/api/journeys/${s.journey.id}/contributors/${s.member.id}` }, + expect: [{ event: 'journey:contributor:changed', user: 'actor', all: true }], + }, + { + // A preference of the writer's own: only their tabs read the journey again. + tool: 'update_journey_preferences', + needs: ['journey'], + args: (s) => ({ journeyId: s.journey.id, hide_skeletons: true }), + rest: { + method: 'patch', + path: (s) => `/api/journeys/${s.journey.id}/preferences`, + body: () => ({ hide_skeletons: true }), + }, + expect: [{ event: 'journey:updated', user: 'actor' }], + }, + { + tool: 'add_journey_provider_photos', + needs: ['journey'], + args: (s) => ({ journeyId: s.journey.id, provider: 'immich', asset_ids: [`asset-${s.n}`] }), + rest: { + method: 'post', + path: (s) => `/api/journeys/${s.journey.id}/gallery/provider-photos`, + body: (s) => ({ provider: 'immich', asset_ids: [`asset-${s.n}`] }), + }, + // The capture times would be asked of the library, detached: it learns nothing here. + stub: (app) => { + const spy = vi.spyOn(app.get(PhotoCaptureBackfillService), 'run').mockResolvedValue(false); + return () => spy.mockRestore(); + }, + settle: true, + expect: [{ event: 'journey:updated', user: 'actor' }], + }, + { + tool: 'link_trip_journal', + needs: ['journey'], + args: (s) => ({ tripId: s.trip.id, journey_id: s.journey.id, members: [] }), + rest: { + method: 'put', + path: (s) => `/api/trips/${s.trip.id}/journal`, + body: (s) => ({ journey_id: s.journey.id, members: [] }), + }, + expect: ['trip:journal:changed'], + }, + { + tool: 'set_trip_journal_members', + needs: ['journey'], + setup: linkJournal, + args: (s) => ({ tripId: s.trip.id, members: [] }), + rest: { method: 'patch', path: (s) => `/api/trips/${s.trip.id}/journal/members`, body: () => ({ members: [] }) }, + expect: ['trip:journal:changed'], + }, + { + tool: 'unlink_trip_journal', + needs: ['journey'], + setup: linkJournal, + args: (s) => ({ tripId: s.trip.id }), + rest: { method: 'delete', path: (s) => `/api/trips/${s.trip.id}/journal` }, + expect: ['trip:journal:changed'], + }, + { + tool: 'take_over_journey_day_stays', + needs: ['journey'], + setup: recordStay, + args: (s) => ({ + journeyId: s.journey.id, + date: DAY, + groups: [{ kind: 'single', stays: [{ suggestionId: kept(s, 'stay') }] }], + }), + rest: { + method: 'post', + path: (s) => `/api/journeys/${s.journey.id}/recordings/${DAY}/stays/import`, + body: (s) => ({ groups: [{ kind: 'single', stays: [{ suggestionId: kept(s, 'stay') }] }] }), + }, + expect: [{ event: 'journey:entry:created', user: 'actor' }], + }, + { + tool: 'save_journey_recorded_day', + needs: ['journey'], + stub: (app) => { + recordedHash = app.get(JourneyRecordedDaysService).hashOf(SEGMENTS, 0); + const spy = vi.spyOn(app.get(DawarichTracksService), 'fetchDay').mockResolvedValue({ + segments: SEGMENTS, + sourceKind: 'tracks', + utcOffsetMinutes: 0, + offsetSource: 'chosen', + sourceOffsetMinutes: null, + window: DAY_WINDOW, + fetchedAt: `${DAY}T23:59:59Z`, + truncated: false, + }); + return () => spy.mockRestore(); + }, + args: (s) => ({ journeyId: s.journey.id, date: DAY, utcOffsetMinutes: 0, expectedHash: recordedHash }), + rest: { + method: 'put', + path: (s) => `/api/journeys/${s.journey.id}/recordings/${DAY}`, + body: () => ({ utcOffsetMinutes: 0, expectedHash: recordedHash }), + }, + expect: [{ event: 'journey:recording:updated', user: 'actor' }], + }, + { + tool: 'delete_journey_recorded_day', + needs: ['journey'], + setup: async (s, app) => { + const day = { + date: DAY, + sourceKind: 'tracks' as const, + utcOffsetMinutes: 0, + window: DAY_WINDOW, + segments: SEGMENTS, + truncated: false, + fetchedAt: `${DAY}T23:59:59Z`, + contentHash: `${'0'.repeat(63)}${s.n % 10}`, + }; + await withRequestContext(ormOf(app), () => + app.get(JourneyRecordedDaysService).keep(s.journey.id, s.owner.id, day, false), + ); + }, + args: (s) => ({ journeyId: s.journey.id, date: DAY }), + rest: { method: 'delete', path: (s) => `/api/journeys/${s.journey.id}/recordings/${DAY}` }, + expect: [{ event: 'journey:recording:updated', user: 'actor' }], + }, +]; + +/** The scene's journey shown in its trip, as the link route writes it, without the event the route sends. */ +async function linkJournal(s: Scene, app: INestApplication): Promise { + await inContext(ormOf(app), (em) => + em.getRepository(TripJournalLinks).upsertLink(s.trip.id, s.journey.id, s.owner.id, Date.now()), + ); +} + +const vacayCases: ParityCase[] = [ + { + tool: 'update_vacay_plan', + needs: ['vacay'], + args: () => ({ block_weekends: false }), + rest: { method: 'put', path: () => `${VACAY}/plan`, body: () => ({ block_weekends: false }) }, + expect: [{ event: 'vacay:settings', user: 'actor' }], + }, + { + tool: 'set_vacay_color', + needs: ['vacay'], + args: () => ({ color: '#22c55e' }), + rest: { method: 'put', path: () => `${VACAY}/color`, body: () => ({ color: '#22c55e' }) }, + expect: [{ event: 'vacay:update', user: 'actor' }], + }, + { + // The invitee hears the invite; the inviter's own tabs read the plan again, + // which now lists it as pending. + tool: 'send_vacay_invite', + needs: ['vacay', 'member'], + stub: quietNotifications, + args: (s) => ({ targetUserId: s.member.id }), + rest: { method: 'post', path: () => `${VACAY}/invite`, body: (s) => ({ user_id: s.member.id }) }, + expect: [ + { event: 'vacay:invite', user: 'others' }, + { event: 'vacay:settings', user: 'actor' }, + ], + }, + { + tool: 'accept_vacay_invite', + actor: 'admin', + needs: ['vacay'], + setup: async (s, app) => { + await addVacayPlanMember(ormOf(app), s.vacay.id, s.admin.id, 'pending'); + }, + args: (s) => ({ planId: s.vacay.id }), + rest: { method: 'post', path: () => `${VACAY}/invite/accept`, body: (s) => ({ plan_id: s.vacay.id }) }, + expect: [{ event: 'vacay:accepted', user: 'actor' }], + }, + { + tool: 'decline_vacay_invite', + actor: 'admin', + needs: ['vacay'], + setup: async (s, app) => { + await addVacayPlanMember(ormOf(app), s.vacay.id, s.admin.id, 'pending'); + }, + args: (s) => ({ planId: s.vacay.id }), + rest: { method: 'post', path: () => `${VACAY}/invite/decline`, body: (s) => ({ plan_id: s.vacay.id }) }, + // The plan's owner and the one declining, whose tabs still showed the invite (G34). + expect: [{ event: 'vacay:declined', user: 'actor' }], + }, + { + tool: 'cancel_vacay_invite', + needs: ['vacay'], + setup: async (s, app) => { + await addVacayPlanMember(ormOf(app), s.vacay.id, s.admin.id, 'pending'); + }, + args: (s) => ({ targetUserId: s.admin.id }), + rest: { method: 'post', path: () => `${VACAY}/invite/cancel`, body: (s) => ({ user_id: s.admin.id }) }, + // The invitee and the plan's own tabs, which listed the invite as pending (G34). + expect: [{ event: 'vacay:cancelled', user: 'actor' }], + }, + { + tool: 'dissolve_vacay_plan', + needs: ['vacay'], + setup: async (s, app) => { + await addVacayPlanMember(ormOf(app), s.vacay.id, s.admin.id); + }, + args: () => ({}), + rest: { method: 'post', path: () => `${VACAY}/dissolve` }, + // Everyone on the plan, the one dissolving it now included (G34). + expect: [{ event: 'vacay:dissolved', user: 'actor' }], + }, + { + tool: 'add_vacay_year', + needs: ['vacay'], + args: () => ({ year: 2032 }), + rest: { method: 'post', path: () => `${VACAY}/years`, body: () => ({ year: 2032 }) }, + expect: [{ event: 'vacay:settings', user: 'actor' }], + }, + { + tool: 'delete_vacay_year', + needs: ['vacay'], + setup: async (s, app) => { + await createRow(ormOf(app), VacayYears, { plan: s.vacay.id, year: 2031 }); + }, + args: () => ({ year: 2031 }), + rest: { method: 'delete', path: () => `${VACAY}/years/2031` }, + expect: [{ event: 'vacay:settings', user: 'actor' }], + }, + { + tool: 'toggle_vacay_entry', + needs: ['vacay'], + args: () => ({ date: '2027-06-01' }), + rest: { method: 'post', path: () => `${VACAY}/entries/toggle`, body: () => ({ date: '2027-06-01' }) }, + expect: [{ event: 'vacay:update', user: 'actor' }], + }, + { + tool: 'toggle_company_holiday', + needs: ['vacay'], + args: () => ({ date: '2027-06-02' }), + rest: { method: 'post', path: () => `${VACAY}/entries/company-holiday`, body: () => ({ date: '2027-06-02' }) }, + expect: [{ event: 'vacay:update', user: 'actor' }], + }, + { + tool: 'update_vacay_stats', + needs: ['vacay'], + args: () => ({ year: 2027, vacationDays: 25 }), + rest: { method: 'put', path: () => `${VACAY}/stats/2027`, body: () => ({ vacation_days: 25 }) }, + expect: [{ event: 'vacay:update', user: 'actor' }], + }, + { + tool: 'add_holiday_calendar', + needs: ['vacay'], + args: () => ({ region: 'DE' }), + rest: { method: 'post', path: () => `${VACAY}/plan/holiday-calendars`, body: () => ({ region: 'DE' }) }, + expect: [{ event: 'vacay:settings', user: 'actor' }], + }, + { + tool: 'update_holiday_calendar', + needs: ['vacay'], + setup: holidayCalendar, + args: (s) => ({ calendarId: kept(s, 'calendar'), label: 'Bavaria' }), + rest: { + method: 'put', + path: (s) => `${VACAY}/plan/holiday-calendars/${kept(s, 'calendar')}`, + body: () => ({ label: 'Bavaria' }), + }, + expect: [{ event: 'vacay:settings', user: 'actor' }], + }, + { + tool: 'delete_holiday_calendar', + needs: ['vacay'], + setup: holidayCalendar, + args: (s) => ({ calendarId: kept(s, 'calendar') }), + rest: { method: 'delete', path: (s) => `${VACAY}/plan/holiday-calendars/${kept(s, 'calendar')}` }, + expect: [{ event: 'vacay:settings', user: 'actor' }], + }, + { + tool: 'share_vacay_calendar', + needs: ['vacay', 'member'], + stub: quietNotifications, + args: (s) => ({ targetUserId: s.member.id }), + rest: { method: 'post', path: () => `${VACAY}/shares`, body: (s) => ({ user_id: s.member.id }) }, + // The viewer, then the owner's other tabs, which list the share. + expect: [{ event: 'vacay:share', user: 'actor' }], + }, + { + tool: 'unshare_vacay_calendar', + needs: ['vacay', 'member'], + setup: async (s, app) => { + const share = await createRow(ormOf(app), VacayShares, { owner: s.owner.id, user: s.member.id }); + keep(s, 'share', share.id); + }, + args: (s) => ({ shareId: kept(s, 'share') }), + rest: { method: 'delete', path: (s) => `${VACAY}/shares/${kept(s, 'share')}` }, + expect: [{ event: 'vacay:share-removed', user: 'actor' }], + }, +]; + +/** A public holiday calendar on the owner's plan. */ +async function holidayCalendar(s: Scene, app: INestApplication): Promise { + const calendar = await createRow(ormOf(app), VacayHolidayCalendars, { plan: s.vacay.id, region: 'DE' }); + keep(s, 'calendar', calendar.id); +} + +/** A label of the owner's collection, written as a row, so the setup sends nothing. */ +async function collectionLabel(s: Scene, app: INestApplication): Promise { + const label = await createRow(ormOf(app), CollectionLabels, { collection: s.collection.id, name: `Rooftops ${s.n}` }); + keep(s, 'label', label.id); +} + +const collectionCases: ParityCase[] = [ + { + // The owner's other tabs list the new collection (G33). + tool: 'create_collection', + args: (s) => ({ name: `Lisbon bakeries ${s.n}` }), + rest: { method: 'post', path: () => COLLECTIONS, body: (s) => ({ name: `Lisbon bakeries ${s.n}` }) }, + expect: [{ event: 'collections:updated', user: 'actor' }], + }, + { + tool: 'update_collection', + needs: ['collection'], + args: (s) => ({ collectionId: s.collection.id, name: `Porto ${s.n}` }), + rest: { + method: 'patch', + path: (s) => `${COLLECTIONS}/${s.collection.id}`, + body: (s) => ({ name: `Porto ${s.n}` }), + }, + expect: [{ event: 'collections:updated', user: 'actor' }], + }, + { + // The owner's own tabs too, not only the members' (G33). + tool: 'delete_collection', + needs: ['collection'], + args: (s) => ({ collectionId: s.collection.id }), + rest: { method: 'delete', path: (s) => `${COLLECTIONS}/${s.collection.id}` }, + expect: [{ event: 'collections:deleted', user: 'actor' }], + }, + { + tool: 'reorder_collections', + needs: ['collection'], + args: (s) => ({ orderedIds: [s.collection.id] }), + rest: { method: 'post', path: () => `${COLLECTIONS}/reorder`, body: (s) => ({ orderedIds: [s.collection.id] }) }, + expect: [{ event: 'collections:updated', user: 'actor' }], + }, + { + tool: 'save_place_to_collection', + needs: ['collection'], + args: (s) => ({ collection_id: s.collection.id, name: `Pont Neuf ${s.n}` }), + rest: { + method: 'post', + path: () => `${COLLECTIONS}/places`, + body: (s) => ({ collection_id: s.collection.id, name: `Pont Neuf ${s.n}` }), + }, + expect: [{ event: 'collections:updated', user: 'actor' }], + }, + { + // place2, not place: the saved place already sits where `place` is, and a duplicate is skipped silently. + tool: 'save_trip_places_to_collection', + needs: ['collection', 'place2'], + args: (s) => ({ collectionId: s.collection.id, tripId: s.trip.id, placeIds: [s.place2.id] }), + rest: { + method: 'post', + path: () => `${COLLECTIONS}/places/from-trip-many`, + body: (s) => ({ collection_id: s.collection.id, source_trip_id: s.trip.id, source_place_ids: [s.place2.id] }), + }, + expect: [{ event: 'collections:updated', user: 'actor' }], + }, + { + tool: 'update_collection_place', + needs: ['collection'], + args: (s) => ({ placeId: s.collectionPlace.id, name: `Saint-Chapelle ${s.n}` }), + rest: { + method: 'patch', + path: (s) => `${COLLECTIONS}/places/${s.collectionPlace.id}`, + body: (s) => ({ name: `Saint-Chapelle ${s.n}` }), + }, + expect: [{ event: 'collections:updated', user: 'actor' }], + }, + { + tool: 'set_collection_place_status', + needs: ['collection'], + args: (s) => ({ placeId: s.collectionPlace.id, status: 'visited' }), + rest: { + method: 'post', + path: (s) => `${COLLECTIONS}/places/${s.collectionPlace.id}/status`, + body: () => ({ status: 'visited' }), + }, + expect: [{ event: 'collections:updated', user: 'actor' }], + }, + { + // The saved place and the trip's `place` stand on the same spot, which is the match. + tool: 'set_collection_place_status_from_trip', + needs: ['collection', 'place'], + args: (s) => ({ trip_id: s.trip.id, place_ids: [s.place.id], status: 'visited' }), + rest: { + method: 'post', + path: () => `${COLLECTIONS}/places/status-from-trip`, + body: (s) => ({ trip_id: s.trip.id, place_ids: [s.place.id], status: 'visited' }), + }, + expect: [{ event: 'collections:updated', user: 'actor' }], + }, + { + tool: 'rate_collection_place', + needs: ['collection'], + args: (s) => ({ placeId: s.collectionPlace.id, rating: 4 }), + rest: { + method: 'put', + path: (s) => `${COLLECTIONS}/places/${s.collectionPlace.id}/rating`, + body: () => ({ rating: 4 }), + }, + expect: [{ event: 'collections:updated', user: 'actor' }], + }, + { + tool: 'delete_collection_place', + needs: ['collection'], + args: (s) => ({ placeId: s.collectionPlace.id }), + rest: { method: 'delete', path: (s) => `${COLLECTIONS}/places/${s.collectionPlace.id}` }, + expect: [{ event: 'collections:updated', user: 'actor' }], + }, + { + tool: 'create_collection_label', + needs: ['collection'], + args: (s) => ({ collection_id: s.collection.id, name: 'Rooftops' }), + rest: { + method: 'post', + path: () => `${COLLECTIONS}/labels`, + body: (s) => ({ collection_id: s.collection.id, name: 'Rooftops' }), + }, + expect: [{ event: 'collections:updated', user: 'actor' }], + }, + { + tool: 'update_collection_label', + needs: ['collection'], + setup: collectionLabel, + args: (s) => ({ labelId: kept(s, 'label'), name: 'Viewpoints' }), + rest: { + method: 'patch', + path: (s) => `${COLLECTIONS}/labels/${kept(s, 'label')}`, + body: () => ({ name: 'Viewpoints' }), + }, + expect: [{ event: 'collections:updated', user: 'actor' }], + }, + { + tool: 'delete_collection_label', + needs: ['collection'], + setup: collectionLabel, + args: (s) => ({ labelId: kept(s, 'label') }), + rest: { method: 'delete', path: (s) => `${COLLECTIONS}/labels/${kept(s, 'label')}` }, + expect: [{ event: 'collections:updated', user: 'actor' }], + }, + { + tool: 'assign_collection_labels', + needs: ['collection'], + setup: collectionLabel, + args: (s) => ({ label_ids: [kept(s, 'label')], place_ids: [s.collectionPlace.id] }), + rest: { + method: 'post', + path: () => `${COLLECTIONS}/labels/assign`, + body: (s) => ({ label_ids: [kept(s, 'label')], place_ids: [s.collectionPlace.id] }), + }, + expect: [{ event: 'collections:updated', user: 'actor' }], + }, + { + // The invitee hears the invite; the owner's tabs list it as pending. + tool: 'invite_to_collection', + needs: ['collection', 'member'], + stub: quietNotifications, + args: (s) => ({ collection_id: s.collection.id, user_id: s.member.id, role: 'editor' }), + rest: { + method: 'post', + path: () => `${COLLECTIONS}/invite`, + body: (s) => ({ collection_id: s.collection.id, user_id: s.member.id, role: 'editor' }), + }, + expect: [ + { event: 'collections:invite', user: 'others' }, + { event: 'collections:updated', user: 'actor' }, + ], + }, + { + tool: 'set_collection_member_role', + needs: ['collection', 'member'], + setup: async (s, app) => { + await addCollectionMember(ormOf(app), s.collection.id, s.member.id); + }, + args: (s) => ({ collectionId: s.collection.id, userId: s.member.id, role: 'viewer' }), + rest: { + method: 'post', + path: () => `${COLLECTIONS}/members/role`, + body: (s) => ({ collection_id: s.collection.id, user_id: s.member.id, role: 'viewer' }), + }, + // Everyone on the list, then the member once more to re-gate their view; the route reads no X-Socket-Id. + expect: [ + { event: 'collections:updated', user: 'actor', all: true }, + { event: 'collections:updated', user: 'others', all: true }, + ], + }, + { + tool: 'remove_collection_member', + needs: ['collection', 'member'], + setup: async (s, app) => { + await addCollectionMember(ormOf(app), s.collection.id, s.member.id); + }, + args: (s) => ({ collectionId: s.collection.id, userId: s.member.id }), + rest: { + method: 'post', + path: () => `${COLLECTIONS}/members/remove`, + body: (s) => ({ collection_id: s.collection.id, user_id: s.member.id }), + }, + expect: [ + { event: 'collections:left', user: 'actor', all: true }, + { event: 'collections:removed', user: 'others', all: true }, + ], + }, + { + tool: 'cancel_collection_invite', + needs: ['collection', 'member'], + setup: async (s, app) => { + await addCollectionMember(ormOf(app), s.collection.id, s.member.id, { status: 'pending' }); + }, + args: (s) => ({ collectionId: s.collection.id, userId: s.member.id }), + rest: { + method: 'post', + path: () => `${COLLECTIONS}/invite/cancel`, + body: (s) => ({ collection_id: s.collection.id, user_id: s.member.id }), + }, + // The invitee, then the owner's tabs, which listed the invite as pending (G33). + expect: [ + { event: 'collections:cancelled', user: 'others' }, + { event: 'collections:updated', user: 'actor' }, + ], + }, + { + tool: 'accept_collection_invite', + actor: 'admin', + needs: ['collection'], + setup: async (s, app) => { + await addCollectionMember(ormOf(app), s.collection.id, s.admin.id, { status: 'pending' }); + }, + args: (s) => ({ collectionId: s.collection.id }), + rest: { + method: 'post', + path: () => `${COLLECTIONS}/invite/accept`, + body: (s) => ({ collection_id: s.collection.id }), + }, + expect: [{ event: 'collections:accepted', user: 'actor' }], + }, + { + tool: 'decline_collection_invite', + actor: 'admin', + needs: ['collection'], + setup: async (s, app) => { + await addCollectionMember(ormOf(app), s.collection.id, s.admin.id, { status: 'pending' }); + }, + args: (s) => ({ collectionId: s.collection.id }), + rest: { + method: 'post', + path: () => `${COLLECTIONS}/invite/decline`, + body: (s) => ({ collection_id: s.collection.id }), + }, + // The owner and the one declining, whose tabs still showed the invite (G33). + expect: [{ event: 'collections:declined', user: 'actor' }], + }, + { + tool: 'leave_collection', + actor: 'admin', + needs: ['collection'], + setup: async (s, app) => { + await addCollectionMember(ormOf(app), s.collection.id, s.admin.id); + }, + args: (s) => ({ collectionId: s.collection.id }), + rest: { method: 'post', path: () => `${COLLECTIONS}/leave`, body: (s) => ({ collection_id: s.collection.id }) }, + // Those staying, then the one leaving, whose tabs leave the list as a removed member's do (G33). + expect: [ + { event: 'collections:left', user: 'others' }, + { event: 'collections:removed', user: 'actor' }, + ], + }, +]; + export const user: CaseGroup = { name: 'user', prefix: 'BCP-USER', - cases: [], + cases: [...atlasCases, ...dawarichCases, ...journeyCases, ...vacayCases, ...collectionCases], exempt: [ { tool: 'create_journey_share_link', reason: TOKEN_ONLY, needs: ['journey'], args: onJourney }, { @@ -20,6 +958,13 @@ export const user: CaseGroup = { args: onJourney, first: { tool: 'create_journey_share_link', args: onJourney }, }, + { + tool: 'dismiss_dawarich_suggestion', + reason: + "takes a stay out of the caller's own Dawarich review list, for which the registry has no event; the journey day review shows the stay as skipped too, but both read their rows when they open, so only a review left open shows the old state until its next read", + setup: recordStay, + args: (s) => ({ suggestionId: kept(s, 'stay') }), + }, { tool: 'create_manual_school_holiday_country', reason: HOLIDAY_CATALOG, @@ -60,81 +1005,4 @@ export const user: CaseGroup = { args: (s) => ({ code: s.holidayCountry.code }), }, ], - pending: [ - 'create_bucket_list_item', - 'delete_bucket_list_item', - 'mark_country_visited', - 'unmark_country_visited', - 'mark_region_visited', - 'unmark_region_visited', - 'update_bucket_list_item', - 'create_collection', - 'update_collection', - 'delete_collection', - 'reorder_collections', - 'save_place_to_collection', - 'save_trip_places_to_collection', - 'update_collection_place', - 'set_collection_place_status', - 'set_collection_place_status_from_trip', - 'rate_collection_place', - 'delete_collection_place', - 'create_collection_label', - 'update_collection_label', - 'delete_collection_label', - 'assign_collection_labels', - 'invite_to_collection', - 'set_collection_member_role', - 'remove_collection_member', - 'cancel_collection_invite', - 'accept_collection_invite', - 'decline_collection_invite', - 'leave_collection', - 'accept_dawarich_suggestion_as_place', - 'accept_dawarich_suggestion_as_journal_entry', - 'mark_bucket_list_item_visited_from_dawarich', - 'dismiss_dawarich_suggestion', - 'confirm_bucket_list_visits', - 'clear_bucket_list_visit', - 'accept_atlas_countries', - 'create_journey', - 'update_journey', - 'restore_journey_suggestions', - 'delete_journey', - 'add_journey_trip', - 'remove_journey_trip', - 'create_journey_entry', - 'update_journey_entry', - 'delete_journey_entry', - 'reorder_journey_entries', - 'reorder_journey_entry_photos', - 'add_journey_contributor', - 'update_journey_contributor_role', - 'remove_journey_contributor', - 'update_journey_preferences', - 'add_journey_provider_photos', - 'link_trip_journal', - 'set_trip_journal_members', - 'unlink_trip_journal', - 'take_over_journey_day_stays', - 'save_journey_recorded_day', - 'delete_journey_recorded_day', - 'update_vacay_plan', - 'set_vacay_color', - 'send_vacay_invite', - 'accept_vacay_invite', - 'decline_vacay_invite', - 'cancel_vacay_invite', - 'dissolve_vacay_plan', - 'add_vacay_year', - 'delete_vacay_year', - 'toggle_vacay_entry', - 'toggle_company_holiday', - 'update_vacay_stats', - 'add_holiday_calendar', - 'update_holiday_calendar', - 'delete_holiday_calendar', - 'share_vacay_calendar', - 'unshare_vacay_calendar', - ], }; diff --git a/server/tests/unit/mcp/tools-collections.test.ts b/server/tests/unit/mcp/tools-collections.test.ts index 47767ddc9..98f94e18f 100644 --- a/server/tests/unit/mcp/tools-collections.test.ts +++ b/server/tests/unit/mcp/tools-collections.test.ts @@ -647,6 +647,27 @@ describe('Tool: copy_collection_places_to_trip', () => { }); }); + it('MCP-COLL-COPY-001: each copied place reaches every tab of the trip as place:created, the asking one included', async () => { + const { user } = createUser(testDb); + createCategory(testDb); + const trip = createTrip(testDb, user.id); + const col = await seedCollection(user.id, 'Plan'); + const pid = await seedPlace(col, user.id, 'Spanish Steps'); + realtime.broadcastMock.mockClear(); + await withHarness(user.id, async (h) => { + await h.client.callTool({ + name: 'copy_collection_places_to_trip', + arguments: { trip_id: trip.id, place_ids: [pid] }, + }); + }); + const sent = realtime.broadcastMock.mock.calls.filter(([, event]) => event === 'place:created'); + expect(sent).toHaveLength(1); + // Room-wide in three arguments: no socket is skipped. + expect(sent[0]).toHaveLength(3); + expect(sent[0][0]).toBe(trip.id); + expect(sent[0][2]).toMatchObject({ place: { name: 'Spanish Steps', trip_id: trip.id }, _source: 'mcp' }); + }); + it('surfaces the 404 text for a trip the user cannot access', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); @@ -654,6 +675,7 @@ describe('Tool: copy_collection_places_to_trip', () => { const trip = createTrip(testDb, other.id); const col = await seedCollection(user.id, 'C'); const pid = await seedPlace(col, user.id, 'X'); + realtime.broadcastMock.mockClear(); await withHarness(user.id, async (h) => { const result = await h.client.callTool({ name: 'copy_collection_places_to_trip', @@ -662,6 +684,8 @@ describe('Tool: copy_collection_places_to_trip', () => { expect(result.isError).toBe(true); expect(errorText(result)).toBe('Trip not found'); }); + // A refused copy reaches nobody. + expect(realtime.broadcastMock.mock.calls.filter(([, event]) => event === 'place:created')).toHaveLength(0); }); }); diff --git a/server/tests/unit/nest/atlas-writes.service.test.ts b/server/tests/unit/nest/atlas-writes.service.test.ts new file mode 100644 index 000000000..7afffa354 --- /dev/null +++ b/server/tests/unit/nest/atlas-writes.service.test.ts @@ -0,0 +1,91 @@ +/** + * AtlasWritesService: every atlas write the REST routes and the MCP tools + * make goes through AtlasService, then tells the writer's tabs that their + * atlas changed. The tab that asked over REST is left out; an MCP call, which + * has no socket, reaches them all, the assistant's own tab included. + */ +import { AtlasWritesService } from '../../../src/nest/atlas/atlas-writes.service'; +import { AtlasService } from '../../../src/nest/atlas/atlas.service'; +import { createSnapshotTestDb } from '../../helpers/db-mock'; +import { createTestModule, type TestModule } from '../../helpers/test-module'; + +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const atlas = { + markCountry: vi.fn(async () => true), + unmarkCountry: vi.fn(async () => undefined), + markRegion: vi.fn(async () => undefined), + unmarkRegion: vi.fn(async () => undefined), + createBucketItem: vi.fn(async () => ({ id: 4, name: 'Kyoto' })), + updateBucketItem: vi.fn(async (): Promise<{ id: number } | null> => ({ id: 4 })), + deleteBucketItem: vi.fn(async () => true), +}; + +const testDb = createSnapshotTestDb(); +let t: TestModule; +let writes: AtlasWritesService; + +beforeAll(async () => { + t = await createTestModule({ + db: testDb, + imports: [], + providers: [AtlasWritesService, { provide: AtlasService, useValue: atlas }], + }); + writes = t.get(AtlasWritesService); +}); + +afterAll(async () => { + await t.close(); + testDb.close(); +}); + +beforeEach(() => { + vi.clearAllMocks(); + t.realtime.broadcastToUserMock.mockClear(); +}); + +const changed = (userId: number, socketId?: string) => [userId, { type: 'atlas:changed' }, socketId]; + +describe('AtlasWritesService', () => { + it('ATLAS-WRITES-001: each write lands first, then the user hears atlas:changed without the asking tab', async () => { + await writes.markCountry(7, 'PT', 'sock-1'); + await writes.unmarkCountry(7, 'PT', 'sock-1'); + await writes.markRegion(7, 'PT-11', 'Lisboa', 'PT', 'sock-1'); + await writes.unmarkRegion(7, 'PT-11', 'sock-1'); + expect(await writes.createBucketItem(7, { name: 'Kyoto' }, 'sock-1')).toEqual({ id: 4, name: 'Kyoto' }); + expect(await writes.updateBucketItem(7, 4, { notes: 'spring' }, 'sock-1')).toEqual({ id: 4 }); + expect(await writes.deleteBucketItem(7, 4, 'sock-1')).toBe(true); + + expect(atlas.markCountry).toHaveBeenCalledWith(7, 'PT'); + expect(atlas.unmarkCountry).toHaveBeenCalledWith(7, 'PT'); + expect(atlas.markRegion).toHaveBeenCalledWith(7, 'PT-11', 'Lisboa', 'PT'); + expect(atlas.unmarkRegion).toHaveBeenCalledWith(7, 'PT-11'); + expect(atlas.createBucketItem).toHaveBeenCalledWith(7, { name: 'Kyoto' }); + expect(atlas.updateBucketItem).toHaveBeenCalledWith(7, 4, { notes: 'spring' }); + expect(atlas.deleteBucketItem).toHaveBeenCalledWith(7, 4); + expect(t.realtime.broadcastToUserMock.mock.calls).toEqual(Array.from({ length: 7 }, () => changed(7, 'sock-1'))); + }); + + it('ATLAS-WRITES-002: an update or delete that found no item says nothing', async () => { + atlas.updateBucketItem.mockResolvedValueOnce(null); + atlas.deleteBucketItem.mockResolvedValueOnce(false); + + expect(await writes.updateBucketItem(7, 99, { name: 'Gone' })).toBeNull(); + expect(await writes.deleteBucketItem(7, 99)).toBe(false); + expect(t.realtime.broadcastToUserMock).not.toHaveBeenCalled(); + }); + + it('ATLAS-WRITES-003: without a socket every tab hears it, and a write that throws sends nothing', async () => { + await writes.markCountry(7, 'FR'); + expect(t.realtime.broadcastToUserMock).toHaveBeenCalledWith(...changed(7, undefined)); + + t.realtime.broadcastToUserMock.mockClear(); + atlas.createBucketItem.mockRejectedValueOnce(new Error('duplicate')); + await expect(writes.createBucketItem(7, { name: 'Kyoto' })).rejects.toThrow('duplicate'); + expect(t.realtime.broadcastToUserMock).not.toHaveBeenCalled(); + + // The Dawarich confirmations announce through the same call after their own commit. + writes.announce(9, 'sock-2'); + expect(t.realtime.broadcastToUserMock).toHaveBeenCalledWith(...changed(9, 'sock-2')); + }); +}); diff --git a/server/tests/unit/nest/atlas.controller.test.ts b/server/tests/unit/nest/atlas.controller.test.ts index 2ef5ac2d3..34fee5412 100644 --- a/server/tests/unit/nest/atlas.controller.test.ts +++ b/server/tests/unit/nest/atlas.controller.test.ts @@ -1,3 +1,4 @@ +import type { AtlasWritesService } from '../../../src/nest/atlas/atlas-writes.service'; import { AtlasController } from '../../../src/nest/atlas/atlas.controller'; import { BucketItemExistsError } from '../../../src/nest/atlas/atlas.service'; import type { AtlasService } from '../../../src/nest/atlas/atlas.service'; @@ -11,8 +12,9 @@ import { describe, it, expect, vi } from 'vitest'; const user = { id: 8 } as User; -function makeController(svc: Partial) { - return new AtlasController(svc as AtlasService); +/** The reads come from AtlasService, the writes from AtlasWritesService; one stub stands in for both. */ +function makeController(svc: Partial & Partial) { + return new AtlasController(svc as unknown as AtlasService, svc as unknown as AtlasWritesService); } function makeRes() { @@ -89,13 +91,34 @@ describe('AtlasController (parity with the legacy /api/addons/atlas route)', () it('POST mark returns success and upper-cases', async () => { const markCountry = vi.fn(); expect(await makeController({ markCountry }).markCountry(user, 'de')).toEqual({ success: true }); - expect(markCountry).toHaveBeenCalledWith(8, 'DE'); + expect(markCountry).toHaveBeenCalledWith(8, 'DE', undefined); }); it('DELETE mark returns success', async () => { const unmarkCountry = vi.fn(); expect(await makeController({ unmarkCountry }).unmarkCountry(user, 'FR')).toEqual({ success: true }); }); + + it('ATLAS-CTRL-001: every write hands on X-Socket-Id, so the tab that asked is left out of atlas:changed', async () => { + const writes = { + markCountry: vi.fn(), + unmarkCountry: vi.fn(), + markRegion: vi.fn(), + unmarkRegion: vi.fn(), + createBucketItem: vi.fn().mockReturnValue({ id: 1 }), + updateBucketItem: vi.fn().mockReturnValue({ id: 1 }), + deleteBucketItem: vi.fn().mockReturnValue(true), + }; + const c = makeController(writes); + await c.markCountry(user, 'de', 'sock-1'); + await c.unmarkCountry(user, 'de', 'sock-1'); + await c.markRegion(user, 'by', { name: 'Bavaria', country_code: 'de' }, 'sock-1'); + await c.unmarkRegion(user, 'by', 'sock-1'); + await c.createBucketItem(user, { name: 'Tokyo' }, 'sock-1'); + await c.updateBucketItem(user, '1', { name: 'Kyoto' }, 'sock-1'); + await c.deleteBucketItem(user, '1', 'sock-1'); + for (const write of Object.values(writes)) expect(write.mock.calls[0]?.at(-1)).toBe('sock-1'); + }); }); describe('region', () => { @@ -108,7 +131,7 @@ describe('AtlasController (parity with the legacy /api/addons/atlas route)', () expect( await makeController({ markRegion }).markRegion(user, 'by', { name: 'Bavaria', country_code: 'de' }), ).toEqual({ success: true }); - expect(markRegion).toHaveBeenCalledWith(8, 'BY', 'Bavaria', 'DE'); + expect(markRegion).toHaveBeenCalledWith(8, 'BY', 'Bavaria', 'DE', undefined); }); }); @@ -130,14 +153,18 @@ describe('AtlasController (parity with the legacy /api/addons/atlas route)', () expect( await makeController({ createBucketItem }).createBucketItem(user, { name: 'Tokyo', lat: 35, lng: 139 }), ).toEqual({ item: { id: 1, name: 'Tokyo' } }); - expect(createBucketItem).toHaveBeenCalledWith(8, { - name: 'Tokyo', - lat: 35, - lng: 139, - country_code: undefined, - notes: undefined, - target_date: undefined, - }); + expect(createBucketItem).toHaveBeenCalledWith( + 8, + { + name: 'Tokyo', + lat: 35, + lng: 139, + country_code: undefined, + notes: undefined, + target_date: undefined, + }, + undefined, + ); }); it('forwards a wished-for region, as the MCP tool does (#1901)', async () => { @@ -150,6 +177,7 @@ describe('AtlasController (parity with the legacy /api/addons/atlas route)', () expect(createBucketItem).toHaveBeenCalledWith( 8, expect.objectContaining({ country_code: 'DE', region_code: 'DE-BY' }), + undefined, ); }); diff --git a/server/tests/unit/nest/collections.actor-echo.test.ts b/server/tests/unit/nest/collections.actor-echo.test.ts new file mode 100644 index 000000000..73aab6f3d --- /dev/null +++ b/server/tests/unit/nest/collections.actor-echo.test.ts @@ -0,0 +1,168 @@ +/** + * CollectionsService: the writes a user makes to their own lists reach every + * tab of theirs, the assistant's included (G33). A REST call leaves out only the + * tab that asked, which updates from its own answer; an MCP call has no socket, + * so all of them hear it. + */ +import { CollectionsModule } from '../../../src/nest/collections/collections.module'; +import { CollectionsService } from '../../../src/nest/collections/collections.service'; +import { NotificationsService } from '../../../src/nest/notifications/notifications.service'; +import { createSnapshotTestDb } from '../../helpers/db-mock'; +import { makeUser } from '../../helpers/factories/users'; +import { resetTestDb } from '../../helpers/test-db'; +import { createTestModule, type TestModule } from '../../helpers/test-module'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; + +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const db = createSnapshotTestDb(); +let t: TestModule; +let orm: TestOrm; +let svc: CollectionsService; + +beforeAll(async () => { + t = await createTestModule({ + db, + imports: [CollectionsModule], + // The invite's in-app notification is the notifications domain's; only the realtime echo is under test here. + overrides: [ + { provide: NotificationsService, useValue: { send: vi.fn(async () => ({ attempted: 0, delivered: 0 })) } }, + ], + }); + svc = t.get(CollectionsService); + orm = await createTestOrm(db); +}); +beforeEach(() => { + resetTestDb(db); + orm.clear(); + t.realtime.reset(); +}); +afterAll(async () => { + await orm.close(); + await t.close(); + db.close(); +}); + +/** What each user was sent, as [user, event type, left-out socket]. */ +const sent = () => + t.realtime.broadcastToUserMock.mock.calls.map(([to, msg, sid]) => [to, (msg as { type: string }).type, sid]); + +describe('CollectionsService: the actor hears their own writes', () => { + it("COLLECTIONS-SVC-217: every invite step reaches the actor's own tabs too, leaving out only the one that asked (G33)", async () => { + const { user: owner } = await makeUser(orm, { username: 'owner-217' }); + const { user: target } = await makeUser(orm, { username: 'target-217' }); + const col = await svc.createCollection(owner.id, { name: 'Fusion' }); + + t.realtime.broadcastToUserMock.mockClear(); + await svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id, 'editor', 'sock-o'); + expect(sent()).toEqual([ + [target.id, 'collections:invite', undefined], + [owner.id, 'collections:updated', 'sock-o'], + ]); + + t.realtime.broadcastToUserMock.mockClear(); + await svc.cancelInvite(col.id, owner.id, target.id, 'sock-o'); + expect(sent()).toEqual([ + [target.id, 'collections:cancelled', undefined], + [owner.id, 'collections:updated', 'sock-o'], + ]); + + await svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); + t.realtime.broadcastToUserMock.mockClear(); + await svc.declineInvite(target.id, col.id, 'sock-t'); + expect(sent()).toEqual([ + [owner.id, 'collections:declined', 'sock-t'], + [target.id, 'collections:declined', 'sock-t'], + ]); + + await svc.sendInvite(col.id, owner.id, owner.username, owner.email, target.id); + await svc.acceptInvite(target.id, col.id, undefined); + t.realtime.broadcastToUserMock.mockClear(); + await svc.leaveCollection(target.id, col.id, 'sock-t'); + expect(sent()).toEqual([ + [owner.id, 'collections:left', 'sock-t'], + [target.id, 'collections:removed', 'sock-t'], + ]); + }); + + it("COLLECTIONS-SVC-218: a new list and a new order reach the owner's other tabs; an order of nothing visible says nothing", async () => { + const { user: a } = await makeUser(orm, { username: 'owner-218' }); + const col = await svc.createCollection(a.id, { name: 'Fresh' }, 'sock-a'); + expect(t.realtime.broadcastToUserMock).toHaveBeenCalledWith( + a.id, + { type: 'collections:updated', collectionId: col.id }, + 'sock-a', + ); + + t.realtime.broadcastToUserMock.mockClear(); + await svc.reorderCollections(a.id, [col.id], 'sock-a'); + expect(t.realtime.broadcastToUserMock.mock.calls).toEqual([ + [a.id, { type: 'collections:updated', collectionId: col.id }, 'sock-a'], + ]); + + t.realtime.broadcastToUserMock.mockClear(); + await svc.reorderCollections(a.id, [999_999], 'sock-a'); + expect(t.realtime.broadcastToUserMock).not.toHaveBeenCalled(); + }); + + it('COLLECTIONS-SVC-219: an MCP call has no socket, so every tab of the actor hears the delete, the owner included', async () => { + const { user: owner } = await makeUser(orm, { username: 'owner-219' }); + const col = await svc.createCollection(owner.id, { name: 'Gone soon' }); + + t.realtime.broadcastToUserMock.mockClear(); + await svc.deleteCollection(owner.id, col.id); + expect(sent()).toEqual([[owner.id, 'collections:deleted', undefined]]); + }); + + it('COLLECTIONS-SVC-230: an imported list is announced once, after its transaction has committed', async () => { + const { user: owner } = await makeUser(orm, { username: 'owner-230' }); + const openTx: boolean[] = []; + t.realtime.broadcastToUserMock.mockImplementation(() => { + openTx.push(db.inTransaction); + }); + + try { + const result = await svc.importCollection( + owner.id, + { + file: { + format: 'trek.collection', + version: 1, + name: 'Announced', + places: [{ name: 'One' }, { name: 'Two' }], + }, + }, + 'sock-o', + ); + const created = result.collection as { id: number }; + expect(result.imported).toBe(2); + expect(t.realtime.broadcastToUserMock.mock.calls).toEqual([ + [owner.id, { type: 'collections:updated', collectionId: created.id }, 'sock-o'], + ]); + } finally { + t.realtime.broadcastToUserMock.mockImplementation(() => undefined); + } + // The places were written inside the import's one transaction, so none was open any more. + expect(openTx).toEqual([false]); + }); + + it('COLLECTIONS-SVC-231: an import that rolls back announces nothing', async () => { + const { user: owner } = await makeUser(orm, { username: 'owner-231' }); + const prepare = db.prepare.bind(db); + const spy = vi.spyOn(db, 'prepare').mockImplementation((sql: string) => { + if (/insert\s+into\s+"?collection_places"?/i.test(sql)) throw new Error('disk is full'); + return prepare(sql); + }); + + try { + await expect( + svc.importCollection(owner.id, { + file: { format: 'trek.collection', version: 1, name: 'Doomed', places: [{ name: 'Never arrives' }] }, + }), + ).rejects.toThrow('disk is full'); + } finally { + spy.mockRestore(); + } + expect(t.realtime.broadcastToUserMock).not.toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/nest/collections.controller.test.ts b/server/tests/unit/nest/collections.controller.test.ts index 820e0d73f..3cb3016ce 100644 --- a/server/tests/unit/nest/collections.controller.test.ts +++ b/server/tests/unit/nest/collections.controller.test.ts @@ -31,6 +31,7 @@ function makeService(overrides: Partial ({ tripId, socketId })), findMembership: vi.fn().mockResolvedValue({ inLists: [] }), assertAccess: vi.fn(), isOwner: vi.fn().mockResolvedValue(true), @@ -80,12 +81,13 @@ describe('CollectionsController', () => { const svc = makeService(); const c = new CollectionsController(svc, new RuntimeEnvService(), storageStub); expect(await c.list(user)).toEqual({ collections: [], incomingInvites: [] }); - expect(await c.create(user, { name: 'Italy' } as never)).toEqual({ id: 7 }); + expect(await c.create(user, { name: 'Italy' } as never, 'sock')).toEqual({ id: 7 }); + expect(svc.createCollection).toHaveBeenCalledWith(1, { name: 'Italy' }, 'sock'); expect(await c.get(user, '3')).toEqual({ collection: { id: 3 }, places: [] }); expect(await c.update(user, '3', { name: 'x' } as never, 'sock')).toEqual({ id: 3 }); expect(svc.updateCollection).toHaveBeenCalledWith(1, 3, { name: 'x' }, 'sock'); - expect(await c.remove(user, '3')).toEqual({ success: true }); - expect(svc.deleteCollection).toHaveBeenCalledWith(1, 3); + expect(await c.remove(user, '3', 'sock')).toEqual({ success: true }); + expect(svc.deleteCollection).toHaveBeenCalledWith(1, 3, 'sock'); }); describe('reorder', () => { @@ -95,11 +97,13 @@ describe('CollectionsController', () => { it('reorders a valid array', async () => { const svc = makeService(); expect( - await new CollectionsController(svc, new RuntimeEnvService(), storageStub).reorder(user, { - orderedIds: [3, 1, 2], - } as never), + await new CollectionsController(svc, new RuntimeEnvService(), storageStub).reorder( + user, + { orderedIds: [3, 1, 2] } as never, + 'sock', + ), ).toEqual({ success: true }); - expect(svc.reorderCollections).toHaveBeenCalledWith(1, [3, 1, 2]); + expect(svc.reorderCollections).toHaveBeenCalledWith(1, [3, 1, 2], 'sock'); }); }); @@ -117,7 +121,13 @@ describe('CollectionsController', () => { expect(svc.deletePlace).toHaveBeenCalledWith(1, 9, 'sid'); await c.saveFromTrip(user, { collection_id: 3, source_trip_id: 5, source_place_id: 8 } as never, 'sid'); expect(svc.saveFromTripPlace).toHaveBeenCalledWith(1, 3, 5, 8, undefined, 'sid'); - expect(await c.copyToTrip(user, { trip_id: 5, place_ids: [9] } as never)).toEqual({ copied: 1, skipped: [] }); + expect(await c.copyToTrip(user, { trip_id: 5, place_ids: [9] } as never, 'sid')).toEqual({ + copied: 1, + skipped: [], + }); + // The copies go to the target trip's room, past the tab that asked. + expect(svc.tripEvents).toHaveBeenCalledWith(5, 'sid'); + expect(svc.copyToTrip).toHaveBeenCalledWith(1, { trip_id: 5, place_ids: [9] }, { tripId: 5, socketId: 'sid' }); }); // The legacy 'ids must be an array of numbers' 400 is gone: @@ -191,14 +201,16 @@ describe('CollectionsController', () => { ), ).toEqual({ status: 409, body: { error: 'Already a member' } }); }); - it('invites successfully as owner', async () => { + it('invites successfully as owner, handing on the socket id', async () => { const svc = makeService(); expect( - await new CollectionsController(svc, new RuntimeEnvService(), storageStub).invite(user, { - collection_id: 3, - user_id: 2, - } as never), + await new CollectionsController(svc, new RuntimeEnvService(), storageStub).invite( + user, + { collection_id: 3, user_id: 2 } as never, + 'sid', + ), ).toEqual({ success: true }); + expect(svc.sendInvite).toHaveBeenCalledWith(3, 1, user.username, user.email, 2, undefined, 'sid'); }); it('accept surfaces an error, else succeeds', async () => { const bad = makeService({ acceptInvite: vi.fn().mockRejectedValue(new DomainError(404, 'Gone')) }); @@ -241,11 +253,13 @@ describe('CollectionsController', () => { ).toEqual({ status: 403, body: { error: 'Only the owner can cancel invites' } }); const svc = makeService(); expect( - await new CollectionsController(svc, new RuntimeEnvService(), storageStub).cancelInvite(user, { - collection_id: 3, - user_id: 2, - } as never), + await new CollectionsController(svc, new RuntimeEnvService(), storageStub).cancelInvite( + user, + { collection_id: 3, user_id: 2 } as never, + 'sid', + ), ).toEqual({ success: true }); + expect(svc.cancelInvite).toHaveBeenCalledWith(3, 1, 2, 'sid'); }); }); diff --git a/server/tests/unit/nest/collections.service.test.ts b/server/tests/unit/nest/collections.service.test.ts index 5ed845b62..f2c3345ea 100644 --- a/server/tests/unit/nest/collections.service.test.ts +++ b/server/tests/unit/nest/collections.service.test.ts @@ -58,6 +58,10 @@ import { FakeRealtimeService } from '../../helpers/fake-realtime'; const realtime = new FakeRealtimeService(); const broadcastToUser = realtime.broadcastToUserMock; +// copyToTrip's journey hook; collections.copy-to-trip.test.ts covers it on the booted module. +const placesHooks = { onCreated: vi.fn(async (_tripId: string, _placeId: number) => undefined) }; +/** The events of a copy, sent the way the REST route sends them, through the fake transport. */ +const copyEvents = (tripId: number) => svc.tripEvents(tripId, 'sock-copy'); const orm = () => sharedTestOrm(testDb); @@ -129,6 +133,7 @@ beforeAll(async () => { await createTestPlacesRepo(testDb), await createTestPlaceRatingsRepo(testDb), await createTestTagsRepo(testDb), await createTestUsersRepo(testDb), buildPlaceImportService(), + placesHooks as never, ); photoCache = new PlacePhotoCacheService( makeStorageFixture('photos/google/').storage, @@ -496,7 +501,7 @@ describe('copyToTrip', () => { createPlace(testDb, trip.id, { name: 'Pantheon' }); const p2 = (await svc.savePlace(u.id, { collection_id: col.id, name: 'Pantheon' })).place!; - const res = await svc.copyToTrip(u.id, { trip_id: trip.id, place_ids: [p1.id, p2.id] }); + const res = await svc.copyToTrip(u.id, { trip_id: trip.id, place_ids: [p1.id, p2.id] }, copyEvents(trip.id)); expect(res.copied).toBe(1); expect(res.skipped.map((s) => s.name)).toEqual(['Pantheon']); @@ -515,7 +520,7 @@ describe('copyToTrip', () => { const p = (await svc.savePlace(owner.id, { collection_id: hidden.id, name: 'Secret' })).place!; const trip = createTrip(testDb, stranger.id); - await expect(svc.copyToTrip(stranger.id, { trip_id: trip.id, place_ids: [p.id] })).rejects.toThrow(); + await expect(svc.copyToTrip(stranger.id, { trip_id: trip.id, place_ids: [p.id] }, copyEvents(trip.id))).rejects.toThrow(); }); it('COLLECTIONS-SVC-022: rejects a trip the user cannot edit (403/404)', async () => { @@ -525,7 +530,7 @@ describe('copyToTrip', () => { const trip = createTrip(testDb, owner2.id); // u has no access const col = await svc.createCollection(u.id, { name: 'C' }); const p = (await svc.savePlace(u.id, { collection_id: col.id, name: 'X' })).place!; - await expect(svc.copyToTrip(u.id, { trip_id: trip.id, place_ids: [p.id] })).rejects.toThrow(); + await expect(svc.copyToTrip(u.id, { trip_id: trip.id, place_ids: [p.id] }, copyEvents(trip.id))).rejects.toThrow(); }); it('COLLECTIONS-SVC-023: a trip MEMBER can copy (place_edit allowed)', async () => { @@ -536,7 +541,7 @@ describe('copyToTrip', () => { addTripMember(testDb, trip.id, member.id); const col = await svc.createCollection(member.id, { name: 'C' }); const p = (await svc.savePlace(member.id, { collection_id: col.id, name: 'Forum' })).place!; - const res = await svc.copyToTrip(member.id, { trip_id: trip.id, place_ids: [p.id] }); + const res = await svc.copyToTrip(member.id, { trip_id: trip.id, place_ids: [p.id] }, copyEvents(trip.id)); expect(res.copied).toBe(1); }); }); @@ -667,7 +672,7 @@ describe('fusion invitations', () => { // ── deleteCollection snapshot + broadcast + cascade ────────────────────────── describe('deleteCollection', () => { - it('COLLECTIONS-SVC-040: owner-only; snapshots accepted+pending, broadcasts collections:deleted, cascades', async () => { + it("COLLECTIONS-SVC-040: owner-only; snapshots accepted+pending, broadcasts collections:deleted to them and the owner's other tabs, cascades", async () => { const owner = createUser(testDb).user; const accepted = createUser(testDb).user; const pending = createUser(testDb).user; @@ -681,12 +686,14 @@ describe('deleteCollection', () => { await expect(svc.deleteCollection(accepted.id, col.id)).rejects.toThrow(); broadcastToUser.mockClear(); - await svc.deleteCollection(owner.id, col.id); + await svc.deleteCollection(owner.id, col.id, 'sock-9'); const targets = broadcastToUser.mock.calls.map((c) => c[0]); - expect(targets).toEqual(expect.arrayContaining([accepted.id, pending.id])); - expect(targets).not.toContain(owner.id); + // The owner too (G33): the assistant's tab and the owner's other devices drop the list. + expect(targets).toEqual(expect.arrayContaining([owner.id, accepted.id, pending.id])); expect(broadcastToUser.mock.calls.every((c) => (c[1] as { type: string }).type === 'collections:deleted')).toBe(true); + // Only the tab that asked is left out. + expect(broadcastToUser.mock.calls.every((c) => c[2] === 'sock-9')).toBe(true); expect(await countRows(await orm(), Collections, { id: col.id })).toBe(0); expect(await placesIn(col.id)).toEqual({ n: 0 }); @@ -944,7 +951,7 @@ describe('collaborative ratings (#1435)', () => { const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); // member is on the trip, so their vote carries - const res = await svc.copyToTrip(owner.id, { trip_id: trip.id, place_ids: [cp.id] }); + const res = await svc.copyToTrip(owner.id, { trip_id: trip.id, place_ids: [cp.id] }, copyEvents(trip.id)); expect(res.copied).toBe(1); const newPlace = await newestPlace(trip.id); @@ -988,7 +995,7 @@ describe('collaborative ratings (#1435)', () => { const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, inTrip.id); - await svc.copyToTrip(owner.id, { trip_id: trip.id, place_ids: [cp.id] }); + await svc.copyToTrip(owner.id, { trip_id: trip.id, place_ids: [cp.id] }, copyEvents(trip.id)); const newPlace = await newestPlace(trip.id); const ids = (await placeVotes(newPlace.id)) @@ -1810,7 +1817,7 @@ describe('Plan 3h Task 2 — R6 two-layer authorization order', () => { const spy = vi.spyOn(permissionsForSpy, 'checkPermission'); spy.mockClear(); try { - await svc.copyToTrip(owner.id, { trip_id: strangerTrip.id, place_ids: [cp.id] }); + await svc.copyToTrip(owner.id, { trip_id: strangerTrip.id, place_ids: [cp.id] }, copyEvents(strangerTrip.id)); expect.unreachable('copyToTrip should have refused'); } catch (e) { expect((e as { status: number }).status).toBe(404); @@ -1833,7 +1840,9 @@ describe('Plan 3h Task 2 — R6 two-layer authorization order', () => { const strangerTrip = createTrip(testDb, strangerOwner.id); // owner (the caller) is not on this trip const before = await countRows(await orm(), Places, { trip: strangerTrip.id }); - await expect(svc.copyToTrip(owner.id, { trip_id: strangerTrip.id, place_ids: [cp.id] })).rejects.toThrow(); + await expect( + svc.copyToTrip(owner.id, { trip_id: strangerTrip.id, place_ids: [cp.id] }, copyEvents(strangerTrip.id)), + ).rejects.toThrow(); const after = await countRows(await orm(), Places, { trip: strangerTrip.id }); expect(after).toBe(before); // no places row (CL64), and therefore no place_tags/place_ratings rows either }); diff --git a/server/tests/unit/nest/dawarich-suggestions.service.test.ts b/server/tests/unit/nest/dawarich-suggestions.service.test.ts index 67783e50a..897fd61a5 100644 --- a/server/tests/unit/nest/dawarich-suggestions.service.test.ts +++ b/server/tests/unit/nest/dawarich-suggestions.service.test.ts @@ -23,6 +23,7 @@ import type { PlacesRepository } from '../../../src/db/repositories/Places.repos import type { TripsRepository } from '../../../src/db/repositories/Trips.repository'; import type { UsersRepository } from '../../../src/db/repositories/Users.repository'; import type { AssignmentsService } from '../../../src/nest/assignments/assignments.service'; +import type { AtlasWritesService } from '../../../src/nest/atlas/atlas-writes.service'; import type { AtlasService } from '../../../src/nest/atlas/atlas.service'; import { AcceptError, DawarichSuggestionsService } from '../../../src/nest/integrations/dawarich-suggestions.service'; import { DawarichError, type DawarichClient } from '../../../src/nest/integrations/dawarich.client'; @@ -92,13 +93,17 @@ const atlasStub = { markCountry: vi.fn(), stats: vi.fn() }; // place write and has to behave like one: everyone on the trip is told, and a // live journey mirrors it. Both are asserted rather than merely tolerated. const placesStub = { create: vi.fn(), broadcast: vi.fn(), onCreated: vi.fn() }; -const assignmentsStub = { dayExists: vi.fn(), createAssignment: vi.fn(), broadcast: vi.fn() }; +// `reconcile` is what the planner runs once a stop is on a day: linked journeys gain its skeleton and hear it. +const assignmentsStub = { dayExists: vi.fn(), createAssignment: vi.fn(), broadcast: vi.fn(), reconcile: vi.fn() }; // The instance's permission matrix, stubbed so a case can narrow place_edit or // day_edit to the owner the way an admin would and watch a member be refused. const permissionsStub = { checkPermission: vi.fn() }; -const journeyStub = { canEdit: vi.fn(), createEntry: vi.fn() }; +// `broadcastJourneyEvent` announces an accepted entry once the transaction has committed. +const journeyStub = { canEdit: vi.fn(), createEntry: vi.fn(), broadcastJourneyEvent: vi.fn() }; // The stay an entry was taken over from is recorded on it (JRN-02); its own suite has the rows. const journeyStaysStub = { record: vi.fn() }; +// Tells the user's tabs that their atlas changed, after a tick or a country landed. +const atlasWritesStub = { announce: vi.fn() }; let t: TestOrm; let suggestions: DawarichVisitSuggestionsRepository; @@ -133,6 +138,7 @@ beforeAll(async () => { bucketList, users, await createTestUnitOfWork(testDb), + atlasWritesStub as unknown as AtlasWritesService, ); }); @@ -183,9 +189,12 @@ function armStubs(): void { placesStub.onCreated.mockReset(); assignmentsStub.createAssignment.mockReset(); assignmentsStub.broadcast.mockReset(); + assignmentsStub.reconcile.mockReset(); + atlasWritesStub.announce.mockReset(); + journeyStub.broadcastJourneyEvent.mockReset(); permissionsStub.checkPermission.mockReset().mockReturnValue(true); journeyStub.canEdit.mockReset().mockReturnValue(true); - journeyStub.createEntry.mockReset().mockReturnValue({ id: CREATED_ENTRY_ID }); + journeyStub.createEntry.mockReset().mockReturnValue({ id: CREATED_ENTRY_ID, journey_id: 88 }); journeyStaysStub.record.mockReset(); } @@ -637,6 +646,14 @@ describe('DawarichSuggestionsService — accepting into a journey', () => { location_lng: 16.3658, }), 'socket-7', + // Quiet inside the transaction: the entry is announced once it has committed. + false, + ); + expect(journeyStub.broadcastJourneyEvent).toHaveBeenCalledWith( + 88, + 'journey:entry:created', + { entry: { id: CREATED_ENTRY_ID, journey_id: 88 } }, + 'socket-7', ); expect(result.createdJournalEntryId).toBe(CREATED_ENTRY_ID); expect(result.createdPlaceId).toBeNull(); @@ -717,6 +734,7 @@ describe('DawarichSuggestionsService — accepting into a journey', () => { location_lng: 16.363, }), undefined, + false, ); }); @@ -787,6 +805,8 @@ describe('DawarichSuggestionsService — ticking off a wish', () => { expect(result.bucketListItemId).toBe(wish); expect(result.createdPlaceId).toBeNull(); expect((await rowOf(id)).accepted_bucket_list_item_id).toBe(wish); + // The tick is in the atlas, so every tab of the user (no socket here) reads it again. + expect(atlasWritesStub.announce).toHaveBeenCalledWith(user.id, undefined); }); it('DAWARICH-SUG-016: with no wish named in the body, the matched one is used', async () => { @@ -887,6 +907,25 @@ describe('DawarichSuggestionsService — confirming a scan', () => { expect((await bucketOf(alreadyTicked)).visited_source).toBe('manual'); }); + it("DAWARICH-SUG-070: a confirmed or cleared tick tells the user's other tabs, and a write that changed nothing tells no one", async () => { + const { user } = createUser(testDb); + const wish = await seedBucketItem(user.id, { name: 'Hallstatt' }); + + expect(await svc.confirmBucketVisits(user.id, [wish], '2026-09-05T10:00:00Z', 'sock-3')).toBe(1); + expect(atlasWritesStub.announce).toHaveBeenCalledWith(user.id, 'sock-3'); + + atlasWritesStub.announce.mockClear(); + expect(await svc.confirmBucketVisits(user.id, [wish], '2026-09-06T10:00:00Z', 'sock-3')).toBe(0); + expect(atlasWritesStub.announce).not.toHaveBeenCalled(); + + expect(await svc.clearBucketVisit(user.id, wish, 'sock-3')).toBe(true); + expect(atlasWritesStub.announce).toHaveBeenCalledWith(user.id, 'sock-3'); + + atlasWritesStub.announce.mockClear(); + expect(await svc.clearBucketVisit(user.id + 1000, wish, 'sock-3')).toBe(false); + expect(atlasWritesStub.announce).not.toHaveBeenCalled(); + }); + it("DAWARICH-SUG-023: confirmBucketVisits ignores ids that are not the caller's", async () => { const { user } = createUser(testDb); const { user: stranger } = createUser(testDb); @@ -1187,6 +1226,9 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { expect(atlasStub.markCountry).toHaveBeenNthCalledWith(1, user.id, 'DE', 'dawarich'); expect(atlasStub.markCountry).toHaveBeenNthCalledWith(2, user.id, 'FR', 'dawarich'); expect(atlasStub.markCountry).toHaveBeenNthCalledWith(3, user.id, 'US', 'dawarich'); + // One announcement after the commit, not one per country. + expect(atlasWritesStub.announce).toHaveBeenCalledTimes(1); + expect(atlasWritesStub.announce).toHaveBeenCalledWith(user.id, undefined); }); it('DAWARICH-SUG-042: the count is what was added, not what was asked for', async () => { @@ -1451,6 +1493,8 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { { assignment: { id: 4242, day_id: day.id } }, 'socket-9', ); + // As the planner does once a stop is on a day: the linked journeys reconcile, leaving out the same tab. + expect(assignmentsStub.reconcile).toHaveBeenCalledWith(trip.id, 'socket-9'); }); it('DAWARICH-SUG-033: an assignment the domain refuses to create is not broadcast as one', async () => { @@ -1463,6 +1507,7 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { await svc.accept(user.id, id, { target: 'place', tripId: trip.id, dayId: day.id }); expect(assignmentsStub.broadcast).not.toHaveBeenCalled(); + expect(assignmentsStub.reconcile).not.toHaveBeenCalled(); }); // ── An acceptance whose result was deleted ──────────────────────────────── diff --git a/server/tests/unit/nest/dawarich.controller.test.ts b/server/tests/unit/nest/dawarich.controller.test.ts index c6920a234..e57bf969b 100644 --- a/server/tests/unit/nest/dawarich.controller.test.ts +++ b/server/tests/unit/nest/dawarich.controller.test.ts @@ -292,12 +292,12 @@ describe('DawarichController parseId', () => { it('DAWARICH-CTRL-033: DELETE bucket-list/:itemId/visit refuses a non-numeric item id with 400', () => { const clearBucketVisit = vi.fn(); - return thrown(() => makeController({ suggestions: { clearBucketVisit } }).clearBucketVisit(user, '12abc')).then( - (r) => { - expect(r).toEqual({ status: 400, body: { error: 'Invalid id' } }); - expect(clearBucketVisit).not.toHaveBeenCalled(); - }, - ); + return thrown(() => + makeController({ suggestions: { clearBucketVisit } }).clearBucketVisit(user, '12abc', makeReq()), + ).then((r) => { + expect(r).toEqual({ status: 400, body: { error: 'Invalid id' } }); + expect(clearBucketVisit).not.toHaveBeenCalled(); + }); }); it("DAWARICH-CTRL-034: GET trips/:tripId/track refuses a non-numeric trip id with 400, not with the guard's 502", async () => { @@ -363,19 +363,21 @@ describe('DawarichController suggestion routes', () => { it('DAWARICH-CTRL-045: POST bucket-list/confirm wraps the count', async () => { const confirmBucketVisits = vi.fn().mockReturnValue(3); expect( - await makeController({ suggestions: { confirmBucketVisits } }).confirmBucketVisits(user, { - itemIds: [1, 2, 3], - visitedAt: '2026-05-01', - }), + await makeController({ suggestions: { confirmBucketVisits } }).confirmBucketVisits( + user, + { itemIds: [1, 2, 3], visitedAt: '2026-05-01' }, + makeReq({ 'x-socket-id': 'sock-abc' }), + ), ).toEqual({ updated: 3 }); - expect(confirmBucketVisits).toHaveBeenCalledWith(7, [1, 2, 3], '2026-05-01'); + // The socket id goes on, so the asking tab is left out of atlas:changed. + expect(confirmBucketVisits).toHaveBeenCalledWith(7, [1, 2, 3], '2026-05-01', 'sock-abc'); }); it('DAWARICH-CTRL-046: DELETE bucket-list/:itemId/visit is a 404 when nothing was cleared', () => { const clearBucketVisit = vi.fn().mockReturnValue(false); - return thrown(() => makeController({ suggestions: { clearBucketVisit } }).clearBucketVisit(user, '4')).then((r) => - expect(r).toEqual({ status: 404, body: { error: 'Bucket-list entry not found' } }), - ); + return thrown(() => + makeController({ suggestions: { clearBucketVisit } }).clearBucketVisit(user, '4', makeReq()), + ).then((r) => expect(r).toEqual({ status: 404, body: { error: 'Bucket-list entry not found' } })); }); it('DAWARICH-CTRL-076: DELETE bucket-list/:itemId/visit answers 200 { success: true } when a tick was cleared', async () => { @@ -383,20 +385,24 @@ describe('DawarichController suggestion routes', () => { // only the "nothing changed" answer is a 404. A cleared tick must not come // back as one, or undoing a wish would look like it failed. const clearBucketVisit = vi.fn().mockReturnValue(true); - expect(await makeController({ suggestions: { clearBucketVisit } }).clearBucketVisit(user, '4')).toEqual({ + const req = makeReq({ 'x-socket-id': 'sock-abc' }); + expect(await makeController({ suggestions: { clearBucketVisit } }).clearBucketVisit(user, '4', req)).toEqual({ success: true, }); - expect(clearBucketVisit).toHaveBeenCalledWith(7, 4); + expect(clearBucketVisit).toHaveBeenCalledWith(7, 4, 'sock-abc'); }); it('DAWARICH-CTRL-047: POST atlas/accept wraps the marked count', async () => { const acceptAtlasCountries = vi.fn().mockReturnValue(2); expect( - await makeController({ suggestions: { acceptAtlasCountries } }).acceptAtlasCountries(user, { - countryCodes: ['DE', 'FR'], - }), + await makeController({ suggestions: { acceptAtlasCountries } }).acceptAtlasCountries( + user, + { countryCodes: ['DE', 'FR'] }, + makeReq(), + ), ).toEqual({ marked: 2 }); - expect(acceptAtlasCountries).toHaveBeenCalledWith(7, ['DE', 'FR']); + // No socket on the request: every tab of the user hears atlas:changed. + expect(acceptAtlasCountries).toHaveBeenCalledWith(7, ['DE', 'FR'], undefined); }); it('DAWARICH-CTRL-048: POST bucket-list/scan returns the scan untouched', async () => { diff --git a/server/tests/unit/nest/journey-domain.broadcasts.test.ts b/server/tests/unit/nest/journey-domain.broadcasts.test.ts new file mode 100644 index 000000000..d815242cc --- /dev/null +++ b/server/tests/unit/nest/journey-domain.broadcasts.test.ts @@ -0,0 +1,150 @@ +/** + * JourneyDomainService: who hears a journey change (G31). The suite takes the + * service from its module over a snapshot test db, so the audience reads, the + * writes and the realtime fake are the ones the app uses. + * + * What it pins: a deleted journey reaches every contributor, read before the + * delete takes their rows; a removed contributor hears their own removal; a + * restore sends `journey:updated`; a preference reaches only the user who set + * it; and a refused or empty write sends nothing at all. + */ +import { JourneyDomainModule } from '../../../src/nest/journey/journey-domain.module'; +import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; +import { createSnapshotTestDb } from '../../helpers/db-mock'; +import { addJourneyContributor, makeJourney, makeJourneyEntry } from '../../helpers/factories/journeys'; +import { makeUser } from '../../helpers/factories/users'; +import { resetTestDb } from '../../helpers/test-db'; +import { createTestModule, type TestModule } from '../../helpers/test-module'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; + +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +const db = createSnapshotTestDb(); +let t: TestModule; +let orm: TestOrm; +let svc: JourneyDomainService; + +beforeAll(async () => { + t = await createTestModule({ db, imports: [JourneyDomainModule] }); + svc = t.get(JourneyDomainService); + orm = await createTestOrm(db); +}); +beforeEach(() => { + resetTestDb(db); + orm.clear(); + t.realtime.reset(); +}); +afterAll(async () => { + await orm.close(); + await t.close(); + db.close(); +}); + +/** What each user was sent, as [user, event type, left-out socket], in user order. */ +const sent = () => + t.realtime.broadcastToUserMock.mock.calls + .map(([to, msg, sid]) => [to, (msg as { type: string }).type, sid] as const) + .sort((a, b) => a[0] - b[0]); + +/** A journey with its owner, an editor and a viewer. */ +async function crew() { + const { user: owner } = await makeUser(orm); + const { user: editor } = await makeUser(orm); + const { user: viewer } = await makeUser(orm); + const journey = await makeJourney(orm, owner.id); + await addJourneyContributor(orm, journey.id, editor.id, 'editor'); + await addJourneyContributor(orm, journey.id, viewer.id, 'viewer'); + t.realtime.broadcastToUserMock.mockClear(); + return { owner, editor, viewer, journey, everyone: [owner.id, editor.id, viewer.id].sort((a, b) => a - b) }; +} + +describe('JourneyDomainService broadcasts (G31)', () => { + it('JOURNEY-BC-001: a deleted journey reaches every contributor, leaving out only the tab that asked', async () => { + const { owner, journey, everyone } = await crew(); + + expect(await svc.deleteJourney(journey.id, owner.id, 'sock-o')).toBe(true); + + expect(sent()).toEqual(everyone.map((id) => [id, 'journey:deleted', 'sock-o'])); + }); + + it('JOURNEY-BC-002: a delete refused to a contributor sends nothing and keeps the journey', async () => { + const { editor, journey } = await crew(); + + expect(await svc.deleteJourney(journey.id, editor.id, 'sock-e')).toBe(false); + + expect(t.realtime.broadcastToUserMock).not.toHaveBeenCalled(); + expect(await svc.canAccessJourney(journey.id, editor.id)).not.toBeNull(); + }); + + it('JOURNEY-BC-003: the removed contributor hears their own removal (role null), as everyone else does', async () => { + const { owner, viewer, journey, everyone } = await crew(); + + expect(await svc.removeContributor(journey.id, owner.id, viewer.id)).toBe(true); + + expect(sent()).toEqual(everyone.map((id) => [id, 'journey:contributor:changed', undefined])); + for (const [, msg] of t.realtime.broadcastToUserMock.mock.calls) { + expect(msg).toEqual({ + type: 'journey:contributor:changed', + journeyId: journey.id, + targetUserId: viewer.id, + role: null, + }); + } + expect(await svc.canAccessJourney(journey.id, viewer.id)).toBeNull(); + }); + + it('JOURNEY-BC-004: removing the owner, a stranger, or as a non-owner announces nothing', async () => { + const { owner, editor, viewer, journey } = await crew(); + const { user: stranger } = await makeUser(orm); + + // The owner row is protected, and a stranger has no row: nothing was deleted. + expect(await svc.removeContributor(journey.id, owner.id, owner.id)).toBe(true); + expect(await svc.removeContributor(journey.id, owner.id, stranger.id)).toBe(true); + // Only the owner may remove anyone. + expect(await svc.removeContributor(journey.id, editor.id, viewer.id)).toBe(false); + + expect(t.realtime.broadcastToUserMock).not.toHaveBeenCalled(); + expect(await svc.canAccessJourney(journey.id, viewer.id)).not.toBeNull(); + }); + + it('JOURNEY-BC-005: an update reaches every contributor; an empty patch or a non-owner sends nothing', async () => { + const { owner, editor, journey, everyone } = await crew(); + + await svc.updateJourney(journey.id, owner.id, { title: 'Renamed' }, 'sock-o'); + expect(sent()).toEqual(everyone.map((id) => [id, 'journey:updated', 'sock-o'])); + + t.realtime.broadcastToUserMock.mockClear(); + await svc.updateJourney(journey.id, owner.id, {}, 'sock-o'); + expect(await svc.updateJourney(journey.id, editor.id, { title: 'Not theirs' }, 'sock-e')).toBeNull(); + expect(t.realtime.broadcastToUserMock).not.toHaveBeenCalled(); + }); + + it('JOURNEY-BC-006: a restore of dismissed suggestions sends journey:updated, nothing when none was', async () => { + const { owner, editor, viewer, journey, everyone } = await crew(); + await makeJourneyEntry(orm, journey.id, owner.id, { dismissed: 1 }); + + expect(await svc.restoreDismissedSuggestions(journey.id, editor.id, 'sock-e')).toEqual({ restored: 1 }); + expect(sent()).toEqual(everyone.map((id) => [id, 'journey:updated', 'sock-e'])); + + t.realtime.broadcastToUserMock.mockClear(); + expect(await svc.restoreDismissedSuggestions(journey.id, owner.id, 'sock-o')).toEqual({ restored: 0 }); + // A viewer may not restore at all. + expect(await svc.restoreDismissedSuggestions(journey.id, viewer.id, 'sock-v')).toBeNull(); + expect(t.realtime.broadcastToUserMock).not.toHaveBeenCalled(); + }); + + it("JOURNEY-BC-007: a preference reaches only the user's own tabs; a stranger's sends nothing", async () => { + const { viewer, journey } = await crew(); + const { user: stranger } = await makeUser(orm); + + const prefs = await svc.updateJourneyPreferences(journey.id, viewer.id, { hide_skeletons: true }, 'sock-v'); + expect(prefs).toEqual({ hide_skeletons: true }); + expect(t.realtime.broadcastToUserMock.mock.calls).toEqual([ + [viewer.id, { type: 'journey:updated', journeyId: journey.id }, 'sock-v'], + ]); + + t.realtime.broadcastToUserMock.mockClear(); + expect(await svc.updateJourneyPreferences(journey.id, stranger.id, { hide_skeletons: true }, 'sock-s')).toBeNull(); + expect(t.realtime.broadcastToUserMock).not.toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/nest/journey-photo-capture.service.test.ts b/server/tests/unit/nest/journey-photo-capture.service.test.ts index f4e2f9b45..218277f82 100644 --- a/server/tests/unit/nest/journey-photo-capture.service.test.ts +++ b/server/tests/unit/nest/journey-photo-capture.service.test.ts @@ -1,9 +1,11 @@ /** - * JourneyPhotoCaptureService (#1587): the capture-time backfill as every journey - * surface runs it, and the refresh that has to follow it so the gallery re-sorts - * without anyone reloading. + * JourneyPhotoCaptureService (#1587): provider photos as every journey surface + * adds them (the adds, then journey:updated to everyone on the journey), the + * capture-time backfill that follows, and the refresh after it so the gallery + * re-sorts without anyone reloading. */ import { Users } from '../../../src/db/entities/Users.entity'; +import type { UnitOfWork } from '../../../src/nest/database/unit-of-work'; import type { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; import { JourneyPhotoCaptureService } from '../../../src/nest/journey/journey-photo-capture.service'; import type { MapsService } from '../../../src/nest/maps/maps.service'; @@ -42,6 +44,15 @@ function build(changed: boolean | Error = true) { const placeEntriesFromPhotos = vi.fn((): { entryId: number; journeyId: number; lat: number; lng: number }[] => []); const nameEntryLocation = vi.fn(); const reverseGeocode = vi.fn(async () => ({ name: 'Marienplatz', address: 'Munich' })); + // Each asset lands as a gallery row whose trek photo id is the asset's number; 'dup' is already there. + const landed = (assetId: string) => (assetId === 'dup' ? null : { id: Number(assetId), photo_id: Number(assetId) }); + const addProviderPhoto = vi.fn((_entryId: number, _userId: number, _provider: string, assetId: string) => + landed(assetId), + ); + const addProviderPhotoToGallery = vi.fn((_journeyId: number, _userId: number, _provider: string, assetId: string) => + landed(assetId), + ); + const transactional = vi.fn((fn: () => Promise) => fn()); const svc = new JourneyPhotoCaptureService( { run, schedule } as unknown as PhotoCaptureBackfillService, { @@ -49,12 +60,18 @@ function build(changed: boolean | Error = true) { journeyIdOfEntry, placeEntriesFromPhotos, nameEntryLocation, + addProviderPhoto, + addProviderPhotoToGallery, } as unknown as JourneyDomainService, { reverseGeocode } as unknown as MapsService, t.orm, + { transactional } as unknown as UnitOfWork, ); return { svc, + addProviderPhoto, + addProviderPhotoToGallery, + transactional, run, schedule, broadcastJourneyEvent, @@ -93,29 +110,44 @@ describe('JourneyPhotoCaptureService', () => { expect(broadcastJourneyEvent).not.toHaveBeenCalled(); }); - it('JPCAP-003: an entry add is reported against the journey that entry belongs to', async () => { - const { svc, journeyIdOfEntry, broadcastJourneyEvent } = build(true); + it('JPCAP-003: an entry add lands in one transaction, then its journey hears journey:updated and the fill follows', async () => { + const { svc, addProviderPhoto, transactional, journeyIdOfEntry, broadcastJourneyEvent, run } = build(true); - svc.scheduleForEntry(4, [{ id: 1, photo_id: 11 }], 3); + const added = await svc.addProviderPhotos( + { entryId: 4 }, + 3, + { provider: 'immich', assetIds: ['11', 'dup'], mediaTypes: ['video'], caption: 'cap', passphrase: 'pw' }, + 'sock-1', + ); - await vi.waitFor(() => expect(broadcastJourneyEvent).toHaveBeenCalledWith(9, 'journey:photos:updated', {})); + // The duplicate answers null and is left out of what comes back. + expect(added).toEqual([{ id: 11, photo_id: 11 }]); + expect(transactional).toHaveBeenCalledTimes(1); + expect(addProviderPhoto).toHaveBeenNthCalledWith(1, 4, 3, 'immich', '11', 'cap', 'pw', 'video'); + expect(addProviderPhoto).toHaveBeenNthCalledWith(2, 4, 3, 'immich', 'dup', 'cap', 'pw', 'image'); expect(journeyIdOfEntry).toHaveBeenCalledWith(4); + // The REST tab that asked is left out; it reads its own answer. + expect(broadcastJourneyEvent).toHaveBeenCalledWith(9, 'journey:updated', {}, 'sock-1'); + await vi.waitFor(() => expect(broadcastJourneyEvent).toHaveBeenCalledWith(9, 'journey:photos:updated', {})); + expect(run).toHaveBeenCalledWith([11], 3); }); - it('JPCAP-003b: a journey lookup that rejects is logged against the entry and fills nothing', async () => { - const { svc, run, journeyIdOfEntry, broadcastJourneyEvent } = build(true); - journeyIdOfEntry.mockImplementation(() => { - throw new Error('db gone'); - }); - const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + it('JPCAP-003b: a gallery add stores no caption and, over MCP, tells every tab; a batch where nothing landed tells no one', async () => { + const { svc, addProviderPhotoToGallery, journeyIdOfEntry, broadcastJourneyEvent, run } = build(true); - svc.scheduleForEntry(4, [{ id: 1, photo_id: 11 }], 3); + await svc.addProviderPhotos({ journeyId: 9 }, 3, { provider: 'synology', assetIds: ['21'], caption: 'cap' }); - await vi.waitFor(() => - expect(error).toHaveBeenCalledWith('[Journey] capture refresh failed for entry 4:', 'db gone'), - ); - expect(run).not.toHaveBeenCalled(); + expect(addProviderPhotoToGallery).toHaveBeenCalledWith(9, 3, 'synology', '21', undefined, undefined, 'image'); + expect(journeyIdOfEntry).not.toHaveBeenCalled(); + expect(broadcastJourneyEvent).toHaveBeenCalledWith(9, 'journey:updated', {}, undefined); + await vi.waitFor(() => expect(broadcastJourneyEvent).toHaveBeenCalledWith(9, 'journey:photos:updated', {})); + expect(run).toHaveBeenCalledWith([21], 3); + + broadcastJourneyEvent.mockClear(); + run.mockClear(); + expect(await svc.addProviderPhotos({ journeyId: 9 }, 3, { provider: 'synology', assetIds: ['dup'] })).toEqual([]); expect(broadcastJourneyEvent).not.toHaveBeenCalled(); + expect(run).not.toHaveBeenCalled(); }); it('JPCAP-004: an entry that has gone still gets its photos filled, with no one to tell', async () => { @@ -125,7 +157,8 @@ describe('JourneyPhotoCaptureService', () => { expect(run).toHaveBeenCalledWith([11], 3); expect(broadcastJourneyEvent).not.toHaveBeenCalled(); - svc.scheduleForEntry(5, [{ id: 1, photo_id: 12 }], 3); + // Entry 5 belongs to no journey any more: its photos are filled, and no event goes out. + await svc.addProviderPhotos({ entryId: 5 }, 3, { provider: 'immich', assetIds: ['12'] }); await vi.waitFor(() => expect(run).toHaveBeenCalledWith([12], 3)); expect(broadcastJourneyEvent).not.toHaveBeenCalled(); }); @@ -134,7 +167,7 @@ describe('JourneyPhotoCaptureService', () => { const { svc, run, journeyIdOfEntry } = build(true); svc.scheduleForJourney(9, [null, { id: 1 }, { photo_id: 'x' }], 3); - svc.scheduleForEntry(4, [], 3); + svc.scheduleForJourney(9, [], 3); expect(run).not.toHaveBeenCalled(); expect(journeyIdOfEntry).not.toHaveBeenCalled(); @@ -264,14 +297,19 @@ describe('JourneyPhotoCaptureService', () => { broadcastJourneyEvent, journeyIdOfEntry: () => 9, placeEntriesFromPhotos: () => [], + addProviderPhoto: (_entryId: number, _userId: number, _provider: string, assetId: string) => ({ + photo_id: Number(assetId), + }), } as unknown as JourneyDomainService, { reverseGeocode: vi.fn() } as unknown as MapsService, t.orm, + { transactional: (fn: () => Promise) => fn() } as unknown as UnitOfWork, ); const rows = (from: number) => Array.from({ length: 5 }, (_, i) => ({ photo_id: from + i })); + const assets = (from: number) => Array.from({ length: 5 }, (_, i) => String(from + i)); svc.scheduleForJourney(9, rows(10), 3); - svc.scheduleForEntry(4, rows(20), 3); + await svc.addProviderPhotos({ entryId: 4 }, 3, { provider: 'immich', assetIds: assets(20) }); svc.scheduleUpload(rows(30), 3); await vi.waitFor(() => expect(recordCaptureMetadata).toHaveBeenCalledTimes(15)); @@ -284,8 +322,9 @@ describe('JourneyPhotoCaptureService', () => { lat: null, lng: null, }); - // One event per provider batch, none for the upload. - await vi.waitFor(() => expect(broadcastJourneyEvent).toHaveBeenCalledTimes(2)); + // One order refresh per provider batch, none for the upload. + const refreshes = () => broadcastJourneyEvent.mock.calls.filter(([, event]) => event === 'journey:photos:updated'); + await vi.waitFor(() => expect(refreshes()).toHaveLength(2)); expect(broadcastJourneyEvent).toHaveBeenCalledWith(9, 'journey:photos:updated', {}); }); }); @@ -319,15 +358,15 @@ describe('JourneyPhotoCaptureService — request context of the detached work', expect(error).not.toHaveBeenCalled(); }); - it('JPCAP-CTX-002: scheduleForEntry looks the journey up inside the fork too', async () => { - const { svc, journeyIdOfEntry, broadcastJourneyEvent } = build(true); - journeyIdOfEntry.mockImplementation((async () => { + it('JPCAP-CTX-002: the fill after an entry add runs inside its own fork, after the add has answered', async () => { + const { svc, run, broadcastJourneyEvent } = build(true); + run.mockImplementation(async () => { await realRead(); - return 9; - }) as never); + return true; + }); const error = vi.spyOn(console, 'error').mockImplementation(() => {}); - svc.scheduleForEntry(4, [{ id: 1, photo_id: 11 }], 3); + await svc.addProviderPhotos({ entryId: 4 }, 3, { provider: 'immich', assetIds: ['11'] }); await vi.waitFor(() => expect(broadcastJourneyEvent).toHaveBeenCalledWith(9, 'journey:photos:updated', {})); expect(error).not.toHaveBeenCalled(); diff --git a/server/tests/unit/nest/journey.controller.test.ts b/server/tests/unit/nest/journey.controller.test.ts index 17d2ee004..4f38dcd58 100644 --- a/server/tests/unit/nest/journey.controller.test.ts +++ b/server/tests/unit/nest/journey.controller.test.ts @@ -28,16 +28,14 @@ const storageStub = { exists: storageExists, sendToResponse: storageSendToResponse, } as unknown as StorageService; -// The capture backfill is detached; most cases here only need it to exist so the -// constructor can call it. The spies are read by the cases that pin which journey -// (or entry) a provider batch is reported against, and that uploads get the -// backfill without the refresh (#1587). -const scheduleForEntry = vi.fn(); -const scheduleForJourney = vi.fn(); +// The provider-photo adds, their event and their detached capture refresh are +// JourneyPhotoCaptureService's (#1587), so the routes only shape the batch and +// the answer; the spies pin that shape. Uploads get the backfill without the +// refresh. +const addProviderPhotos = vi.fn(); const scheduleUpload = vi.fn(); const captureBackfillStub = { - scheduleForEntry, - scheduleForJourney, + addProviderPhotos, scheduleUpload, } as unknown as JourneyPhotoCaptureService; @@ -66,7 +64,11 @@ async function thrownAsync(fn: () => Promise): Promise<{ status: number throw new Error('expected throw'); } -beforeEach(() => vi.clearAllMocks()); +beforeEach(() => { + vi.clearAllMocks(); + // A case's own answers for the provider-photo use case never carry into the next. + addProviderPhotos.mockReset(); +}); describe('JourneyController', () => { it('GET / lists; POST / 400 without title, else creates', async () => { @@ -79,12 +81,14 @@ describe('JourneyController', () => { }); const createJourney = vi.fn().mockReturnValue({ id: 9 }); expect( - await ctl(svc({ createJourney } as Partial)).create(user, { - title: ' Trip ', - trip_ids: [1, '2'], - }), + await ctl(svc({ createJourney } as Partial)).create( + user, + { title: ' Trip ', trip_ids: [1, '2'] }, + 'sock-1', + ), ).toEqual({ id: 9 }); - expect(createJourney).toHaveBeenCalledWith(1, { title: 'Trip', subtitle: undefined, trip_ids: [1, 2] }); + // The socket id goes on, so the creating tab is left out of journey:updated. + expect(createJourney).toHaveBeenCalledWith(1, { title: 'Trip', subtitle: undefined, trip_ids: [1, 2] }, 'sock-1'); }); it('GET /suggestions + /available-trips', async () => { @@ -124,8 +128,8 @@ describe('JourneyController', () => { }); it('provider-photos: batch, single 400/403, success', async () => { - const batch = svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 1 }) } as Partial); - expect(await ctl(batch).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a', 'b'] })).toEqual({ + addProviderPhotos.mockResolvedValueOnce([{ id: 1 }, { id: 1 }]); + expect(await ctl(svc()).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a', 'b'] })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2, }); @@ -133,45 +137,40 @@ describe('JourneyController', () => { status: 400, body: { error: 'provider and asset_id required' }, }); + expect(addProviderPhotos).toHaveBeenCalledTimes(1); + addProviderPhotos.mockResolvedValueOnce([]); expect( - await thrownAsync(() => - ctl(svc({ addProviderPhoto: vi.fn().mockReturnValue(null) } as Partial)).providerPhotos( - user, - '3', - { provider: 'immich', asset_id: 'a' }, - ), - ), + await thrownAsync(() => ctl(svc()).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' })), ).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } }); }); - it('provider-photos hand what they added to the shared capture refresh, by entry or by journey; uploads only backfill (#1587)', async () => { + it('provider-photos go through the one use case, by entry or by journey, with the socket id; uploads only backfill (#1587)', async () => { const entryPhoto = { id: 1, photo_id: 11 }; const galleryPhoto = { id: 2, photo_id: 12 }; - const withAdds = svc({ - addProviderPhoto: vi.fn().mockReturnValue(entryPhoto), - addProviderPhotoToGallery: vi.fn().mockReturnValue(galleryPhoto), - addPhoto: vi.fn().mockReturnValue(entryPhoto), - uploadGalleryPhotos: vi.fn().mockReturnValue([galleryPhoto]), - immichAutoUploadEnabled: vi.fn().mockReturnValue(false), - } as Partial); - const c = ctl(withAdds); + addProviderPhotos.mockImplementation(async (target: { entryId?: number }) => + target.entryId === undefined ? [galleryPhoto] : [entryPhoto], + ); + const c = ctl( + svc({ + addPhoto: vi.fn().mockReturnValue(entryPhoto), + uploadGalleryPhotos: vi.fn().mockReturnValue([galleryPhoto]), + immichAutoUploadEnabled: vi.fn().mockReturnValue(false), + } as Partial), + ); - await c.providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a'] }); + await c.providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a'] }, 'sock-1'); await c.providerPhotos(user, '3', { provider: 'immich', asset_id: 'b' }); await c.uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {}); - expect(scheduleForEntry.mock.calls).toEqual([ - [3, [entryPhoto], 1], - [3, [entryPhoto], 1], - ]); - - await c.galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a'] }); + await c.galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a'] }, 'sock-1'); await c.galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'b' }); await c.uploadGalleryPhotos(user, '9', [{ filename: 'g.jpg' } as Express.Multer.File]); - expect(scheduleForJourney.mock.calls).toEqual([ - [9, [galleryPhoto], 1], - [9, [galleryPhoto], 1], - ]); + expect(addProviderPhotos.mock.calls.map(([target, userId, , sid]) => [target, userId, sid])).toEqual([ + [{ entryId: 3 }, 1, 'sock-1'], + [{ entryId: 3 }, 1, undefined], + [{ journeyId: 9 }, 1, 'sock-1'], + [{ journeyId: 9 }, 1, undefined], + ]); // The client uploads one file per request, so a refresh per upload would // reload every open client once per photo: uploads backfill and stay quiet. expect(scheduleUpload.mock.calls).toEqual([ @@ -301,38 +300,30 @@ describe('JourneyController', () => { }); it('provider-photos forwards per-asset media_types for gallery and entries (#823)', async () => { - const add = vi.fn().mockReturnValue({ id: 1 }); - await ctl(svc({ addProviderPhotoToGallery: add } as Partial)).galleryProviderPhotos(user, '9', { + addProviderPhotos.mockResolvedValue([{ id: 1 }]); + await ctl(svc()).galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], media_types: ['video', 'image'], }); - expect(add).toHaveBeenNthCalledWith(1, 9, 1, 'immich', 'a', undefined, undefined, 'video'); - expect(add).toHaveBeenNthCalledWith(2, 9, 1, 'immich', 'b', undefined, undefined, 'image'); - const addOne = vi.fn().mockReturnValue({ id: 2 }); - await ctl(svc({ addProviderPhotoToGallery: addOne } as Partial)).galleryProviderPhotos(user, '9', { - provider: 'immich', - asset_id: 'c', - media_type: 'video', - }); - expect(addOne).toHaveBeenCalledWith(9, 1, 'immich', 'c', undefined, undefined, 'video'); - - // Entry path mirrors the gallery path. - const eAdd = vi.fn().mockReturnValue({ id: 3 }); - await ctl(svc({ addProviderPhoto: eAdd } as Partial)).providerPhotos(user, '4', { + await ctl(svc()).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'c', media_type: 'video' }); + // Entry path mirrors the gallery path, and only an entry keeps the caption. + await ctl(svc()).providerPhotos(user, '4', { provider: 'immich', asset_ids: ['x'], media_types: ['video'], caption: 'c', }); - expect(eAdd).toHaveBeenNthCalledWith(1, 4, 1, 'immich', 'x', 'c', undefined, 'video'); - const eOne = vi.fn().mockReturnValue({ id: 4 }); - await ctl(svc({ addProviderPhoto: eOne } as Partial)).providerPhotos(user, '4', { - provider: 'immich', - asset_id: 'y', - media_type: 'video', - }); - expect(eOne).toHaveBeenCalledWith(4, 1, 'immich', 'y', undefined, undefined, 'video'); + await ctl(svc()).providerPhotos(user, '4', { provider: 'immich', asset_id: 'y', media_type: 'video' }); + await ctl(svc()).galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['z'], caption: 'dropped' }); + + expect(addProviderPhotos.mock.calls.map(([target, , batch]) => [target, batch])).toEqual([ + [{ journeyId: 9 }, { provider: 'immich', assetIds: ['a', 'b'], mediaTypes: ['video', 'image'] }], + [{ journeyId: 9 }, { provider: 'immich', assetIds: ['c'], mediaTypes: ['video'] }], + [{ entryId: 4 }, { provider: 'immich', assetIds: ['x'], mediaTypes: ['video'], caption: 'c' }], + [{ entryId: 4 }, { provider: 'immich', assetIds: ['y'], mediaTypes: ['video'] }], + [{ journeyId: 9 }, { provider: 'immich', assetIds: ['z'] }], + ]); }); it('GET/PATCH/DELETE /:id map 404', async () => { @@ -673,20 +664,22 @@ describe('JourneyController', () => { }); it('provider-photos batch passes the passphrase through when present', async () => { - const addProviderPhoto = vi.fn().mockReturnValue({ id: 1 }); - await ctl(svc({ addProviderPhoto } as Partial)).providerPhotos(user, '3', { + addProviderPhotos.mockResolvedValueOnce([{ id: 1 }]); + await ctl(svc()).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a'], caption: 'cap', passphrase: 'secret', }); - expect(addProviderPhoto).toHaveBeenCalledWith(3, 1, 'immich', 'a', 'cap', 'secret', 'image'); + expect(addProviderPhotos).toHaveBeenCalledWith( + { entryId: 3 }, + 1, + { provider: 'immich', assetIds: ['a'], mediaTypes: undefined, caption: 'cap', passphrase: 'secret' }, + undefined, + ); // single-photo success path - expect( - await ctl( - svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 2 }) } as Partial), - ).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' }), - ).toEqual({ id: 2 }); + addProviderPhotos.mockResolvedValueOnce([{ id: 2 }]); + expect(await ctl(svc()).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 2 }); }); it('PATCH photos: 404 then returns the updated photo', async () => { @@ -735,28 +728,30 @@ describe('JourneyController', () => { }); it('gallery provider-photos: batch (with passphrase), single 400/403, success', async () => { - const addProviderPhotoToGallery = vi.fn().mockReturnValue({ id: 1 }); - const batch = ctl(svc({ addProviderPhotoToGallery } as Partial)); + addProviderPhotos.mockResolvedValueOnce([{ id: 1 }, { id: 1 }]); expect( - await batch.galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], passphrase: 'pw' }), + await ctl(svc()).galleryProviderPhotos(user, '9', { + provider: 'immich', + asset_ids: ['a', 'b'], + passphrase: 'pw', + }), ).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 }); - expect(addProviderPhotoToGallery).toHaveBeenCalledWith(9, 1, 'immich', 'a', undefined, 'pw', 'image'); + expect(addProviderPhotos).toHaveBeenCalledWith( + { journeyId: 9 }, + 1, + { provider: 'immich', assetIds: ['a', 'b'], mediaTypes: undefined, caption: undefined, passphrase: 'pw' }, + undefined, + ); expect(await thrownAsync(() => ctl(svc()).galleryProviderPhotos(user, '9', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' }, }); + addProviderPhotos.mockResolvedValueOnce([]); expect( - await thrownAsync(() => - ctl( - svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue(null) } as Partial), - ).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' }), - ), + await thrownAsync(() => ctl(svc()).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' })), ).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } }); - expect( - await ctl( - svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue({ id: 3 }) } as Partial), - ).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' }), - ).toEqual({ id: 3 }); + addProviderPhotos.mockResolvedValueOnce([{ id: 3 }]); + expect(await ctl(svc()).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 3 }); }); it('DELETE gallery photo: 404, then removes the object when present', async () => { @@ -834,7 +829,7 @@ describe('JourneyController', () => { } as Express.Multer.File), ).toEqual({ id: 9, cover_image: 'journey/c.jpg' }); expect(storageStub.put).toHaveBeenCalledWith('journey', 'c.jpg', { tmpPath: undefined }); - expect(updateJourney).toHaveBeenCalledWith(9, 1, { cover_image: 'journey/c.jpg' }); + expect(updateJourney).toHaveBeenCalledWith(9, 1, { cover_image: 'journey/c.jpg' }, undefined); }); it('DELETE /:id and trips/contributors success paths', async () => { @@ -915,7 +910,7 @@ describe('JourneyController', () => { expect( await ctl(svc({ restoreDismissedSuggestions: restore } as Partial)).restoreSuggestions(user, '9'), ).toEqual({ restored: 3 }); - expect(restore).toHaveBeenCalledWith(9, 1); + expect(restore).toHaveBeenCalledWith(9, 1, undefined); expect( await thrownAsync(() => ctl( diff --git a/server/tests/unit/nest/vacay.controller.test.ts b/server/tests/unit/nest/vacay.controller.test.ts index 501a9f2d2..1a2bc2431 100644 --- a/server/tests/unit/nest/vacay.controller.test.ts +++ b/server/tests/unit/nest/vacay.controller.test.ts @@ -101,10 +101,12 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', () ); }); - it('sends an invite', async () => { + it('sends an invite, handing on the socket id', async () => { const sendInvite = vi.fn().mockReturnValue({}); - expect(await makeController({ ...planBase, sendInvite }).invite(user, { user_id: 2 })).toEqual({ success: true }); - expect(sendInvite).toHaveBeenCalledWith(10, 1, 'u', 'u@example.test', 2); + expect(await makeController({ ...planBase, sendInvite }).invite(user, { user_id: 2 }, 'sid')).toEqual({ + success: true, + }); + expect(sendInvite).toHaveBeenCalledWith(10, 1, 'u', 'u@example.test', 2, 'sid'); }); it('maps an acceptInvite error', () => { @@ -119,9 +121,10 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', () const cancelInvite = vi.fn(); const dissolvePlan = vi.fn(); expect(await makeController({ declineInvite }).declineInvite(user, { plan_id: 5 })).toEqual({ success: true }); - expect(await makeController({ ...planBase, cancelInvite }).cancelInvite(user, { user_id: 2 })).toEqual({ + expect(await makeController({ ...planBase, cancelInvite }).cancelInvite(user, { user_id: 2 }, 'sid')).toEqual({ success: true, }); + expect(cancelInvite).toHaveBeenCalledWith(10, 2, 'sid'); expect(await makeController({ dissolvePlan }).dissolve(user)).toEqual({ success: true }); }); }); diff --git a/server/tests/unit/nest/vacay.service.test.ts b/server/tests/unit/nest/vacay.service.test.ts index 80a4541d6..2a60c22f2 100644 --- a/server/tests/unit/nest/vacay.service.test.ts +++ b/server/tests/unit/nest/vacay.service.test.ts @@ -23,6 +23,7 @@ import { createUser } from '../../helpers/factories'; import { VacayService } from '../../../src/nest/vacay/vacay.service'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; +import { FakeRealtimeService } from '../../helpers/fake-realtime'; import { notificationsStub } from '../../helpers/notifications'; import { createTestUnitOfWork } from '../../helpers/test-uow'; import { @@ -91,6 +92,8 @@ async function storedRows( // VACAY-SVC-067 (vacay.bridge delegation) died with the bridge — its only // consumer, the legacy tripService, folded into the DI-native TripsService. let svc: VacayService; +/** What the service hands the sockets, for the cases that read who hears a write. */ +const realtime = new FakeRealtimeService(); // ── Lifecycle ───────────────────────────────────────────────────────────────── @@ -102,7 +105,7 @@ beforeAll(async () => { await createTestVacayCompanyHolidaysRepo(testDb), await createTestVacayHolidayCalendarsRepo(testDb), await createTestVacaySharesRepo(testDb), await createTestVacayUserSettingsRepo(testDb), await createTestSchoolHolidayRegionsRepo(testDb), - new RealtimeService(), notificationsStub(), await createTestUnitOfWork(testDb), + realtime, notificationsStub(), await createTestUnitOfWork(testDb), ); }); @@ -860,6 +863,58 @@ describe('cancelInvite', () => { }); }); +describe('invites and dissolving reach the actor too (G34)', () => { + const sent = () => realtime.broadcastToUserMock.mock.calls.map(([to, msg, sid]) => [to, msg.type, sid]); + + it('VACAY-SVC-076: an invite reaches the invitee, and the plan\'s own tabs read the plan again', async () => { + const { user: owner, plan } = await setupUserWithPlan(); + const { user: target } = createUser(testDb); + realtime.broadcastToUserMock.mockClear(); + + await svc.sendInvite(plan.id, owner.id, 'owner', 'owner@example.test', target.id, 'sock-o'); + + expect(sent()).toEqual([ + [target.id, 'vacay:invite', undefined], + [owner.id, 'vacay:settings', 'sock-o'], + ]); + }); + + it('VACAY-SVC-077: a cancel reaches the invitee and the plan; a decline the plan and the one declining', async () => { + const { user: owner, plan } = await setupUserWithPlan(); + const { user: target } = createUser(testDb); + await insertMember(plan.id, target.id, 'pending'); + realtime.broadcastToUserMock.mockClear(); + + await svc.cancelInvite(plan.id, target.id, 'sock-o'); + expect(sent()).toEqual([ + [target.id, 'vacay:cancelled', 'sock-o'], + [owner.id, 'vacay:cancelled', 'sock-o'], + ]); + + await insertMember(plan.id, target.id, 'pending'); + realtime.broadcastToUserMock.mockClear(); + await svc.declineInvite(target.id, plan.id, 'sock-t'); + expect(sent()).toEqual([ + [owner.id, 'vacay:declined', 'sock-t'], + [target.id, 'vacay:declined', 'sock-t'], + ]); + }); + + it('VACAY-SVC-078: dissolving tells everyone on the plan, the one dissolving it included', async () => { + const { user: owner, plan } = await setupUserWithPlan(); + const { user: member } = createUser(testDb); + await insertMember(plan.id, member.id, 'accepted'); + realtime.broadcastToUserMock.mockClear(); + + await svc.dissolvePlan(owner.id, 'sock-o'); + + expect(sent().filter(([, type]) => type === 'vacay:dissolved')).toEqual([ + [owner.id, 'vacay:dissolved', 'sock-o'], + [member.id, 'vacay:dissolved', 'sock-o'], + ]); + }); +}); + // ── getAvailableUsers ───────────────────────────────────────────────────────── describe('getAvailableUsers', () => {