feat(assistant): collection list and saved place cards

This commit is contained in:
Maurice
2026-10-22 15:50:52 +02:00
parent 237e4e4897
commit fd15a7343e
68 changed files with 6052 additions and 92 deletions
@@ -378,14 +378,14 @@ export class CollectionPlacesRepository extends TrekRepository<CollectionPlaces>
collectionIds: number[],
query: { google_place_id?: string; google_ftid?: string; lat?: number; lng?: number },
tolerance: number,
): Promise<{ place_id: number; collection_id: number; name: string; status: string }[]> {
): Promise<{ place_id: number; collection_id: number; name: string; color: string | null; status: string }[]> {
if (collectionIds.length === 0) return [];
const hasCoords = query.lat != null && query.lng != null;
if (!query.google_place_id && !query.google_ftid && !hasCoords) return [];
return await this.kysely<CollectionPlacesMembershipKyselyDB>()
.selectFrom('collection_places as cp')
.innerJoin('collections as c', 'c.id', 'cp.collection_id')
.select(['cp.id as place_id', 'cp.collection_id', 'c.name', 'cp.status'])
.select(['cp.id as place_id', 'cp.collection_id', 'c.name', 'c.color', 'cp.status'])
.where('cp.collection_id', 'in', collectionIds)
.where((eb) => {
const clauses = [];
@@ -0,0 +1,121 @@
import type { AnswerInput } from '../card-types';
import { clipped, recordOf, type Row } from '../card-values';
import { rowsOf } from '../undo-snapshot';
import {
colorOf,
listOf,
listRowOf,
MAX_LISTS,
MAX_PLACES,
positiveId,
savedPlacesOf,
statusOf,
tally,
usedLabels,
} from './collection-values';
import type { CollectionCardList, CollectionListRow, CollectionListsCard, CollectionPlacesCard } from '@trek/shared';
/**
* The cards the collections' reads draw beside the answer: a list with its
* first places (`get_collection`), saved places asked for by id
* (`get_collection_items`), the user's lists with the invites waiting
* (`list_collections`) and the lists a place is saved in
* (`find_place_in_collections`). Who may be invited (`available_collection_users`)
* stays a sentence: a list of user names says nothing a card would.
*/
/** A places card of `rows`, under `list` when one list holds them all. */
function placesCard(
list: CollectionCardList | null,
rows: readonly Row[],
input: AnswerInput,
labelPool: unknown,
whole: readonly Row[] | null,
): CollectionPlacesCard | null {
const places = savedPlacesOf(rows, input.card);
if (!list && places.length === 0) return null;
const shown = places.slice(0, MAX_PLACES);
return {
kind: 'collectionPlaces',
list,
places: shown,
more: places.length - shown.length,
labels: usedLabels(labelPool, shown, whole),
outcome: null,
everyList: false,
otherTrip: false,
};
}
/** One list read whole: its head with the places per status, its first places, the labels they carry. */
export function collectionAnswer(input: AnswerInput): CollectionPlacesCard | null {
const root = recordOf(input.value);
const collection = recordOf(root.collection);
const places = rowsOf(root.places);
const list = listOf(collection, tally(places));
return list ? placesCard(list, places, input, collection.labels, places) : null;
}
/** Saved places asked for by id: under their list when they share one, labels named but not counted. */
export function itemsAnswer(input: AnswerInput): CollectionPlacesCard | null {
const root = recordOf(input.value);
const places = rowsOf(root.places);
const lists = rowsOf(root.lists);
const listIds = new Set(places.map((place) => positiveId(place.collection_id)));
const only = listIds.size === 1 ? lists.find((row) => listIds.has(positiveId(row.id))) : undefined;
const pool = lists.flatMap((row) => rowsOf(row.labels));
return places.length > 0 ? placesCard(only ? listOf(only) : null, places, input, pool, null) : null;
}
/** The user's lists in their order, then the invites that wait for an answer. */
export function listsAnswer({ value }: AnswerInput): CollectionListsCard | null {
const root = recordOf(value);
const own = rowsOf(root.collections)
.map((row) => listOf(row))
.filter((list): list is CollectionCardList => list !== null)
.map((list) => listRowOf(list));
const invites = rowsOf(root.incomingInvites)
.map((row) => inviteRowOf(row))
.filter((row): row is CollectionListRow => row !== null);
const rows = [...own, ...invites];
if (rows.length === 0) return null;
return listsCard(rows, null);
}
/** A lists card of `rows`, the ones past its bound counted. */
export function listsCard(rows: readonly CollectionListRow[], place: string | null): CollectionListsCard {
const more = Math.max(0, rows.length - MAX_LISTS);
return { kind: 'collectionLists', lists: rows.slice(0, MAX_LISTS), more, place };
}
/** An invite as a row: the list's name and who sent it; its size is not the invitee's to see. */
export function inviteRowOf(raw: unknown): CollectionListRow | null {
const row = recordOf(raw);
const id = positiveId(row.collection_id);
const name = clipped(row.name, 120);
if (id === null || !name) return null;
const from = recordOf(row.from);
const fromId = positiveId(from.id);
const fromName = clipped(from.username, 80);
return listRowOf(
{ id, name, color: null, placeCount: null, memberCount: 0, counts: null },
{ invite: true, person: fromId !== null && fromName ? { id: fromId, name: fromName, avatarKey: null } : null },
);
}
/** Where a place is saved, with its status in each list; nothing when it is saved nowhere. */
export function membershipAnswer({ args, value }: AnswerInput): CollectionListsCard | null {
const root = recordOf(value);
if (root.saved !== true) return null;
const seen = new Set<number>();
const rows: CollectionListRow[] = [];
for (const row of rowsOf(root.lists)) {
const id = positiveId(row.collection_id);
const name = clipped(row.name, 120);
if (id === null || !name || seen.has(id)) continue;
seen.add(id);
const list = { id, name, color: colorOf(row.color), placeCount: null, memberCount: 0, counts: null };
rows.push(listRowOf(list, { status: statusOf(row.status) }));
}
return rows.length > 0 ? listsCard(rows, clipped(args.name, 120)) : null;
}
@@ -0,0 +1,258 @@
import type { ToolArgs, ToolCards, UndoSnapshot } from '../card-types';
import { argId, clipped, readKey, recordOf, type Row } from '../card-values';
import { rowsOf, same } from '../undo-snapshot';
import { listsCard } from './collection-answers';
import { colorOf, idsIn, listOf, listRowOf, positiveId, savedPlaceOf } from './collection-values';
import type {
AssistantChangePreview,
CollectionCardPlace,
CollectionListsCard,
CollectionPlacesCard,
TrekCard,
} from '@trek/shared';
/**
* What an applied change of the collections shows in place of its preview:
* the saved place as it is now (its id, its own photo), what a copy or a
* status set across lists did, the list or label as the tool answered. Each
* builds on the stored preview card, so it needs no read, and keeps what only
* the preview knew (the place in the trip, what the change did to it). Also:
* the ids a receipt names, and the way back from a created list or a saved
* place while nothing changed it since.
*/
type AppliedInput = { args: ToolArgs; result: unknown; preview: AssistantChangePreview | null };
/** The preview's card, when it is of the kind asked for. */
function previewCard<K extends TrekCard['kind']>(
preview: AssistantChangePreview | null,
kind: K,
): Extract<TrekCard, { kind: K }> | null {
const card = preview?.card ?? null;
return card?.kind === kind ? (card as Extract<TrekCard, { kind: K }>) : null;
}
/** The saved place a tool answered with, keeping what only the preview knew about it. */
function answeredPlace(result: unknown, before: CollectionCardPlace | undefined): CollectionCardPlace | null {
const place = savedPlaceOf(recordOf(result).place, null);
if (!place) return null;
return {
...place,
inTripPlaceId: before?.inTripPlaceId ?? null,
savedIn: before?.savedIn ?? [],
delta: before?.delta ?? null,
};
}
/** A change of one saved place: the place as it is now, under the preview's list. */
function placeApplied({ result, preview }: AppliedInput): CollectionPlacesCard | null {
const card = previewCard(preview, 'collectionPlaces');
const place = answeredPlace(result, card?.places[0]);
if (!place) return null;
return {
kind: 'collectionPlaces',
list: card?.list ?? null,
places: [place],
more: 0,
labels: card?.labels ?? [],
outcome: null,
everyList: false,
otherTrip: false,
};
}
function countOf(value: unknown): number {
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? Math.min(value, 1_000_000) : 0;
}
/**
* A copy (into a trip or into a list): how many went in and which were there
* already. The outcome names the ones skipped, so the places no longer carry
* the warning the preview gave.
*/
function copyApplied({ result, preview }: AppliedInput): CollectionPlacesCard | null {
const card = previewCard(preview, 'collectionPlaces');
if (!card) return null;
const places = card.otherTrip ? card.places.map((place) => ({ ...place, inTripPlaceId: null })) : card.places;
const skipped = rowsOf(recordOf(result).skipped);
const skippedNames = skipped
.map((row) => clipped(row.name, 120))
.filter((name): name is string => name !== null)
.slice(0, 5);
return {
...card,
places,
outcome: { type: 'copied', copied: countOf(recordOf(result).copied), skipped: skipped.length, skippedNames },
};
}
function savedApplied(input: AppliedInput): CollectionPlacesCard | null {
const root = recordOf(input.result);
if (root.duplicate !== true) return placeApplied(input);
const card = previewCard(input.preview, 'collectionPlaces');
const name = clipped(recordOf(root.duplicateOf).name, 120) ?? card?.places[0]?.name ?? null;
return card && name ? { ...card, outcome: { type: 'duplicate', name } } : null;
}
function statusSetApplied({ result, preview }: AppliedInput): CollectionPlacesCard | null {
const card = previewCard(preview, 'collectionPlaces');
const root = recordOf(result);
if (!card) return null;
return { ...card, outcome: { type: 'statusSet', updated: countOf(root.updated), found: countOf(root.places) } };
}
/** A list the tool created or changed, as it answered; a change keeps what it was before. */
function listApplied(change: 'add' | 'update') {
return ({ result, preview }: AppliedInput): CollectionListsCard | null => {
const list = listOf(recordOf(result).collection);
if (!list) return null;
const before = previewCard(preview, 'collectionLists')?.lists[0]?.before ?? null;
return listsCard([listRowOf(list, { change, before: change === 'update' ? before : null })], null);
};
}
/** A label the tool created or changed, in place of the one the preview named. */
function labelApplied({ result, preview }: AppliedInput): CollectionPlacesCard | null {
const card = previewCard(preview, 'collectionPlaces');
const label = recordOf(recordOf(result).label);
const name = clipped(label.name, 60);
const shown = card?.labels[0];
if (!card || !shown || !name) return null;
const color = colorOf(label.color) ?? shown.color;
return { ...card, labels: [{ ...shown, id: positiveId(label.id), name, color }] };
}
export const COLLECTION_APPLIED: Readonly<Record<string, (input: AppliedInput) => TrekCard | null>> = {
save_place_to_collection: savedApplied,
update_collection_place: placeApplied,
set_collection_place_status: placeApplied,
rate_collection_place: placeApplied,
copy_collection_places_to_trip: copyApplied,
save_trip_places_to_collection: copyApplied,
set_collection_place_status_from_trip: statusSetApplied,
create_collection: listApplied('add'),
update_collection: listApplied('update'),
create_collection_label: labelApplied,
update_collection_label: labelApplied,
};
/** The ids a receipt names where the answer does not name them under the usual keys. */
export const COLLECTION_APPLIED_IDS: Readonly<Record<string, (args: ToolArgs, result: unknown) => number[]>> = {
create_collection: (_args, result) => idsOf(positiveId(recordOf(recordOf(result).collection).id)),
update_collection: (args) => idsOf(argId(args, 'collectionId')),
delete_collection: (args) => idsOf(argId(args, 'collectionId')),
save_trip_places_to_collection: (args) => idsOf(argId(args, 'collectionId')),
accept_collection_invite: (args) => idsOf(argId(args, 'collectionId')),
decline_collection_invite: (args) => idsOf(argId(args, 'collectionId')),
cancel_collection_invite: (args) => idsOf(argId(args, 'collectionId')),
create_collection_label: (args) => idsOf(argId(args, 'collection_id')),
update_collection_label: (_args, result) => idsOf(positiveId(recordOf(recordOf(result).label).collection_id)),
// The new trip places, so "Show on map" opens the first of them.
copy_collection_places_to_trip: (_args, result) =>
rowsOf(recordOf(result).created)
.map((row) => positiveId(row.id))
.filter((id): id is number => id !== null),
};
function idsOf(id: number | null): number[] {
return id === null ? [] : [id];
}
/** The id of what a create answered with under `key`, as it came (the read is made whatever it is). */
function createdId(result: unknown, key: string): unknown {
return recordOf(recordOf(result)[key]).id;
}
/** What a read the undo made answered, by its tool and arguments. */
function readOf(snapshot: UndoSnapshot, tool: string, args: Row): Row {
return recordOf(snapshot.reads.get(readKey(tool, args)));
}
/** A list the change created, still as it was: same name, no places, no labels, nobody else in it. */
function listUnchanged(result: unknown, snapshot: UndoSnapshot): boolean {
const created = recordOf(recordOf(result).collection);
const read = readOf(snapshot, 'get_collection', { collectionId: created.id });
const current = recordOf(read.collection);
if (positiveId(current.id) === null || current.id !== created.id) return false;
const others = rowsOf(current.members).filter((member) => member.is_owner !== true);
const labels = rowsOf(current.labels);
return (
same(created, current, ['name', 'color', 'updated_at']) &&
rowsOf(read.places).length === 0 &&
others.length === 0 &&
labels.length === 0
);
}
/** The fields of a saved place that must read as the save left them for the undo to remove it. */
const SAVED_FIELDS = [
'name',
'collection_id',
'status',
'updated_at',
'lat',
'lng',
'address',
'notes',
'description',
'category_id',
'image_url',
] as const;
function sortedLabelIds(row: Row): string {
const ids = idsIn(row.label_ids, 1000);
return ids.sort((a, b) => a - b).join(',');
}
/**
* A saved place the change created, still as it was: same fields, same list,
* same labels, not rated since, in a list the user may still delete from.
*/
function placeUnchanged(result: unknown, snapshot: UndoSnapshot): boolean {
const created = recordOf(recordOf(result).place);
const read = readOf(snapshot, 'get_collection_items', { placeIds: [created.id] });
const current = rowsOf(read.places).find((row) => row.id === created.id);
if (!current) return false;
const list = rowsOf(read.lists).find((row) => row.id === current.collection_id);
return (
list?.can_delete === true &&
same(created, current, SAVED_FIELDS) &&
rowsOf(current.ratings).length === rowsOf(created.ratings).length &&
sortedLabelIds(current) === sortedLabelIds(created)
);
}
/**
* Whether the user who saved a place may remove it again: only a list's owner
* or admin deletes, an editor only adds. The answer names who saved it and
* whose list it is; an admin who is not the owner cannot be told apart from an
* editor here, so the undo is offered to owners only.
*/
function saverOwnsList(place: Row): boolean {
const savedBy = positiveId(place.saved_by);
const owner = positiveId(place.owner_id);
return savedBy === null || owner === null || savedBy === owner;
}
/** The way back from a created list: deleting it, while nothing was added to it or shared since. */
export const DELETE_CREATED_LIST: NonNullable<ToolCards['undo']> = {
tool: 'delete_collection',
args: (_args, result) => {
const collectionId = positiveId(recordOf(recordOf(result).collection).id);
return collectionId === null ? null : { collectionId };
},
reads: (_args, result) => [{ tool: 'get_collection', args: { collectionId: createdId(result, 'collection') } }],
unchanged: listUnchanged,
};
/** The way back from a saved place: removing it while nobody changed, rated or labelled it since. */
export const DELETE_SAVED_PLACE: NonNullable<ToolCards['undo']> = {
tool: 'delete_collection_place',
args: (_args, result) => {
const place = recordOf(recordOf(result).place);
const placeId = positiveId(place.id);
return placeId === null || !saverOwnsList(place) ? null : { placeId };
},
reads: (_args, result) => [{ tool: 'get_collection_items', args: { placeIds: [createdId(result, 'place')] } }],
unchanged: placeUnchanged,
};
@@ -0,0 +1,411 @@
import type { Described, PreviewContext, ToolArgs } from '../card-types';
import { argId, clipped, recordOf } from '../card-values';
import { Rows } from '../preview-format';
import { rowsOf } from '../undo-snapshot';
import { inviteRowOf, listsCard } from './collection-answers';
import { type CollectionLookup, NO_LOOKUP } from './collection-load';
import {
carriedLabelIds,
commonListId,
emptyDelta,
foundPlaces,
itemListOf,
itemsOf,
labelIdSet,
listLabelIds,
namesOf,
placeFromArgs,
placeFromTrip,
placesCard,
tripPlaceIds,
wholeList,
type WholeList,
} from './collection-parts';
import {
argText,
colorOf,
idsIn,
labelOf,
listOf,
listRowOf,
positiveId,
statusArg,
statusOf,
} from './collection-values';
import {
COLLECTION_CARD_FIELDS,
type CollectionCardDelta,
type CollectionCardList,
type CollectionCardPlace,
type CollectionListRow,
type TrekCard,
} from '@trek/shared';
/**
* The change cards of the collections' writes: what a held call will do to a
* list, a saved place or a label, from its arguments and what its lookup read
* (collection-load.ts). Every describer reads `{}` arguments and a failed
* lookup too; the card then shows what the arguments say, or nothing. The
* words of the row stay plain (the subject, the tool's own label); the card
* carries the facts. Only trip places light up on the map: a saved place's id
* is never a trip place's.
*/
type Lookup = CollectionLookup | null;
interface CollectionChange {
describe(ctx: PreviewContext, args: ToolArgs, loaded: Lookup): Described;
change(ctx: PreviewContext, args: ToolArgs, loaded: Lookup): TrekCard | null;
}
function described(tool: string, subject: string | null, placeIds: number[] = []): Described {
return { subject: subject ?? '', rows: new Rows(), dayId: null, placeIds, tool, domain: 'collections' };
}
function lookupOf(loaded: Lookup): CollectionLookup {
return loaded ?? NO_LOOKUP;
}
/** The arguments of a saved place's change that name a field of it, by the card's field names. */
const FIELD_OF: Readonly<Record<string, (typeof COLLECTION_CARD_FIELDS)[number]>> = {
name: 'name',
address: 'address',
lat: 'coordinates',
lng: 'coordinates',
description: 'description',
notes: 'notes',
category_id: 'category',
links: 'link',
image_url: 'photo',
};
function fieldsOf(args: ToolArgs): CollectionCardDelta['fields'] {
const fields = Object.keys(args).flatMap((key) => (FIELD_OF[key] && args[key] !== undefined ? [FIELD_OF[key]] : []));
return COLLECTION_CARD_FIELDS.filter((field) => fields.includes(field));
}
/** The lists a membership lookup found the place to save in already, at most three. */
function savedInOf(membership: unknown): CollectionCardPlace['savedIn'] {
const found: CollectionCardPlace['savedIn'] = [];
for (const row of rowsOf(recordOf(membership).lists)) {
const listId = positiveId(row.collection_id);
const name = clipped(row.name, 120);
if (listId !== null && name && !found.some((saved) => saved.listId === listId)) {
found.push({ listId, name, status: statusOf(row.status) });
}
}
return found.slice(0, 3);
}
/** The names of the first trip places a call names, for its subject. */
function tripNames(ctx: PreviewContext, ids: readonly number[]): string {
return namesOf(ids.slice(0, 3).flatMap((id) => ctx.card.places.get(id) ?? []));
}
/** The one saved place a change names, as its lookup found it. */
function currentPlace(ctx: PreviewContext, args: ToolArgs, loaded: Lookup): CollectionCardPlace | null {
const placeId = argId(args, 'placeId');
return placeId === null ? null : (foundPlaces(ctx, lookupOf(loaded), [placeId])[0] ?? null);
}
/**
* The card of a change to one saved place, under its list; the place carries
* what changes. `moreLabels` names labels of another list (the one it moves to).
*/
function onePlaceCard(
loaded: Lookup,
place: CollectionCardPlace | null,
delta: CollectionCardPlace['delta'],
moreLabels: unknown = null,
) {
if (!place) return null;
const items = itemsOf(lookupOf(loaded));
const list = itemListOf(items.lists, place.listId);
const pool = [...items.lists.flatMap((row) => rowsOf(row.labels)), ...rowsOf(moreLabels)];
return placesCard(list, [{ ...place, delta }], pool);
}
/** A place change: its subject is the place, the card its list and what happens to it. */
function placeChange(tool: string, deltaOf: (args: ToolArgs, place: CollectionCardPlace) => CollectionCardDelta) {
return {
describe: (ctx, args, loaded) => described(tool, currentPlace(ctx, args, loaded)?.name ?? null),
change: (ctx, args, loaded) => {
const place = currentPlace(ctx, args, loaded);
return place ? onePlaceCard(loaded, place, deltaOf(args, place)) : null;
},
} satisfies CollectionChange;
}
/** A change over several saved places, by the ids the call names. */
function manyPlaces(ctx: PreviewContext, args: ToolArgs, loaded: Lookup): CollectionCardPlace[] {
return foundPlaces(ctx, lookupOf(loaded), idsIn(args.place_ids, 50));
}
/** Labels added to (or taken from) each place, as the service applies them: only its own list's, only a real change. */
function labelDeltaOf(ctx: PreviewContext, args: ToolArgs, loaded: Lookup): CollectionCardPlace[] {
const lists = itemsOf(lookupOf(loaded)).lists;
const asked = idsIn(args.label_ids, 50);
const remove = args.remove === true;
return manyPlaces(ctx, args, loaded).map((place) => {
const own = listLabelIds(lists, place.listId);
const carried = carriedLabelIds(lookupOf(loaded), place.id);
const applicable = asked.filter((id) => own.has(id));
const changed = remove
? applicable.filter((id) => carried.includes(id))
: applicable.filter((id) => !carried.includes(id));
const delta = emptyDelta(remove ? { labelsRemoved: changed.slice(0, 10) } : { labelsAdded: changed.slice(0, 10) });
return { ...place, delta };
});
}
/** A list row from the list a lookup read whole. */
function currentListRow(loaded: Lookup): WholeList {
return wholeList(lookupOf(loaded).collection);
}
/** The user's lists as `list_collections` gave them, and the invites waiting. */
function userLists(loaded: Lookup): { own: CollectionCardList[]; invites: CollectionListRow[] } {
const root = recordOf(lookupOf(loaded).lists);
return {
own: rowsOf(root.collections)
.map((row) => listOf(row))
.filter((list): list is CollectionCardList => list !== null),
invites: rowsOf(root.incomingInvites)
.map((row) => inviteRowOf(row))
.filter((row): row is CollectionListRow => row !== null),
};
}
function inviteFor(loaded: Lookup, args: ToolArgs): CollectionListRow | null {
const id = argId(args, 'collectionId');
return userLists(loaded).invites.find((row) => row.id === id) ?? null;
}
/** The label a change names, as its lookup found it, with its list read whole. */
function currentLabel(args: ToolArgs, loaded: Lookup) {
const labelId = argId(args, 'labelId');
const raw = itemsOf(lookupOf(loaded)).labels.find((label) => positiveId(label.id) === labelId);
const whole = wholeList(lookupOf(loaded).collection);
return { label: raw ? labelOf(raw, whole.places) : null, list: whole.list };
}
const LIST_CHANGES: Readonly<Record<string, CollectionChange>> = {
create_collection: {
describe: (_ctx, args) => described('create_collection', argText(args.name, 120)),
change: (_ctx, args) => {
const name = argText(args.name, 120);
if (!name) return null;
const list = { id: null, name, color: colorOf(args.color), placeCount: 0, memberCount: 0, counts: null };
return listsCard([listRowOf(list, { change: 'add' })], null);
},
},
update_collection: {
describe: (_ctx, args, loaded) =>
described('update_collection', argText(args.name, 120) ?? currentListRow(loaded).list?.name ?? null),
change: (_ctx, args, loaded) => {
const { list } = currentListRow(loaded);
if (!list) return null;
const name = argText(args.name, 120) ?? list.name;
const color = args.color === undefined ? list.color : colorOf(args.color);
const renamed = name !== list.name || color !== list.color;
const before = renamed ? { name: list.name, color: list.color } : null;
return listsCard([listRowOf({ ...list, name, color }, { change: 'update', before })], null);
},
},
delete_collection: {
describe: (_ctx, _args, loaded) => described('delete_collection', currentListRow(loaded).list?.name ?? null),
change: (_ctx, _args, loaded) => {
const { list } = currentListRow(loaded);
return list ? listsCard([listRowOf(list, { change: 'remove' })], null) : null;
},
},
reorder_collections: {
describe: (_ctx, args, loaded) => {
const { own } = userLists(loaded);
const order = idsIn(args.orderedIds, 1000).map((id) => own.find((list) => list.id === id));
return described('reorder_collections', namesOf(order.filter((list) => list !== undefined).slice(0, 3)));
},
change: (_ctx, args, loaded) => {
const { own } = userLists(loaded);
const rows = idsIn(args.orderedIds, 1000)
.map((id) => own.find((list) => list.id === id))
.filter((list): list is CollectionCardList => list !== undefined)
.map((list) => listRowOf(list, { change: 'order' }));
return rows.length > 0 ? listsCard(rows, null) : null;
},
},
accept_collection_invite: {
describe: (_ctx, args, loaded) => described('accept_collection_invite', inviteFor(loaded, args)?.name ?? null),
change: (_ctx, args, loaded) => {
const invite = inviteFor(loaded, args);
return invite ? listsCard([{ ...invite, change: 'join' }], null) : null;
},
},
decline_collection_invite: {
describe: (_ctx, args, loaded) => described('decline_collection_invite', inviteFor(loaded, args)?.name ?? null),
change: (_ctx, args, loaded) => {
const invite = inviteFor(loaded, args);
return invite ? listsCard([{ ...invite, change: 'decline' }], null) : null;
},
},
cancel_collection_invite: {
describe: (_ctx, _args, loaded) => described('cancel_collection_invite', currentListRow(loaded).list?.name ?? null),
change: (_ctx, args, loaded) => {
const { list, members } = currentListRow(loaded);
if (!list) return null;
const userId = argId(args, 'userId');
const member = members.find((row) => positiveId(row.user_id) === userId);
const name = clipped(member?.username, 80);
const person = userId !== null && name ? { id: userId, name, avatarKey: null } : null;
return listsCard([listRowOf(list, { change: 'withdraw', person })], null);
},
},
};
const PLACE_CHANGES: Readonly<Record<string, CollectionChange>> = {
save_place_to_collection: {
describe: (_ctx, args) => described('save_place_to_collection', argText(args.name, 120)),
change: (ctx, args, loaded) => {
const whole = wholeList(lookupOf(loaded).collection);
const place = placeFromArgs(ctx, args, whole.list?.id ?? argId(args, 'collection_id'));
if (!place) return null;
return placesCard(whole.list, [{ ...place, savedIn: savedInOf(lookupOf(loaded).membership) }], whole.labels);
},
},
save_trip_places_to_collection: {
describe: (ctx, args) => {
const ids = tripPlaceIds(ctx, args.placeIds);
return described('save_trip_places_to_collection', tripNames(ctx, ids), ids);
},
change: (ctx, args, loaded) => {
const whole = wholeList(lookupOf(loaded).collection);
const ids = tripPlaceIds(ctx, args.placeIds);
const listId = whole.list?.id ?? argId(args, 'collectionId');
const places = ids
.slice(0, 8)
.map((id) => placeFromTrip(ctx, id, listId, 'idea'))
.filter((place): place is CollectionCardPlace => place !== null);
return places.length > 0 ? placesCard(whole.list, places, whole.labels, { total: ids.length }) : null;
},
},
set_collection_place_status_from_trip: {
describe: (ctx, args) => {
const ids = tripPlaceIds(ctx, args.place_ids);
return described('set_collection_place_status_from_trip', tripNames(ctx, ids), ids);
},
change: (ctx, args) => {
const to = statusArg(args.status) ?? 'idea';
const ids = tripPlaceIds(ctx, args.place_ids);
const places = ids
.slice(0, 8)
.map((id) => placeFromTrip(ctx, id, null, to))
.filter((place): place is CollectionCardPlace => place !== null)
.map((place) => ({ ...place, delta: emptyDelta({ status: { from: null, to } }) }));
return places.length > 0 ? placesCard(null, places, null, { total: ids.length, everyList: true }) : null;
},
},
update_collection_place: {
describe: (ctx, args, loaded) =>
described('update_collection_place', argText(args.name, 120) ?? currentPlace(ctx, args, loaded)?.name ?? null),
change: (ctx, args, loaded) => {
const place = currentPlace(ctx, args, loaded);
if (!place) return null;
const to = statusArg(args.status);
const moveTo = argId(args, 'collection_id');
const moving = moveTo !== null && moveTo !== place.listId;
const target = wholeList(lookupOf(loaded).target);
// A move takes every label of the old list off; the labels asked for then come from the new list.
const had = carriedLabelIds(lookupOf(loaded), place.id);
const carried: number[] = moving ? [] : had;
const allowed = moving ? labelIdSet(target.labels) : listLabelIds(itemsOf(lookupOf(loaded)).lists, place.listId);
const wanted = args.label_ids === undefined ? null : idsIn(args.label_ids, 50).filter((id) => allowed.has(id));
const dropped: number[] = moving ? had : wanted ? had.filter((id) => !wanted.includes(id)) : [];
const delta = emptyDelta({
status: to !== null && to !== place.status ? { from: place.status, to } : null,
labelsAdded: wanted ? wanted.filter((id) => !carried.includes(id)).slice(0, 10) : [],
labelsRemoved: dropped.slice(0, 10),
fields: fieldsOf(args),
movedTo: moving ? target.list : null,
});
return onePlaceCard(loaded, place, delta, moving ? target.labels : null);
},
},
set_collection_place_status: placeChange('set_collection_place_status', (args, place) =>
emptyDelta({ status: { from: place.status, to: statusArg(args.status) ?? 'idea' } }),
),
rate_collection_place: placeChange('rate_collection_place', (args) => {
const rating = typeof args.rating === 'number' && Number.isInteger(args.rating) ? args.rating : null;
return emptyDelta({ rating: { to: rating !== null && rating >= 1 && rating <= 5 ? rating : null } });
}),
delete_collection_place: placeChange('delete_collection_place', () => emptyDelta({ removed: true })),
assign_collection_labels: {
describe: (ctx, args, loaded) =>
described('assign_collection_labels', namesOf(manyPlaces(ctx, args, loaded).slice(0, 3))),
change: (ctx, args, loaded) => {
const places = labelDeltaOf(ctx, args, loaded);
if (places.length === 0) return null;
const items = itemsOf(lookupOf(loaded));
const list = itemListOf(items.lists, commonListId(places));
const pool = items.lists.flatMap((row) => rowsOf(row.labels));
return placesCard(list, places, pool, { total: idsIn(args.place_ids, 1000).length });
},
},
copy_collection_places_to_trip: {
describe: (ctx, args, loaded) =>
described('copy_collection_places_to_trip', namesOf(manyPlaces(ctx, args, loaded).slice(0, 3))),
change: (ctx, args, loaded) => {
const found = manyPlaces(ctx, args, loaded);
if (found.length === 0) return null;
const items = itemsOf(lookupOf(loaded));
const tripId = argId(args, 'trip_id');
const otherTrip = tripId !== null && tripId !== ctx.card.tripId;
// Whether another trip has a place already is not known here: the chat's trip says nothing about it.
const places = otherTrip ? found.map((place) => ({ ...place, inTripPlaceId: null })) : found;
const pool = items.lists.flatMap((row) => rowsOf(row.labels));
return placesCard(itemListOf(items.lists, commonListId(places)), places, pool, {
total: idsIn(args.place_ids, 1000).length,
otherTrip,
});
},
},
};
const LABEL_CHANGES: Readonly<Record<string, CollectionChange>> = {
create_collection_label: {
describe: (_ctx, args) => described('create_collection_label', argText(args.name, 60)),
change: (_ctx, args, loaded) => {
const name = argText(args.name, 60);
if (!name) return null;
const label = { id: null, name, color: colorOf(args.color), places: 0, change: 'add' as const, before: null };
return { ...placesCard(wholeList(lookupOf(loaded).collection).list, [], null), labels: [label] };
},
},
update_collection_label: {
describe: (_ctx, args, loaded) =>
described('update_collection_label', argText(args.name, 60) ?? currentLabel(args, loaded).label?.name ?? null),
change: (_ctx, args, loaded) => {
const { label, list } = currentLabel(args, loaded);
if (!label) return null;
const name = argText(args.name, 60) ?? label.name;
const color = args.color === undefined ? label.color : colorOf(args.color);
const before = name !== label.name || color !== label.color ? { name: label.name, color: label.color } : null;
const changed = { ...label, name, color, change: 'rename' as const, before };
return { ...placesCard(list, [], null), labels: [changed] };
},
},
delete_collection_label: {
describe: (_ctx, args, loaded) =>
described('delete_collection_label', currentLabel(args, loaded).label?.name ?? null),
change: (_ctx, args, loaded) => {
const { label, list } = currentLabel(args, loaded);
return label ? { ...placesCard(list, [], null), labels: [{ ...label, change: 'remove' as const }] } : null;
},
},
};
/** Every held change of the collections with its card. */
export const COLLECTION_CHANGES: Readonly<Record<string, CollectionChange>> = {
...LIST_CHANGES,
...PLACE_CHANGES,
...LABEL_CHANGES,
};
@@ -0,0 +1,114 @@
import type { CardReader, ToolArgs } from '../card-types';
import { argId, recordOf } from '../card-values';
import { rowsOf } from '../undo-snapshot';
import { idsIn, positiveId } from './collection-values';
/**
* What describing a held change of the collections reads first, through the
* turn's memo (at most four reads, each a null on failure): the list a change
* works in (`get_collection`), the saved places or labels it names
* (`get_collection_items`), the lists a place to save is in already
* (`find_place_in_collections`) and the user's lists with their invites
* (`list_collections`). Its arguments are the tool's own; nothing here writes.
*/
export interface CollectionLookup {
/** The list the change works in, read whole. */
collection: unknown;
/** The list a saved place moves to. */
target: unknown;
items: unknown;
membership: unknown;
lists: unknown;
}
export const NO_LOOKUP: CollectionLookup = {
collection: null,
target: null,
items: null,
membership: null,
lists: null,
};
type Loader = (args: ToolArgs, read: CardReader) => Promise<CollectionLookup>;
function collectionOf(read: CardReader, collectionId: number | null): Promise<unknown> {
return collectionId === null ? Promise.resolve(null) : read('get_collection', { collectionId });
}
function placesOf(read: CardReader, placeIds: number[]): Promise<unknown> {
return placeIds.length === 0 ? Promise.resolve(null) : read('get_collection_items', { placeIds });
}
/** How to ask where a place is saved: by its provider ids or its position; nothing to ask without either. */
function membershipQuery(args: ToolArgs): ToolArgs | null {
const query: ToolArgs = {};
for (const key of ['google_place_id', 'google_ftid']) {
if (typeof args[key] === 'string' && args[key] !== '') query[key] = args[key];
}
if (typeof args.lat === 'number' && typeof args.lng === 'number') {
query.lat = args.lat;
query.lng = args.lng;
}
return Object.keys(query).length > 0 ? query : null;
}
/** The one saved place the change names. */
const onePlace: Loader = async (args, read) => ({
...NO_LOOKUP,
items: await placesOf(read, idsIn([args.placeId], 1)),
});
/** The saved places of a change over several. */
const manyPlaces: Loader = async (args, read) => ({
...NO_LOOKUP,
items: await placesOf(read, idsIn(args.place_ids, 50)),
});
/** The list a change works in, by the argument that names it. */
const listBy =
(key: string): Loader =>
async (args, read) => ({ ...NO_LOOKUP, collection: await collectionOf(read, argId(args, key)) });
/** The user's lists and the invites waiting. */
const allLists: Loader = async (_args, read) => ({ ...NO_LOOKUP, lists: await read('list_collections') });
/** A label by its id, then its list read whole, so the card can say how many places carry it. */
const oneLabel: Loader = async (args, read) => {
const labelId = argId(args, 'labelId');
const items = labelId === null ? null : await read('get_collection_items', { labelIds: [labelId] });
const label = rowsOf(recordOf(items).labels)[0];
return { ...NO_LOOKUP, items, collection: await collectionOf(read, positiveId(recordOf(label).collection_id)) };
};
export const COLLECTION_LOADS: Readonly<Record<string, Loader>> = {
save_place_to_collection: async (args, read) => {
const query = membershipQuery(args);
const [collection, membership] = await Promise.all([
collectionOf(read, argId(args, 'collection_id')),
query ? read('find_place_in_collections', query) : Promise.resolve(null),
]);
return { ...NO_LOOKUP, collection, membership };
},
save_trip_places_to_collection: listBy('collectionId'),
update_collection_place: async (args, read) => {
const [items, target] = await Promise.all([
placesOf(read, idsIn([args.placeId], 1)),
collectionOf(read, argId(args, 'collection_id')),
]);
return { ...NO_LOOKUP, items, target };
},
set_collection_place_status: onePlace,
rate_collection_place: onePlace,
delete_collection_place: onePlace,
assign_collection_labels: manyPlaces,
copy_collection_places_to_trip: manyPlaces,
update_collection: listBy('collectionId'),
delete_collection: listBy('collectionId'),
cancel_collection_invite: listBy('collectionId'),
create_collection_label: listBy('collection_id'),
update_collection_label: oneLabel,
delete_collection_label: oneLabel,
reorder_collections: allLists,
accept_collection_invite: allLists,
decline_collection_invite: allLists,
};
@@ -0,0 +1,230 @@
import type { TripCard } from '../../assistant-context';
import type { PreviewContext, ToolArgs } from '../card-types';
import { finite, isLat, isLng, recordOf, type Row } from '../card-values';
import { rowsOf } from '../undo-snapshot';
import type { CollectionLookup } from './collection-load';
import {
argText,
googleIdOf,
idsIn,
inTripOf,
listOf,
MAX_PLACES,
positiveId,
savedPlaceOf,
statusOf,
tally,
usedLabels,
} from './collection-values';
import type {
CollectionCardDelta,
CollectionCardList,
CollectionCardPlace,
CollectionPlacesCard,
CollectionStatus,
} from '@trek/shared';
/**
* The pieces the change cards of the collections are put together from: the
* list a lookup read, the saved places it found, a place to save as its
* arguments describe it, a trip place as a saved place would be, and the card
* that holds them.
*/
/** A list read whole by `get_collection`: its head, its places and its label definitions. */
export interface WholeList {
list: CollectionCardList | null;
places: Row[];
labels: unknown;
members: Row[];
}
export function wholeList(value: unknown): WholeList {
const root = recordOf(value);
const collection = recordOf(root.collection);
const places = rowsOf(root.places);
const members = rowsOf(collection.members);
return { list: listOf(collection, tally(places)), places, labels: collection.labels, members };
}
/** What `get_collection_items` found: the saved places and the lists they sit in. */
export function itemsOf(lookup: CollectionLookup): { places: Row[]; lists: Row[]; labels: Row[] } {
const root = recordOf(lookup.items);
return { places: rowsOf(root.places), lists: rowsOf(root.lists), labels: rowsOf(root.labels) };
}
/** The list one of the found places sits in, as a card names it (not read whole: no counts). */
export function itemListOf(lists: readonly Row[], listId: number | null): CollectionCardList | null {
const row = listId === null ? undefined : lists.find((list) => positiveId(list.id) === listId);
return row ? listOf(row) : null;
}
/** The ids of a list's label definitions. */
export function labelIdSet(labels: unknown): Set<number> {
return new Set(rowsOf(labels).map((label) => positiveId(label.id) ?? 0));
}
/** The label ids a list defines, from the lists `get_collection_items` gave. */
export function listLabelIds(lists: readonly Row[], listId: number | null): Set<number> {
return labelIdSet(lists.find((list) => positiveId(list.id) === listId)?.labels);
}
/**
* Every label a found place carries (a list holds up to 50), not only the
* ones its card names: what a change adds or takes away is worked out on these.
*/
export function carriedLabelIds(lookup: CollectionLookup, placeId: number | null): number[] {
const row = itemsOf(lookup).places.find((place) => positiveId(place.id) === placeId);
return idsIn(row?.label_ids, 50);
}
/** The one list every place sits in; null when they sit in several. */
export function commonListId(places: readonly CollectionCardPlace[]): number | null {
const ids = new Set(places.map((place) => place.listId));
const [only] = [...ids];
return ids.size === 1 && only !== undefined ? only : null;
}
export function emptyDelta(over: Partial<CollectionCardDelta> = {}): CollectionCardDelta {
return {
status: null,
rating: null,
labelsAdded: [],
labelsRemoved: [],
fields: [],
movedTo: null,
removed: false,
...over,
};
}
/** A places card; `total` counts the places the change names beyond the ones shown. */
export function placesCard(
list: CollectionCardList | null,
places: readonly CollectionCardPlace[],
labelPool: unknown,
extra: Partial<CollectionPlacesCard> & { total?: number } = {},
): CollectionPlacesCard {
const { total, ...rest } = extra;
const shown = places.slice(0, MAX_PLACES);
// The labels a change adds or takes away are named too.
const named = shown.map((place) => ({
...place,
labelIds: [...place.labelIds, ...(place.delta?.labelsAdded ?? []), ...(place.delta?.labelsRemoved ?? [])],
}));
return {
kind: 'collectionPlaces',
list,
places: shown,
more: Math.max(0, (total ?? places.length) - shown.length),
labels: usedLabels(labelPool, named, null),
outcome: null,
everyList: false,
otherTrip: false,
...rest,
};
}
/** A category by its id, as the turn's category list names it. */
function categoryArg(ctx: PreviewContext, value: unknown): CollectionCardPlace['category'] {
const id = positiveId(value);
const name = id === null ? null : argText(ctx.categories.get(id), 60);
return name ? { name, icon: null, color: null } : null;
}
/** A place to save as the call describes it: not saved yet, so without an id. */
export function placeFromArgs(ctx: PreviewContext, args: ToolArgs, listId: number | null): CollectionCardPlace | null {
const name = argText(args.name, 120);
if (!name) return null;
const lat = finite(args.lat);
const lng = finite(args.lng);
const coords = isLat(lat) && isLng(lng);
const googlePlaceId = googleIdOf(args.google_place_id);
const osmId = argText(args.osm_id, 40);
return {
id: null,
listId,
name,
address: argText(args.address, 200),
lat: coords ? lat : null,
lng: coords ? lng : null,
category: categoryArg(ctx, args.category_id),
status: statusOf(args.status),
labelIds: [],
rating: null,
ratingCount: 0,
googlePlaceId,
osmId,
imageKey: null,
inTripPlaceId: inTripOf(ctx.card, {
googlePlaceId,
osmId,
sourceTripId: args.source_trip_id,
sourcePlaceId: args.source_place_id,
}),
savedIn: [],
delta: null,
};
}
const tripImages = new WeakMap<TripCard, Map<number, unknown>>();
/** The photos of the trip's places, read once per trip card from the places the turn read. */
function tripImageOf(card: TripCard, placeId: number): unknown {
let images = tripImages.get(card);
if (!images) {
const rows = rowsOf(recordOf(card.sources.places).places);
images = new Map(rows.map((row) => [positiveId(row.id) ?? 0, row.image_url]));
tripImages.set(card, images);
}
return images.get(placeId) ?? null;
}
/** A trip place as it would be saved in a list: the trip's own place, so it is "already in the trip". */
export function placeFromTrip(
ctx: PreviewContext,
placeId: number,
listId: number | null,
status: CollectionStatus,
): CollectionCardPlace | null {
const place = ctx.card.places.get(placeId);
if (!place) return null;
const saved = savedPlaceOf(
{
name: place.name,
address: place.address,
lat: place.lat,
lng: place.lng,
google_place_id: place.googlePlaceId,
osm_id: place.osmId,
image_url: tripImageOf(ctx.card, placeId),
status,
collection_id: listId,
},
null,
);
return saved ? { ...saved, category: categoryArg(ctx, place.categoryId), inTripPlaceId: placeId } : null;
}
/** The trip places a call names, in its order: those the trip card holds. */
export function tripPlaceIds(ctx: PreviewContext, value: unknown): number[] {
return idsIn(value, 1000).filter((id) => ctx.card.places.has(id));
}
/** The saved places a lookup found, in the order the call names them. */
export function foundPlaces(
ctx: PreviewContext,
lookup: CollectionLookup,
ids: readonly number[],
): CollectionCardPlace[] {
const rows = itemsOf(lookup).places;
return ids
.map((id) => rows.find((row) => positiveId(row.id) === id))
.map((row) => (row ? savedPlaceOf(row, ctx.card) : null))
.filter((place): place is CollectionCardPlace => place !== null);
}
/** "Septime, Le Comptoir": the names a change works on, for its subject. */
export function namesOf(places: readonly { name: string }[]): string {
return places.map((place) => place.name).join(', ');
}
@@ -0,0 +1,218 @@
import type { TripCard } from '../../assistant-context';
import { clipped, fileKeyOf, finite, hexOrNull, isLat, isLng, recordOf, type Row } from '../card-values';
import { rowsOf } from '../undo-snapshot';
import {
cardPhotoKeySchema,
COLLECTION_STATUSES,
type CollectionCardLabel,
type CollectionCardList,
type CollectionCardPlace,
type CollectionListRow,
type CollectionStatus,
} from '@trek/shared';
/**
* How the collections' answers read as card pieces: a list, a label, a saved
* place. Every text is masked and cut (`clipped`), every colour checked, a
* photo only TREK's own upload, and notes, descriptions, phone numbers, links
* and the members' addresses are never read. A saved place is matched to the
* chat's trip by its provider id, or by the trip place it was saved from.
*/
/** The rows a card shows at most, and the labels it names. */
export const MAX_PLACES = 8;
export const MAX_LISTS = 12;
const MAX_LABELS = 24;
/**
* A list's or label's colour as a card carries it: the short `#abc` the
* forms accept spelled out to `#aabbcc`, anything else checked as usual.
*/
export function colorOf(value: unknown): string | null {
if (typeof value === 'string' && /^#[0-9a-fA-F]{3}$/.test(value)) {
return value.replace(/[0-9a-fA-F]/g, (digit) => digit + digit);
}
return hexOrNull(value);
}
export function positiveId(value: unknown): number | null {
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null;
}
/** The positive ids in a list argument, in order and each once; at most `max`. */
export function idsIn(value: unknown, max: number): number[] {
if (!Array.isArray(value)) return [];
const ids = value.map(positiveId).filter((id): id is number => id !== null);
return [...new Set(ids)].slice(0, max);
}
function countOf(value: unknown): number | null {
return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 1_000_000 ? value : null;
}
export function statusOf(value: unknown): CollectionStatus {
return COLLECTION_STATUSES.find((status) => status === value) ?? 'idea';
}
/** A status a change asks for; null when the argument names none. */
export function statusArg(value: unknown): CollectionStatus | null {
return COLLECTION_STATUSES.find((status) => status === value) ?? null;
}
/** The places of a whole list by status. */
export function tally(places: readonly Row[]): NonNullable<CollectionCardList['counts']> {
const counts = { idea: 0, want: 0, visited: 0 };
for (const place of places) counts[statusOf(place.status)] += 1;
return counts;
}
/** The members who share a list besides its owner: accepted ones only. */
function sharedWith(members: unknown): number {
return rowsOf(members).filter((member) => member.status === 'accepted' && member.is_owner !== true).length;
}
/** A list from a collection row of `get_collection`, `list_collections` or `get_collection_items`. */
export function listOf(raw: unknown, counts: CollectionCardList['counts'] = null): CollectionCardList | null {
const row = recordOf(raw);
const id = positiveId(row.id);
const name = clipped(row.name, 120);
if (id === null || !name) return null;
return {
id,
name,
color: colorOf(row.color),
placeCount: countOf(row.place_count),
memberCount: sharedWith(row.members),
counts,
};
}
/** A list row of the lists card: a list as it is, unless the caller says what changes. */
export function listRowOf(list: CollectionCardList, extra: Partial<CollectionListRow> = {}): CollectionListRow {
return { ...list, status: null, person: null, invite: false, change: 'none', before: null, ...extra };
}
/** A label of a list; `places` counts the places of the whole list that carry it, where the list was read. */
export function labelOf(
raw: unknown,
places: readonly Row[] | null,
extra: Partial<CollectionCardLabel> = {},
): CollectionCardLabel | null {
const row = recordOf(raw);
const id = positiveId(row.id);
const name = clipped(row.name, 60);
if (!name) return null;
const carriers = places && id !== null ? places.filter((place) => idsIn(place.label_ids, 50).includes(id)) : null;
return {
id,
name,
color: colorOf(row.color),
places: carriers ? carriers.length : null,
change: 'none',
before: null,
...extra,
};
}
/** The labels of `pool` the shown places carry, counted over `whole` where the whole list was read. */
export function usedLabels(
pool: unknown,
shown: readonly CollectionCardPlace[],
whole: readonly Row[] | null,
): CollectionCardLabel[] {
const used = new Set(shown.flatMap((place) => place.labelIds));
return rowsOf(pool)
.filter((label) => used.has(positiveId(label.id) ?? 0))
.map((label) => labelOf(label, whole))
.filter((label): label is CollectionCardLabel => label !== null)
.slice(0, MAX_LABELS);
}
/** The file name of a place photo TREK keeps itself; a photo on another host gives none. */
function placeImageKey(value: unknown): string | null {
return typeof value === 'string' && value.startsWith('/uploads/places/') ? fileKeyOf(value) : null;
}
/**
* A text from a call's arguments as a card shows it: on one line and cut to
* `max`, not masked, since it is what the change will store.
*/
export function argText(value: unknown, max: number): string | null {
if (typeof value !== 'string') return null;
const line = value.replace(/\s+/g, ' ').trim();
if (line === '') return null;
return line.length > max ? `${line.slice(0, max - 1)}…` : line;
}
export function googleIdOf(value: unknown): string | null {
return typeof value === 'string' && cardPhotoKeySchema.safeParse(value).success ? value : null;
}
/** An average of the members' votes, to a tenth; null without votes. */
function ratingOf(value: unknown): number | null {
const rating = finite(value);
return rating !== null && rating >= 1 && rating <= 5 ? Math.round(rating * 10) / 10 : null;
}
function categoryOf(raw: unknown): CollectionCardPlace['category'] {
const row = recordOf(raw);
const name = clipped(row.name, 60);
if (!name) return null;
const icon = typeof row.icon === 'string' && /^[A-Za-z0-9]{1,40}$/.test(row.icon) ? row.icon : null;
return { name, icon, color: colorOf(row.color) };
}
/** The trip place that is this saved place: the same Google or OpenStreetMap id, else the one it was saved from. */
export function inTripOf(
card: TripCard | null,
ids: { googlePlaceId: string | null; osmId: string | null; sourceTripId?: unknown; sourcePlaceId?: unknown },
): number | null {
if (!card) return null;
for (const place of card.places.values()) {
if (ids.googlePlaceId !== null && place.googlePlaceId === ids.googlePlaceId) return place.id;
if (ids.osmId !== null && place.osmId === ids.osmId) return place.id;
}
const source = positiveId(ids.sourcePlaceId);
return ids.sourceTripId === card.tripId && source !== null && card.places.has(source) ? source : null;
}
/** A saved place from a row of the collections' answers; `card` is the chat's trip, null where none is at hand. */
export function savedPlaceOf(raw: unknown, card: TripCard | null): CollectionCardPlace | null {
const row = recordOf(raw);
const name = clipped(row.name, 120);
if (!name) return null;
const lat = finite(row.lat);
const lng = finite(row.lng);
const googlePlaceId = googleIdOf(row.google_place_id);
const osmId = clipped(row.osm_id, 40);
const coords = isLat(lat) && isLng(lng);
return {
id: positiveId(row.id),
listId: positiveId(row.collection_id),
name,
address: clipped(row.address, 200),
lat: coords ? lat : null,
lng: coords ? lng : null,
category: categoryOf(row.category),
status: statusOf(row.status),
labelIds: idsIn(row.label_ids, 10),
rating: ratingOf(row.rating_avg),
ratingCount: countOf(row.rating_count) ?? 0,
googlePlaceId,
osmId,
imageKey: placeImageKey(row.image_url),
inTripPlaceId: inTripOf(card, {
googlePlaceId,
osmId,
sourceTripId: row.source_trip_id,
sourcePlaceId: row.source_place_id,
}),
savedIn: [],
delta: null,
};
}
/** The saved places of rows that read as one, each once. */
export function savedPlacesOf(rows: readonly Row[], card: TripCard | null): CollectionCardPlace[] {
return rows.map((row) => savedPlaceOf(row, card)).filter((place): place is CollectionCardPlace => place !== null);
}
@@ -1,4 +1,84 @@
import type { DomainCards } from '../card-types';
import type { AssistantWriteTool } from '../../assistant-tools.constants';
import type { DomainCards, ToolCards } from '../card-types';
import { collectionAnswer, itemsAnswer, listsAnswer, membershipAnswer } from './collection-answers';
import {
COLLECTION_APPLIED,
COLLECTION_APPLIED_IDS,
DELETE_CREATED_LIST,
DELETE_SAVED_PLACE,
} from './collection-applied';
import { COLLECTION_CHANGES } from './collection-changes';
import { COLLECTION_LOADS, type CollectionLookup } from './collection-load';
/** The cards of the collections and their places. None yet: this package fills them in. */
export const COLLECTION_CARDS: DomainCards = { id: 'collection', tools: {} };
/**
* The collections in the assistant chat: a list read shows its head and
* first places, saved places by id their rows, the lists with the invites
* waiting, and where a place is saved; every write is held as a change card
* built from what it will touch, and replaced by what it did once applied.
* A saved place's id is its own, never a trip place's, so the collections'
* place ids never name a trip place on the generic card either.
*/
/** Each write's verb and what its receipt names: a list, a saved place, or for a copy into the trip, trip places. */
const WRITES: Readonly<Record<string, AssistantWriteTool>> = {
create_collection: { verbKey: 'genericCreate', entity: 'collection' },
update_collection: { verbKey: 'genericUpdate', entity: 'collection' },
delete_collection: { verbKey: 'genericRemove', entity: 'collection' },
reorder_collections: { verbKey: 'genericUpdate', entity: 'collection' },
accept_collection_invite: { verbKey: 'genericUpdate', entity: 'collection' },
decline_collection_invite: { verbKey: 'genericRemove', entity: 'collection' },
cancel_collection_invite: { verbKey: 'genericRemove', entity: 'collection' },
save_place_to_collection: { verbKey: 'genericCreate', entity: 'collection_place' },
save_trip_places_to_collection: { verbKey: 'genericCreate', entity: 'collection' },
update_collection_place: { verbKey: 'genericUpdate', entity: 'collection_place' },
set_collection_place_status: { verbKey: 'genericUpdate', entity: 'collection_place' },
set_collection_place_status_from_trip: { verbKey: 'genericUpdate', entity: 'collection' },
rate_collection_place: { verbKey: 'genericUpdate', entity: 'collection_place' },
delete_collection_place: { verbKey: 'genericRemove', entity: 'collection_place' },
copy_collection_places_to_trip: { verbKey: 'genericCreate', entity: 'place' },
create_collection_label: { verbKey: 'genericCreate', entity: 'collection' },
update_collection_label: { verbKey: 'genericUpdate', entity: 'collection' },
delete_collection_label: { verbKey: 'genericRemove', entity: 'collection' },
assign_collection_labels: { verbKey: 'genericUpdate', entity: 'collection' },
};
/** A place's own photo is uploaded in the app, never set from an address the model picked (as for trip places). */
const NO_IMAGE = { stripped: ['image_url'] } as const;
/** The change card of one write, its lookup and what it shows once applied. */
function writeCards(tool: string, write: AssistantWriteTool): ToolCards<CollectionLookup> {
const change = COLLECTION_CHANGES[tool];
const applied = COLLECTION_APPLIED[tool];
const appliedIds = COLLECTION_APPLIED_IDS[tool];
const load = COLLECTION_LOADS[tool];
return {
write,
...(change ? { describe: change.describe, change: change.change } : {}),
...(load ? { load } : {}),
...(applied ? { applied } : {}),
...(appliedIds ? { appliedIds } : {}),
};
}
const WRITE_CARDS = Object.fromEntries(
Object.entries(WRITES).map(([tool, write]): [string, ToolCards<CollectionLookup>] => [tool, writeCards(tool, write)]),
);
export const COLLECTION_CARDS: DomainCards = {
id: 'collection',
foreignIdGroups: ['collections'],
tools: {
get_collection: { answer: collectionAnswer },
get_collection_items: { answer: itemsAnswer },
list_collections: { answer: listsAnswer },
find_place_in_collections: { answer: membershipAnswer },
...WRITE_CARDS,
create_collection: { ...WRITE_CARDS.create_collection, undo: DELETE_CREATED_LIST },
save_place_to_collection: {
...WRITE_CARDS.save_place_to_collection,
undo: DELETE_SAVED_PLACE,
argPolicy: NO_IMAGE,
},
update_collection_place: { ...WRITE_CARDS.update_collection_place, argPolicy: NO_IMAGE },
},
};
@@ -0,0 +1,83 @@
import { CollectionLabels } from '../../db/entities/CollectionLabels.entity';
import { Collections } from '../../db/entities/Collections.entity';
import { CollectionLabelsRepository } from '../../db/repositories/CollectionLabels.repository';
import { CollectionsRepository } from '../../db/repositories/Collections.repository';
import { DomainError } from '../common/domain-error';
import { CollectionsService } from './collections.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import type { CollectionDetailResponse, CollectionItemsRequest, CollectionItemsResponse } from '@trek/shared';
/** The most lists one lookup opens: 50 places and 20 labels can sit in no more than 70. */
const MAX_LISTS = 70;
/**
* Saved places and labels by their own ids, wherever they are filed (the
* get_collection_items tool): what an assistant's card needs to name a place
* a change touches, and what the model needs to read one place of a long list
* without the whole list. Every list is read through the collections
* service's own access check, so an id the caller cannot see is simply left
* out, the same as one that does not exist.
*/
@Injectable()
export class CollectionsItemsService {
constructor(
private readonly collections: CollectionsService,
@InjectRepository(Collections) private readonly collectionsRepo: CollectionsRepository,
@InjectRepository(CollectionLabels) private readonly labels: CollectionLabelsRepository,
) {}
async getItems(userId: number, request: CollectionItemsRequest): Promise<CollectionItemsResponse> {
const placeIds = [...new Set(request.placeIds ?? [])];
const labelIds = [...new Set(request.labelIds ?? [])];
const listIds = new Set<number>();
for (const placeId of placeIds) {
const listId = await this.collectionsRepo.collectionIdOfPlace(placeId);
if (listId !== undefined) listIds.add(listId);
}
for (const labelId of labelIds) {
const listId = await this.labels.collectionIdOf(labelId);
if (listId !== undefined) listIds.add(listId);
}
const read: Array<CollectionDetailResponse & { canDelete: boolean }> = [];
for (const listId of [...listIds].slice(0, MAX_LISTS)) {
const detail = await this.readable(userId, listId);
if (!detail) continue;
// Places leave a list only by its owner or an admin (assertCanDelete); an editor adds.
const role = await this.collections.roleOf(userId, listId);
read.push({ ...detail, canDelete: role === 'owner' || role === 'admin' });
}
const wantedPlaces = new Set(placeIds);
const wantedLabels = new Set(labelIds);
return {
lists: read.map(({ collection, canDelete }) => ({
id: collection.id,
name: collection.name,
color: collection.color ?? null,
place_count: collection.place_count,
is_owner: collection.is_owner,
can_delete: canDelete,
labels: collection.labels ?? [],
members: (collection.members ?? []).map(({ user_id, username, status, role, is_owner }) => ({
user_id,
username,
status,
role,
is_owner,
})),
})),
places: read.flatMap(({ places }) => places.filter((place) => wantedPlaces.has(place.id))),
labels: read.flatMap(({ collection }) => (collection.labels ?? []).filter((label) => wantedLabels.has(label.id))),
};
}
/** One list as its members read it; null for one the user may not see or that is gone. */
private async readable(userId: number, listId: number): Promise<CollectionDetailResponse | null> {
try {
return await this.collections.getCollection(userId, listId);
} catch (err) {
if (err instanceof DomainError) return null;
throw err;
}
}
}
@@ -14,6 +14,7 @@ import {
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { CollectionsItemsService } from './collections-items.service';
import { CollectionsService } from './collections.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import {
@@ -27,6 +28,7 @@ import {
collectionLabelAssignRequestSchema,
collectionInviteRequestSchema,
collectionSetStatusFromTripRequestSchema,
collectionItemsRequestSchema,
collectionLinkSchema,
COLLECTION_STATUSES,
COLLECTION_ROLES,
@@ -43,6 +45,7 @@ import type {
CollectionLabelUpdateRequest,
CollectionLabelAssignRequest,
CollectionSetStatusFromTripRequest,
CollectionItemsRequest,
CollectionStatus,
CollectionRole,
} from '@trek/shared';
@@ -86,6 +89,7 @@ export class CollectionsMcp {
@InjectRepository(Users) private readonly users: UsersRepository,
readonly addons: AddonsService,
private readonly guards: McpToolGuardsService,
private readonly items: CollectionsItemsService,
) {}
// ── Read ──────────────────────────────────────────────────────────────
@@ -182,6 +186,23 @@ export class CollectionsMcp {
}
}
@Tool({
name: 'get_collection_items',
description:
'Read saved places and labels by their own ids across every list the user can see, without loading whole lists: each place with its status, labels, rating and notes, and the lists they sit in with their colour, size, labels and members. Saved place ids are collection place ids, never trip place ids. Ids the user cannot see are left out.',
inputSchema: collectionItemsRequestSchema.shape,
annotations: TOOL_ANNOTATIONS_READONLY,
when: collectionsAddonOn,
access: { group: 'collections', mode: 'read' },
})
async getCollectionItems(request: CollectionItemsRequest, ctx: McpContext) {
try {
return ok(await this.items.getItems(ctx.userId, request));
} catch (err) {
return fail(err);
}
}
// ── Collections CRUD ─────────────────────────────────────────────────
@Tool({
@@ -21,6 +21,7 @@ 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';
import { CollectionsItemsService } from './collections-items.service';
import { CollectionsController } from './collections.controller';
import { MAX_COVER_SIZE } from './collections.controller';
import { CollectionsMcp } from './collections.mcp';
@@ -76,7 +77,7 @@ import { MulterModule } from '@nestjs/platform-express';
]),
],
controllers: [CollectionsController],
providers: [CollectionsService, CollectionsMcp, CollectionsRpc],
providers: [CollectionsService, CollectionsItemsService, CollectionsMcp, CollectionsRpc],
exports: [CollectionsService],
})
export class CollectionsModule {}