fix(realtime): journey, atlas, vacay and collection writes in parity

This commit is contained in:
Maurice
2026-10-22 12:14:28 +02:00
parent ce7b5bf453
commit b24a82fe33
39 changed files with 2280 additions and 815 deletions
+2 -2
View File
@@ -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
}
-4
View File
@@ -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
}
@@ -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<void> {
await this.atlas.markCountry(userId, code);
this.announce(userId, socketId);
}
async unmarkCountry(userId: number, code: string, socketId?: string): Promise<void> {
await this.atlas.unmarkCountry(userId, code);
this.announce(userId, socketId);
}
async markRegion(userId: number, code: string, name: string, countryCode: string, socketId?: string): Promise<void> {
await this.atlas.markRegion(userId, code, name, countryCode);
this.announce(userId, socketId);
}
async unmarkRegion(userId: number, code: string, socketId?: string): Promise<void> {
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<boolean> {
const deleted = await this.atlas.deleteBucketItem(userId, itemId);
if (deleted) this.announce(userId, socketId);
return deleted;
}
}
+43 -21
View File
@@ -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 };
+12 -8
View File
@@ -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,
+10 -2
View File
@@ -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 {}
+1 -1
View File
@@ -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 {
@@ -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 };
}
}
@@ -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);
}
@@ -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,
@@ -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' })
@@ -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<CollectionImportResult> {
async importCollection(userId: number, body: CollectionImportRequest, sid?: string): Promise<CollectionImportResult> {
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<Collection> {
/** `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<Collection> {
// 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<Collection> {
@@ -627,7 +629,7 @@ export class CollectionsService {
return { ...col, is_owner: col.owner_id === userId };
}
async deleteCollection(userId: number, id: number): Promise<void> {
async deleteCollection(userId: number, id: number, socketId?: string): Promise<void> {
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<void> {
async reorderCollections(userId: number, orderedIds: number[], socketId?: string): Promise<void> {
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<number>([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<number>([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<void> {
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<void> {
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<void> {
async cancelInvite(collectionId: number, ownerId: number, targetUserId: number, socketId?: string): Promise<void> {
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<void> {
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"). */
@@ -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<DawarichAcceptResult> {
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<number> {
async confirmBucketVisits(userId: number, itemIds: number[], visitedAt?: string, sid?: string): Promise<number> {
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<boolean> {
return this.bucketList.clearVisited(itemId, userId);
async clearBucketVisit(userId: number, itemId: number, sid?: string): Promise<boolean> {
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<number> {
async acceptAtlasCountries(userId: number, codes: string[], sid?: string): Promise<number> {
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;
}
}
@@ -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 ──────────────────────────────────────────────────────────
@@ -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<string, string> {
const out: Record<string, string> = {};
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<string, unknown>,
excludeSocketId?: string | number,
// Read before a write that takes people off the journey, so they still hear about it.
audience?: number[],
): Promise<void> {
// 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<Journey> {
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 | null> {
// 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<boolean> {
async deleteJourney(journeyId: number, userId: number, sid?: string): Promise<boolean> {
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<boolean> {
async removeTripFromJourney(journeyId: number, tripId: number, userId: number, sid?: string): Promise<boolean> {
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<boolean> {
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;
}
@@ -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<unknown[]> {
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<void> {
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.
+35
View File
@@ -158,6 +158,41 @@ export function collectCountries(points: StatsInputPoint[], names: Record<string
}));
}
/**
* 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;
}
}
/** The English name of every country the points pass through, by upper-case code. */
export function countryNamesFor(points: { country: string | null }[]): Record<string, string> {
const out: Record<string, string> = {};
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. */
+35 -49
View File
@@ -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);
}
+14 -24
View File
@@ -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 });
+12 -33
View File
@@ -38,17 +38,17 @@ export class JourneyService {
listJourneys(userId: number) {
return this.journey.listJourneys(userId);
}
createJourney(userId: number, data: Parameters<typeof this.journey.createJourney>[1]) {
return this.journey.createJourney(userId, data);
createJourney(userId: number, data: Parameters<typeof this.journey.createJourney>[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<typeof this.journey.updateJourney>[2]) {
return this.journey.updateJourney(id, userId, data);
updateJourney(id: number, userId: number, data: Parameters<typeof this.journey.updateJourney>[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<typeof this.journey.updateJourneyPreferences>[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<typeof this.journey.uploadGalleryPhotos>[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);
}
+8 -4
View File
@@ -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 };
}
+15 -17
View File
@@ -687,7 +687,7 @@ export class VacayService {
// Invitations
// -------------------------------------------------------------------------
async sendInvite(planId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number): Promise<void> {
async sendInvite(planId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number, socketId?: string): Promise<void> {
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<void> {
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<void> {
async cancelInvite(planId: number, targetUserId: number, socketId?: string): Promise<void> {
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 */ }
+6 -2
View File
@@ -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 () => {
+29 -27
View File
@@ -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,
@@ -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[] = [];
File diff suppressed because it is too large Load Diff
@@ -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);
});
});
@@ -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'));
});
});
+40 -12
View File
@@ -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<AtlasService>) {
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<AtlasService> & Partial<AtlasWritesService>) {
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,
);
});
@@ -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();
});
});
@@ -31,6 +31,7 @@ function makeService(overrides: Partial<Record<keyof CollectionsService, unknown
deletePlace: vi.fn(),
deletePlacesMany: vi.fn().mockResolvedValue([1, 2]),
copyToTrip: vi.fn().mockResolvedValue({ copied: 1, skipped: [] }),
tripEvents: vi.fn((tripId: number, socketId: string | undefined) => ({ 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');
});
});
@@ -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
});
@@ -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 ────────────────────────────────
@@ -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 () => {
@@ -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();
});
});
@@ -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(<T>(fn: () => Promise<T>) => 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: <T>(fn: () => Promise<T>) => 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();
@@ -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<unknown>): 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<JourneyService>)).create(user, {
title: ' Trip ',
trip_ids: [1, '2'],
}),
await ctl(svc({ createJourney } as Partial<JourneyService>)).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<JourneyService>);
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<JourneyService>)).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<JourneyService>);
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<JourneyService>),
);
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<JourneyService>)).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<JourneyService>)).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<JourneyService>)).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<JourneyService>)).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<JourneyService>)).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<JourneyService>),
).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<JourneyService>));
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<JourneyService>),
).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<JourneyService>),
).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<JourneyService>)).restoreSuggestions(user, '9'),
).toEqual({ restored: 3 });
expect(restore).toHaveBeenCalledWith(9, 1);
expect(restore).toHaveBeenCalledWith(9, 1, undefined);
expect(
await thrownAsync(() =>
ctl(
@@ -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 });
});
});
+56 -1
View File
@@ -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<T extends object>(
// 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', () => {