mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
fix(realtime): journey, atlas, vacay and collection writes in parity
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user