feat(tours): point a tour at its trip's packing list

A tour can now point at the packing list of its trip, as a whole or at one
of its categories (say "Hiking gear" for a hike), and shows it in its detail.
The new Packing list section reads the list live as the user may see it: the
backpack tile with the item count on its corner and the category's own
colour, how many are packed with a thin progress bar, the first items ticked
off or not with their quantity, how many more there are, and a button that
opens the trip's Lists tab. Who may change the tour links one, changes it or
lets go through a dialog that lists the whole list and every category with
its colour and item count, and writes only on its confirm.

It is a reference, never a copy: the items stay in Packing, are ticked off
and edited there, and each traveller sees in the tour only the items Packing
shows them, so an item kept to oneself never shows through a tour. A
category that is renamed, cleared or only holds items of others reads as
missing, an empty one as empty, a link that could not be read says so with a
retry, and while Lists are switched off the link stays, dimmed, and can
still be let go. Removing the link, or the tour, leaves the list and every
item as they are. Nothing suggests or changes an item by itself.

On the server the link is a row of its own in the tours domain
(tour_packing_links, migration 046110), one per tour and gone with it.
GET, PUT and DELETE /api/trips/:tripId/tours/:placeId/packing-link read it
with the trip and change it with place_edit; linking needs the Lists addon
and, for a category, an item of it the user can see. The writes answer the
tour with its link and tell the trip through tours:changed, so the offline
client queues them as writes of the tour and replays them like one. A copied
trip keeps its tours' links, a tour saved as new or taken over does not.
The MCP tools get_tour_packing, link_tour_packing and unlink_tour_packing run
over the same service and checks; the read also hands the tour's facts as
context for suggesting what to pack, which only becomes an item through the
packing tools. The empty category placeholder now lives in @trek/shared so
both sides leave it out alike. All 27 locales carry the new strings, and
the wiki describes the link and the tools.
This commit is contained in:
Maurice
2026-10-20 13:56:42 +02:00
parent 65fe8cd6e5
commit f833a05461
106 changed files with 3661 additions and 22 deletions
@@ -0,0 +1,29 @@
import { EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core';
import { TourPackingLinksRepository } from '../repositories/TourPackingLinks.repository';
import { DbTimestampType } from '../types';
import { Tours } from './Tours.entity';
import { Users } from './Users.entity';
export class TourPackingLinks {
[EntityRepositoryType]?: TourPackingLinksRepository;
[PrimaryKeyProp]?: 'place';
place?: Ref<Tours> | null;
place_id?: number | null;
category?: string | null;
linked_by?: number | null;
linked_at!: string & Opt;
linkedByRef?: Ref<Users> | null;
}
export const TourPackingLinksSchema = defineEntity({
class: TourPackingLinks,
repository: () => TourPackingLinksRepository,
properties: {
place: () => p.oneToOne(Tours).primary().ref().name('place_id').deleteRule('cascade').nullable().hidden(),
place_id: p.integer().nullable().persist(false),
category: p.text().nullable(),
linked_by: p.integer().nullable().persist(false).index('idx_tour_packing_links_linked_by'),
linked_at: p.type(DbTimestampType).defaultRaw(`CURRENT_TIMESTAMP`),
linkedByRef: () => p.manyToOne(Users).ref().joinColumn('linked_by').nullable().hidden().index('idx_tour_packing_links_linked_by'),
},
});
+3
View File
@@ -6,6 +6,7 @@ import { TourActivities } from './TourActivities.entity';
import { TourActivityExclusions } from './TourActivityExclusions.entity';
import { TourChangeSteps } from './TourChangeSteps.entity';
import { TourObstacleReviews } from './TourObstacleReviews.entity';
import { TourPackingLinks } from './TourPackingLinks.entity';
import { TourSources } from './TourSources.entity';
import { TourTypes } from './TourTypes.entity';
import { TourWaypoints } from './TourWaypoints.entity';
@@ -56,6 +57,7 @@ export class Tours {
tour_activity_exclusions_collection = new Collection<TourActivityExclusions>(this);
tour_change_steps_collection = new Collection<TourChangeSteps>(this);
tour_obstacle_reviews_collection = new Collection<TourObstacleReviews>(this);
tour_packing_links: Ref<TourPackingLinks> | null = null;
tour_sources: Ref<TourSources> | null = null;
tour_waypoints_collection = new Collection<TourWaypoints>(this);
}
@@ -192,6 +194,7 @@ export const ToursSchema = defineEntity({
tour_activity_exclusions_collection: () => p.oneToMany(TourActivityExclusions).mappedBy('place').hidden(),
tour_change_steps_collection: () => p.oneToMany(TourChangeSteps).mappedBy('place').hidden(),
tour_obstacle_reviews_collection: () => p.oneToMany(TourObstacleReviews).mappedBy('place').hidden(),
tour_packing_links: () => p.oneToOne(TourPackingLinks).ref().mappedBy('place').hidden(),
tour_sources: () => p.oneToOne(TourSources).ref().mappedBy('place').hidden(),
tour_waypoints_collection: () => p.oneToMany(TourWaypoints).mappedBy('place').hidden(),
},
+3
View File
@@ -54,6 +54,7 @@ import { TodoCategoryAssignees } from './TodoCategoryAssignees.entity';
import { TodoItems } from './TodoItems.entity';
import { TourActivities } from './TourActivities.entity';
import { TourActivityExclusions } from './TourActivityExclusions.entity';
import { TourPackingLinks } from './TourPackingLinks.entity';
import { TourSources } from './TourSources.entity';
import { TrekPhotos } from './TrekPhotos.entity';
import { TripAlbumLinks } from './TripAlbumLinks.entity';
@@ -184,6 +185,7 @@ export class Users {
todo_items_collection = new Collection<TodoItems>(this);
tour_activities_collection = new Collection<TourActivities>(this);
tour_activity_exclusions_collection = new Collection<TourActivityExclusions>(this);
tour_packing_links_collection = new Collection<TourPackingLinks>(this);
tour_sources_collection = new Collection<TourSources>(this);
trek_photos_collection = new Collection<TrekPhotos>(this);
trip_album_links_collection = new Collection<TripAlbumLinks>(this);
@@ -332,6 +334,7 @@ export const UsersSchema = defineEntity({
todo_items_collection: () => p.oneToMany(TodoItems).mappedBy('assignedUser').hidden(),
tour_activities_collection: () => p.oneToMany(TourActivities).mappedBy('assignedByRef').hidden(),
tour_activity_exclusions_collection: () => p.oneToMany(TourActivityExclusions).mappedBy('user').hidden(),
tour_packing_links_collection: () => p.oneToMany(TourPackingLinks).mappedBy('linkedByRef').hidden(),
tour_sources_collection: () => p.oneToMany(TourSources).mappedBy('importedByRef').hidden(),
trek_photos_collection: () => p.oneToMany(TrekPhotos).mappedBy('owner').hidden(),
trip_album_links_collection: () => p.oneToMany(TripAlbumLinks).mappedBy('user').hidden(),
+2
View File
@@ -112,6 +112,7 @@ import { TourActivitiesSchema } from './TourActivities.entity';
import { TourActivityExclusionsSchema } from './TourActivityExclusions.entity';
import { TourChangeStepsSchema } from './TourChangeSteps.entity';
import { TourObstacleReviewsSchema } from './TourObstacleReviews.entity';
import { TourPackingLinksSchema } from './TourPackingLinks.entity';
import { TourSourcesSchema } from './TourSources.entity';
import { TourStationVisitsSchema } from './TourStationVisits.entity';
import { TourTypesSchema } from './TourTypes.entity';
@@ -261,6 +262,7 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [
TourActivityExclusionsSchema,
TourChangeStepsSchema,
TourObstacleReviewsSchema,
TourPackingLinksSchema,
TourSourcesSchema,
TourStationVisitsSchema,
TourTypesSchema,
@@ -0,0 +1,10 @@
// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
// change the migration, then run `npm run gen:db-types`.
import type { InsertOptional } from './columns';
export interface TourPackingLinksTable {
place_id: InsertOptional<number>;
category: InsertOptional<string | null>;
linked_by: InsertOptional<number | null>;
linked_at: InsertOptional<string>;
}
+2
View File
@@ -118,6 +118,7 @@ import type { TourActivitiesTable } from './TourActivities.table';
import type { TourActivityExclusionsTable } from './TourActivityExclusions.table';
import type { TourChangeStepsTable } from './TourChangeSteps.table';
import type { TourObstacleReviewsTable } from './TourObstacleReviews.table';
import type { TourPackingLinksTable } from './TourPackingLinks.table';
import type { TourSourcesTable } from './TourSources.table';
import type { TourStationVisitsTable } from './TourStationVisits.table';
import type { TourTypesTable } from './TourTypes.table';
@@ -273,6 +274,7 @@ export interface DB {
tour_activity_exclusions: TourActivityExclusionsTable;
tour_change_steps: TourChangeStepsTable;
tour_obstacle_reviews: TourObstacleReviewsTable;
tour_packing_links: TourPackingLinksTable;
tour_sources: TourSourcesTable;
tour_station_visits: TourStationVisitsTable;
tour_types: TourTypesTable;
@@ -0,0 +1,25 @@
import { Migration } from '@mikro-orm/migrations';
/**
* P2D-XD-01: a tour points at the packing list of its trip, as a whole or at
* one of its categories. The row only names what it points at: `category` is
* NULL for the whole list, otherwise the category as its items carry it. The
* items stay Packing's, so nothing here copies, owns or deletes one, and
* removing the row leaves the list as it was. One link per tour, gone with
* the tour; `linked_by` turns unknown when that account goes.
*/
export class Migration20200101046110_a_tour_points_at_its_packing_list extends Migration {
override name = 'Migration20200101046110_a_tour_points_at_its_packing_list';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS tour_packing_links (
place_id INTEGER PRIMARY KEY REFERENCES tours(place_id) ON DELETE CASCADE,
category TEXT,
linked_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
linked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
this.addSql('CREATE INDEX IF NOT EXISTS idx_tour_packing_links_linked_by ON tour_packing_links(linked_by)');
}
}
@@ -0,0 +1,55 @@
import { currentTimestampKysely } from '../dialect/kysely-functions';
import type { TourPackingLinks } from '../entities/TourPackingLinks.entity';
import type { DB } from '../kysely/db';
import { TrekRepository } from './_shared/trek-repository';
type LinksDB = Pick<DB, 'tour_packing_links'>;
/** What a tour points at in its trip's packing list: null for the whole list, otherwise one category. */
interface TourPackingLinkRow {
category: string | null;
linked_at: string;
}
/**
* `tour_packing_links` (P2D-XD-01): the packing list, or the category of it,
* a tour points at. One row per tour, gone with the tour. It names what it
* points at and nothing else, so no statement here reaches a packing item.
*/
export class TourPackingLinksRepository extends TrekRepository<TourPackingLinks> {
private db() {
return this.kysely<LinksDB>();
}
/** The tour's link; undefined while it points at nothing. */
async findForTour(place_id: number): Promise<TourPackingLinkRow | undefined> {
return await this.db()
.selectFrom('tour_packing_links')
.select(['category', 'linked_at'])
.where('place_id', '=', place_id)
.executeTakeFirst();
}
/** Points the tour at the list or a category, replacing what it pointed at before; the time is the new link's. */
async put(place_id: number, category: string | null, linked_by: number): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.db()
.insertInto('tour_packing_links')
.values({ place_id, category, linked_by })
.onConflict((oc) =>
oc.column('place_id').doUpdateSet((eb) => ({
category: eb.ref('excluded.category'),
linked_by: eb.ref('excluded.linked_by'),
// The clock in the spelling of the live database: CURRENT_TIMESTAMP is no function call.
linked_at: currentTimestampKysely(platform),
})),
)
.execute();
}
/** Lets go of what the tour points at; false when it pointed at nothing. */
async remove(place_id: number): Promise<boolean> {
const result = await this.db().deleteFrom('tour_packing_links').where('place_id', '=', place_id).executeTakeFirst();
return Number(result.numDeletedRows ?? 0) > 0;
}
}
@@ -215,6 +215,7 @@ type ToursKyselyDB = Pick<
| 'tour_sources'
| 'days'
| 'tour_obstacle_reviews'
| 'tour_packing_links'
| 'trips'
>;
@@ -569,6 +570,35 @@ export class ToursRepository extends TrekRepository<Tours> {
.execute();
}
/**
* P2D-XD-01: the packing list or category a copied tour pointed at, onto its
* copy in the copied trip, whose packing list is copied too. Nothing for a
* tour that pointed at nothing.
*/
async copyPackingLink(source_place_id: number, place_id: number): Promise<void> {
await this.db()
.insertInto('tour_packing_links')
.columns(['place_id', 'category', 'linked_by', 'linked_at'])
.expression((eb) =>
eb
.selectFrom('tour_packing_links')
.select((inner) => [inner.val(place_id).as('place_id'), 'category', 'linked_by', 'linked_at'])
.where('place_id', '=', source_place_id),
)
.execute();
}
/**
* What a copied trip's tour keeps besides its facet and its points: its
* import record, its obstacle decisions and its packing link, onto its copy.
* Called inside the trip copy's transaction.
*/
async copyTourRelations(source_place_id: number, place_id: number): Promise<void> {
await this.copySource(source_place_id, place_id);
await this.copyObstacleReviews(source_place_id, place_id);
await this.copyPackingLink(source_place_id, place_id);
}
/** The days every tour of a trip sits on, each tour and day once, in day order, with the stage a day holds (MDT-02). */
async listTourDays(trip_id: number): Promise<TourDayRow[]> {
return await this.db()
@@ -0,0 +1,5 @@
import { tourPackingLinkRequestSchema } from '@trek/shared';
import { createZodDto } from 'nestjs-zod';
export class TourPackingLinkDto extends createZodDto(tourPackingLinkRequestSchema) {}
@@ -0,0 +1,56 @@
import { ADDON_IDS } from '../../addons';
import type { User } from '../../types';
import { AddonGuard } from '../addons/addon.guard';
import { RequireAddon } from '../addons/require-addon.decorator';
import { CurrentUser } from '../auth-core/current-user.decorator';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { ResponseContract } from '../common/response-contract';
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
import { TourPackingLinkDto } from './dto/tour-packing-link.dto';
import { TourPackingLinkService } from './tour-packing-link.service';
import { Body, Controller, Delete, Get, Headers, Param, Put, UseGuards } from '@nestjs/common';
import { tourDetailResponseSchema, tourPackingLinkResponseSchema } from '@trek/shared';
/**
* P2D-XD-01 /api/trips/:tripId/tours/:placeId/packing-link: the packing list,
* or the category of it, a tour points at. Reading it needs the trip; pointing
* the tour somewhere else or letting go needs 'place_edit', as every other
* change of a tour does. The writes answer the tour with its link, so a write
* replayed from the offline queue lands like any other tour write.
*/
@Controller('api/trips/:tripId/tours')
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
@RequireAddon(ADDON_IDS.TOURS, 'Tours')
export class TourPackingLinkController {
constructor(private readonly packingLinks: TourPackingLinkService) {}
@Get(':placeId/packing-link')
@ResponseContract(tourPackingLinkResponseSchema)
async read(@Param('tripId') tripId: string, @Param('placeId') placeId: string) {
return await this.packingLinks.read(tripId, placeId);
}
@RequirePermission('place_edit')
@Put(':placeId/packing-link')
@ResponseContract(tourDetailResponseSchema)
async link(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('placeId') placeId: string,
@Body() body: TourPackingLinkDto,
@Headers('x-socket-id') socketId?: string,
) {
return await this.packingLinks.link(tripId, placeId, body, user.id, socketId);
}
@RequirePermission('place_edit')
@Delete(':placeId/packing-link')
@ResponseContract(tourDetailResponseSchema)
async unlink(
@Param('tripId') tripId: string,
@Param('placeId') placeId: string,
@Headers('x-socket-id') socketId?: string,
) {
return await this.packingLinks.unlink(tripId, placeId, socketId);
}
}
@@ -0,0 +1,193 @@
import { ADDON_IDS } from '../../addons';
import { TourPackingLinks } from '../../db/entities/TourPackingLinks.entity';
import { Tours } from '../../db/entities/Tours.entity';
import type { TourPackingLinksRepository } from '../../db/repositories/TourPackingLinks.repository';
import type { TourListRow, ToursRepository } from '../../db/repositories/Tours.repository';
import { AddonsService } from '../addons/addons.service';
import { DomainError } from '../common/domain-error';
import { toRowId } from '../common/row-id';
import { PackingService } from '../packing/packing.service';
import { ToursService } from './tours.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import {
linkedPackingItems,
type TourDetailResponse,
type TourPackingLink,
type TourPackingLinkRequest,
type TourPackingLinkResponse,
type TourPackingLinkState,
} from '@trek/shared';
/** A packing item as the viewer may see it, with what a link shows of it. */
interface VisiblePackingItem {
id: number;
name: string;
category: string | null;
checked: number | null;
quantity: number;
}
/** One item of a linked list as an assistant reads it. */
interface TourPackingViewItem {
id: number;
name: string;
category: string | null;
quantity: number;
packed: boolean;
}
/**
* P2D-XD-01: a tour's packing link as an assistant reads it: what it points
* at, the items of it the user may see with how many are packed, and the
* tour's own facts as context for a suggestion. Unknown facts stay null.
*/
interface TourPackingView {
packing_link: TourPackingLink | null;
/** How the link reads; null while the tour points at nothing. */
state: TourPackingLinkState | null;
items: TourPackingViewItem[];
packed: number;
total: number;
tour: {
name: string;
tour_type: string;
distance_km: number | null;
elevation_gain_m: number | null;
elevation_loss_m: number | null;
walking_minutes: number | null;
planned_duration_minutes: number | null;
difficulty: string | null;
max_hiking_difficulty: number | null;
};
}
/** A saved tour of the trip, by the ids a route or a tool names it with. */
interface TourInTrip {
tripId: number;
placeId: number;
tour: TourListRow;
}
/**
* P2D-XD-01: a tour points at the packing list of its trip, as a whole or at
* one of its categories. Packing keeps the list, its items and who sees which:
* the link is read against the items the user may see, and nothing here
* copies, changes or deletes one. Linking needs the Packing addon on and, for a
* category, that the user can see an item of it; removing a link leaves the
* list as it was. REST and MCP call the same methods.
*/
@Injectable()
export class TourPackingLinkService {
constructor(
@InjectRepository(TourPackingLinks) private readonly links: TourPackingLinksRepository,
@InjectRepository(Tours) private readonly toursRepo: ToursRepository,
private readonly tours: ToursService,
private readonly packing: PackingService,
private readonly addons: AddonsService,
) {}
/** The saved tour of this trip; 404 for a tour of another trip, a plain place or a malformed id. */
private async tourIn(tripId: number | string, placeId: number | string): Promise<TourInTrip> {
const tid = toRowId(tripId);
const pid = toRowId(placeId);
const tour = tid !== null && pid !== null ? await this.toursRepo.findInTrip(tid, pid) : undefined;
if (!tour || tid === null || pid === null) throw new DomainError(404, 'Tour not found');
return { tripId: tid, placeId: pid, tour };
}
/** Packing has to be on for a link to be made or read through. */
private async requirePacking(): Promise<void> {
if (!(await this.addons.isAddonEnabled(ADDON_IDS.PACKING))) {
throw new DomainError(404, 'Packing addon is not enabled', { code: 'packing_addon_off' });
}
}
/** The packing items of the trip this user may see, in the order Packing lists them. */
private async visibleItems(tripId: number, userId: number): Promise<VisiblePackingItem[]> {
const items: VisiblePackingItem[] = await this.packing.listItems(tripId, userId);
return items;
}
/** What the tour points at; null while it points at nothing. */
async read(tripId: number | string, placeId: number | string): Promise<TourPackingLinkResponse> {
const { placeId: pid } = await this.tourIn(tripId, placeId);
return { packing_link: (await this.links.findForTour(pid)) ?? null };
}
/**
* Points the tour at the whole packing list or at one of its categories,
* replacing what it pointed at. A category must hold an item this user can
* see, so a link never names one they cannot reach. Answers the tour with
* its new link.
*/
async link(
tripId: string,
placeId: string,
body: TourPackingLinkRequest,
userId: number,
socketId?: string,
): Promise<TourDetailResponse> {
const tour = await this.tourIn(tripId, placeId);
await this.requirePacking();
if (body.category !== null) {
const items = await this.visibleItems(tour.tripId, userId);
if (!items.some((item) => item.category === body.category)) {
throw new DomainError(400, 'Packing category not found', { code: 'packing_category_not_found' });
}
}
await this.links.put(tour.placeId, body.category, userId);
return await this.announce(tripId, tour.placeId, socketId);
}
/** Lets go of what the tour points at. The packing list and its items stay as they are. */
async unlink(tripId: string, placeId: string, socketId?: string): Promise<TourDetailResponse> {
const tour = await this.tourIn(tripId, placeId);
await this.links.remove(tour.placeId);
return await this.announce(tripId, tour.placeId, socketId);
}
/** Tells the trip's other clients and answers the tour with its link as it is now. */
private async announce(tripId: string, placeId: number, socketId?: string): Promise<TourDetailResponse> {
this.tours.broadcastToursChanged(tripId, [placeId], socketId);
const detail = await this.tours.getTour(tripId, String(placeId));
return { ...detail, packing_link: (await this.links.findForTour(placeId)) ?? null };
}
/**
* The link as an assistant reads it: the items the user may see of what it
* points at, and the tour's facts as context. Reading changes nothing.
*/
async view(tripId: number, placeId: number, userId: number): Promise<TourPackingView> {
const { tour } = await this.tourIn(tripId, placeId);
await this.requirePacking();
const link = (await this.links.findForTour(placeId)) ?? null;
const context = {
name: tour.name,
tour_type: tour.tour_type,
distance_km: tour.distance,
elevation_gain_m: tour.elevation_gain,
elevation_loss_m: tour.elevation_loss,
walking_minutes: tour.duration,
planned_duration_minutes: tour.planned_duration_minutes,
difficulty: tour.difficulty,
max_hiking_difficulty: tour.max_hiking_difficulty,
};
if (!link) return { packing_link: null, state: null, items: [], packed: 0, total: 0, tour: context };
const linked = linkedPackingItems(link, await this.visibleItems(tripId, userId));
return {
packing_link: link,
state: linked.state,
items: linked.items.map((item) => ({
id: item.id,
name: item.name,
category: item.category,
quantity: item.quantity,
packed: Boolean(item.checked),
})),
packed: linked.packed,
total: linked.items.length,
tour: context,
};
}
}
+104
View File
@@ -0,0 +1,104 @@
import { ADDON_IDS } from '../../addons';
import { canRead, canReadTrips, canWrite } from '../../mcp/scopes';
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
import {
McpController,
Tool,
TOOL_ANNOTATIONS_READONLY,
TOOL_ANNOTATIONS_WRITE,
ok,
type McpContext,
} from '../../nest-mcp';
import { addonGate, type HasAddons } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { TripAccessService } from '../trip-membership/trip-access.service';
import { TourPackingLinkService } from './tour-packing-link.service';
import { idSchema, tourPackingLinkRequestSchema, type TourPackingLinkRequest } from '@trek/shared';
const toursOn = addonGate(ADDON_IDS.TOURS);
const packingOn = addonGate(ADDON_IDS.PACKING);
/** A tour's packing link reaches into Packing, so both addons have to be on. */
async function tourPackingOn(ctx: McpContext, self: HasAddons): Promise<boolean> {
return (await toursOn(ctx, self)) && (await packingOn(ctx, self));
}
const tourIds = {
tripId: idSchema,
placeId: idSchema.describe('The place id of the tour, as list_tours gives it'),
};
/**
* P2D-XD-01: the packing list a tour points at, over the same service and the
* same checks as REST: reading needs the trip, pointing the tour somewhere and
* letting go need 'place_edit'. Packing keeps its items; nothing here adds,
* ticks off or removes one.
*/
@McpController()
export class TourPackingMcp {
constructor(
private readonly packingLinks: TourPackingLinkService,
private readonly access: TripAccessService,
private readonly guards: McpToolGuardsService,
// Read by the addon gate of every tool here.
readonly addons: AddonsService,
) {}
/** The trip open to the user and, for a write, 'place_edit' on it; the refusal to answer, or null. */
private async refusal(tripId: number, userId: number, write: boolean) {
if (!(await this.access.findAccessible(tripId, userId))) return noAccess();
if (write && !(await this.guards.hasTripPermission('place_edit', tripId, userId))) return permissionDenied();
return null;
}
@Tool({
name: 'get_tour_packing',
description:
'Read the packing list a saved tour points at: packing_link names it (category null for the whole packing list of the trip, otherwise one category of it; null while the tour points at nothing), state says how it reads (ready, empty while it holds no item yet, or missing when its category has no item the user can see any more, renamed, cleared or kept to other travellers), and items lists what the user may see of it with quantity and whether each is packed. tour carries the tour’s own facts (type, distance in km, ascent and descent in metres, walking and planned minutes, difficulty, the SAC limit of a hike), unknown ones null, as context for suggesting what to pack. Suggestions stay suggestions: add an item only through the packing tools and only when the user asks for it.',
inputSchema: tourIds,
annotations: TOOL_ANNOTATIONS_READONLY,
when: tourPackingOn,
access: (ctx) => canReadTrips(ctx.scopes) && canRead(ctx.scopes, 'packing'),
})
async getTourPacking({ tripId, placeId }: { tripId: number; placeId: number }, ctx: McpContext) {
const refused = await this.refusal(tripId, ctx.userId, false);
if (refused) return refused;
return ok(await this.packingLinks.view(tripId, placeId, ctx.userId));
}
@Tool({
name: 'link_tour_packing',
description:
'Point a saved tour at the packing list of its trip: category null for the whole list, or the name of one of its categories exactly as its items carry it (one the user can see an item of). It replaces what the tour pointed at before. Nothing is copied or changed in the packing list. Needs place_edit on the trip and the Packing addon.',
inputSchema: { ...tourIds, category: tourPackingLinkRequestSchema.shape.category },
annotations: TOOL_ANNOTATIONS_WRITE,
when: tourPackingOn,
access: (ctx) => canWrite(ctx.scopes, 'trips') && canRead(ctx.scopes, 'packing'),
})
async linkTourPacking(
{ tripId, placeId, category }: { tripId: number; placeId: number } & TourPackingLinkRequest,
ctx: McpContext,
) {
const refused = await this.refusal(tripId, ctx.userId, true);
if (refused) return refused;
const detail = await this.packingLinks.link(String(tripId), String(placeId), { category }, ctx.userId);
return ok({ packing_link: detail.packing_link ?? null });
}
@Tool({
name: 'unlink_tour_packing',
description:
'Let a saved tour stop pointing at its packing list. The packing list, its categories and its items stay exactly as they are. Needs place_edit on the trip.',
inputSchema: tourIds,
annotations: TOOL_ANNOTATIONS_WRITE,
when: tourPackingOn,
access: { group: 'trips', mode: 'write' },
})
async unlinkTourPacking({ tripId, placeId }: { tripId: number; placeId: number }, ctx: McpContext) {
const refused = await this.refusal(tripId, ctx.userId, true);
if (refused) return refused;
const detail = await this.packingLinks.unlink(String(tripId), String(placeId));
return ok({ packing_link: detail.packing_link ?? null });
}
}
+14 -1
View File
@@ -4,6 +4,7 @@ import { TourActivities } from '../../db/entities/TourActivities.entity';
import { TourActivityExclusions } from '../../db/entities/TourActivityExclusions.entity';
import { TourChangeSteps } from '../../db/entities/TourChangeSteps.entity';
import { TourObstacleReviews } from '../../db/entities/TourObstacleReviews.entity';
import { TourPackingLinks } from '../../db/entities/TourPackingLinks.entity';
import { TourSources } from '../../db/entities/TourSources.entity';
import { TourStationVisits } from '../../db/entities/TourStationVisits.entity';
import { TourTypes } from '../../db/entities/TourTypes.entity';
@@ -15,6 +16,7 @@ import { CollectionsModule } from '../collections/collections.module';
import { MapsModule } from '../maps/maps.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { OverpassModule } from '../overpass/overpass.module';
import { PackingModule } from '../packing/packing.module';
import { PaddleObstaclesModule } from '../paddle-obstacles/paddle-obstacles.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { PlaceImportModule } from '../place-import/place-import.module';
@@ -46,6 +48,9 @@ import { TourLibraryController, TourTakeOverController } from './tour-library.co
import { TourLibraryMcp } from './tour-library.mcp';
import { TourLibraryService } from './tour-library.service';
import { TourObstacleReviewsService } from './tour-obstacle-reviews.service';
import { TourPackingLinkController } from './tour-packing-link.controller';
import { TourPackingLinkService } from './tour-packing-link.service';
import { TourPackingMcp } from './tour-packing.mcp';
import { PaddleCheckCache } from './tour-paddle-check.cache';
import { TourPaddleCheckService } from './tour-paddle-check.service';
import { TourPaddlePlanService } from './tour-paddle-plan.service';
@@ -90,7 +95,9 @@ import { Module } from '@nestjs/common';
* TourActivityStatsService, over the trips TripMembershipModule says they can
* open, with the tours the user left out of them. Whole tours kept in a
* collection (LIB-03) are this domain's own rows; the collection's roles and
* its live notice to its members come from CollectionsService.
* its live notice to its members come from CollectionsService. A tour points at
* the packing list of its trip (P2D-XD-01) through a row of its own; the list
* and who sees which of its items stay PackingService's.
*/
@Module({
imports: [
@@ -116,6 +123,8 @@ import { Module } from '@nestjs/common';
AssignmentsDomainModule,
// A collection keeps whole tours (LIB-03): its roles decide who sees, keeps and removes one.
CollectionsModule,
// A tour's packing link is read against the items Packing lets the user see (P2D-XD-01).
PackingModule,
MikroOrmModule.forFeature([
CollectionTours,
Tours,
@@ -123,6 +132,7 @@ import { Module } from '@nestjs/common';
TourWaypoints,
TourSources,
TourObstacleReviews,
TourPackingLinks,
TourStationVisits,
TourChangeSteps,
TourActivities,
@@ -147,6 +157,7 @@ import { Module } from '@nestjs/common';
ToursWandererController,
TourLibraryController,
CollectionToursController,
TourPackingLinkController,
],
providers: [
ToursService,
@@ -171,6 +182,7 @@ import { Module } from '@nestjs/common';
TourActivitiesService,
TourActivityStatsService,
CollectionToursService,
TourPackingLinkService,
ToursMcp,
TourLibraryMcp,
TourWalkImportMcp,
@@ -180,6 +192,7 @@ import { Module } from '@nestjs/common';
TourActivitiesMcp,
TourActivityStatsMcp,
CollectionToursMcp,
TourPackingMcp,
],
})
export class ToursModule {}
+4 -4
View File
@@ -733,8 +733,9 @@ export class TripsService {
if (newPlaceId) await this.tagsRepo.insertIgnore(newPlaceId, [t.tag_id]); // TP47
}
// A Tour is its place plus the `tours` facet, its import record, its obstacle decisions and the waypoints its
// route was planned through. The facet row goes first, since the others reference it; without them it is no Tour.
// A Tour is its place plus the `tours` facet, its import record, its obstacle decisions, its packing link and
// the waypoints its route was planned through. The facet row goes first, since the others reference it;
// without them it is no Tour.
const oldTours = await this.toursRepo.listRowsForTrip(Number(sourceTripId));
const copiedTours = new Map<number, number>();
for (const { place_id, ...tour } of oldTours) {
@@ -743,8 +744,7 @@ export class TripsService {
await this.toursRepo.insertCopy(newPlaceId, tour, placeMap);
copiedTours.set(place_id, newPlaceId);
}
await Promise.all([...copiedTours].map(([from, to]) => this.toursRepo.copySource(from, to)));
await Promise.all([...copiedTours].map(([from, to]) => this.toursRepo.copyObstacleReviews(from, to)));
for (const [from, to] of copiedTours) await this.toursRepo.copyTourRelations(from, to);
const waypointsByPlace = new Map<number, TourWaypointRow[]>();
for (const { place_id, ...waypoint } of await this.tourWaypointsRepo.listForTrip(Number(sourceTripId))) {