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