Compare commits

..

4 Commits

Author SHA1 Message Date
Maurice 9f5d01ee09 test(shared): cover the stored Costs category and the hint the MCP reads 2026-11-04 15:29:29 +01:00
Maurice 67ca58f2a5 fix(mcp): a transport priced without a category is stored under its Costs key 2026-11-04 15:12:20 +01:00
Maurice 9eab19ea6c fix(mcp): hold two more tests to the fixed Costs keys
The budget prompt and the price link of a new transport now read the stored key.
2026-11-04 15:00:03 +01:00
Maurice 50ea9a74c9 fix(mcp): keep budget categories to the Costs keys, and say what a journal entry's visibility does
An assistant writing expenses through the MCP was told "e.g. Accommodation, Food,
Transport" and stored exactly that, so "Food" and "food" sat side by side in the
MCP totals while the Costs tab already read both as one. A typed category in any
case, or a common name like Hotel or Flight, is now stored as its fixed key on
every path (REST, MCP, linked booking costs), the totals the MCP reads group by
that key, and the tools name the real keys. The client reads categories through
the same shared mapping. Any other text is still kept as given.

The journal tools said that a private entry stays off the share link. It never
did: the share link shows every entry that is not a draft, whatever its
visibility, and the web editor has no visibility at all. The tools now say so
and point to is_draft, which is what keeps an entry to the contributors.

The wiki explains why the location button needs HTTPS and how to run TREK behind
an HTTPS tunnel while still using it over plain HTTP at home, and that Android's
photo picker drops a photo's location while Immich and Synology keep it.
2026-11-04 14:26:25 +01:00
16 changed files with 175 additions and 55 deletions
@@ -1,6 +1,6 @@
import { Hotel, Utensils, ShoppingCart, Bus, Plane, Ticket, Camera, ShoppingBag, FileText, HeartPulse, Coins, MoreHorizontal, Fuel, ParkingCircle } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { COST_CATEGORIES, type CostCategory } from '@trek/shared'
import { COST_CATEGORIES, costCategoryOf, type CostCategory } from '@trek/shared'
/**
* The fixed Costs categories. Users can't add their own — every expense maps to
@@ -34,34 +34,7 @@ export const COST_CAT_META: Record<CostCategory, CostCategoryMeta> = {
export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => COST_CAT_META[k])
/**
* Legacy / English free-text categories (and reservation type labels) mapped to
* the fixed keys. Bookings used to store labels like "Flight"/"Train"/"Other",
* which never matched the lowercase keys and fell through to `other`.
*/
const LEGACY_CATEGORY_MAP: Record<string, CostCategory> = {
flight: 'flights', flights: 'flights', plane: 'flights', flug: 'flights',
train: 'transport', bus: 'transport', car: 'transport', 'car rental': 'transport',
ferry: 'transport', boat: 'transport', taxi: 'transport', transfer: 'transport', cable_car: 'transport',
transport: 'transport', transportation: 'transport',
hotel: 'accommodation', accommodation: 'accommodation', lodging: 'accommodation', hostel: 'accommodation',
restaurant: 'food', food: 'food', dining: 'food', meal: 'food', meals: 'food',
grocery: 'groceries', groceries: 'groceries',
activity: 'activities', activities: 'activities',
sightseeing: 'sightseeing', sights: 'sightseeing',
shop: 'shopping', shopping: 'shopping',
fee: 'fees', fees: 'fees',
health: 'health', medical: 'health',
tip: 'tips', tips: 'tips',
gas: 'fuel', fuel: 'fuel', petrol: 'fuel',
parking: 'parking', parkings: 'parking', 'car park': 'parking',
other: 'other', misc: 'other',
}
/** Map any stored category (incl. legacy/localized free-text values) to a known meta. */
export function catMeta(cat: string | null | undefined): CostCategoryMeta {
if (!cat) return COST_CAT_META.other
if (cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
const mapped = LEGACY_CATEGORY_MAP[cat.trim().toLowerCase()]
return mapped ? COST_CAT_META[mapped] : COST_CAT_META.other
return COST_CAT_META[costCategoryOf(cat) ?? 'other']
}
+1 -1
View File
@@ -7,7 +7,7 @@
"src/db/repositories/Users.repository.ts": 1445,
"src/nest/atlas/atlas.service.ts": 1177,
"src/nest/auth/auth.service.ts": 1143,
"src/nest/budget/budget.service.ts": 1613,
"src/nest/budget/budget.service.ts": 1612,
"src/nest/collections/collections.service.ts": 1426,
"src/nest/doc-sync/doc-sync.service.ts": 1060,
"src/nest/doc-sync/providers/synology-drive.client.ts": 1160,
+4 -4
View File
@@ -26,7 +26,7 @@ import { BudgetWritesService } from './budget-writes.service';
import { BudgetService } from './budget.service';
import { ExchangeRatesService } from './exchange-rates.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { idSchema, sumMinor, toMinor } from '@trek/shared';
import { COST_CATEGORY_HINT, idSchema, sumMinor, toMinor } from '@trek/shared';
import { z } from 'zod';
@@ -200,7 +200,7 @@ export class BudgetMcp {
inputSchema: {
tripId: idSchema,
name: z.string().min(1).max(200),
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
category: z.string().max(100).optional().describe(COST_CATEGORY_HINT),
total_price: z.number().describe('Signed: a negative total records a refund/partial reimbursement (#2176)'),
currency: z
.string()
@@ -341,7 +341,7 @@ export class BudgetMcp {
tripId: idSchema,
itemId: idSchema,
name: z.string().min(1).max(200).optional(),
category: z.string().max(100).optional(),
category: z.string().max(100).optional().describe(COST_CATEGORY_HINT),
total_price: z.number().optional(),
currency: z
.string()
@@ -482,7 +482,7 @@ export class BudgetMcp {
inputSchema: {
tripId: idSchema,
name: z.string().min(1).max(200),
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
category: z.string().max(100).optional().describe(COST_CATEGORY_HINT),
total_price: z.number().describe('Signed: a negative total records a refund/partial reimbursement (#2176)'),
note: z.string().max(500).optional(),
userIds: z
+7 -8
View File
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import type { BudgetFallbackFx, BudgetParticipantFinal, BudgetUnconverted, TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { splitEqualShares, sumMinor, toMinor } from '@trek/shared';
import { costCategoryOf, splitEqualShares, storedCostCategory, sumMinor, toMinor } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { PermissionsService } from '../permissions/permissions.service';
import { avatarUrl } from '../common/avatarUrl';
@@ -91,8 +91,7 @@ export function splitLegacyTicketNote(
* queries instead of a full listSettlements() scan, settlement usernames use
* COALESCE(display_name, username) like every item query, and updateMembers no
* longer double-applies avatarUrl.
*/
@Injectable()
*/@Injectable()
export class BudgetService {
constructor(
private readonly permissions: PermissionsService,
@@ -520,7 +519,7 @@ export class BudgetService {
const maxOrder = await this.budgetItemsRepo.maxSortOrder(tripId);
const sortOrder = (maxOrder !== null ? maxOrder : -1) + 1;
const cat = data.category || 'other';
const cat = storedCostCategory(data.category);
// Ensure category has a sort_order entry
const catExists = await this.budgetCategoryOrderRepo.exists(tripId, cat);
@@ -660,7 +659,7 @@ export class BudgetService {
// (the legacy `COALESCE(?, col)` truthy-wins shape), everything else
// passes `present = <field> !== undefined` (a true presence sentinel).
await this.budgetItemsRepo.update(id, {
category: [!!data.category, data.category || ''],
category: [!!data.category, data.category ? storedCostCategory(data.category) : ''],
name: [!!data.name, data.name || ''],
total_price: [data.total_price !== undefined, data.total_price !== undefined ? data.total_price : 0],
currency: [data.currency !== undefined, data.currency !== undefined ? (data.currency || null) : null],
@@ -705,11 +704,11 @@ export class BudgetService {
// If category changed, update category order table
if (data.category) {
const catExists = await this.budgetCategoryOrderRepo.exists(tripId, data.category);
const catExists = await this.budgetCategoryOrderRepo.exists(tripId, storedCostCategory(data.category));
if (!catExists) {
const maxCatOrder = await this.budgetCategoryOrderRepo.maxSortOrder(tripId);
const catOrder = (maxCatOrder !== null && maxCatOrder !== undefined ? maxCatOrder : -1) + 1;
await this.budgetCategoryOrderRepo.insertIgnore(tripId, data.category, catOrder);
await this.budgetCategoryOrderRepo.insertIgnore(tripId, storedCostCategory(data.category), catOrder);
}
}
@@ -1037,7 +1036,7 @@ export class BudgetService {
}
const cents = toMinor(convert(r.total_price || 0));
total += cents;
const cat = r.category || '';
const cat = costCategoryOf(r.category) ?? (r.category || '');
byCategory[cat] = (byCategory[cat] || 0) + cents;
}
return {
+1 -1
View File
@@ -383,7 +383,7 @@ export class JourneyMcp {
tags: z.array(z.string()).optional(),
pros_cons: PROS_CONS.optional().describe('The verdict on the place: what was worth it and what was not'),
visibility: ENTRY_VISIBILITY.optional().describe(
'Defaults to private; "shared" and "public" expose the entry through the journey share link',
'Stored with the entry, defaults to private. It does not decide the share link: every entry that is not a draft shows on the journey share link. Use is_draft to keep an entry to the contributors',
),
type: ENTRY_TYPE.optional().describe(
'Defaults to "entry"; "skeleton" is the stub TREK derives from a trip place and hides behind the hide-skeletons preference',
+2 -1
View File
@@ -155,7 +155,8 @@ describe('Tool: create_budget_item', () => {
});
const data = parseToolResult(result) as any;
expect(data.item.name).toBe('Hotel Paris');
expect(data.item.category).toBe('Accommodation');
// Stored as its fixed key, as the Costs tab shows it (#2835).
expect(data.item.category).toBe('accommodation');
expect(data.item.total_price).toBe(500);
expect(data.item.note).toBe('Prepaid');
});
+6 -6
View File
@@ -565,10 +565,10 @@ describe('Prompt: budget-overview', () => {
const client = await buildServer(user.id);
const text = await invokePromptText(client, 'budget-overview', { tripId: trip.id });
expect(text).toContain('Budget Trip');
expect(text).toContain('Transport');
expect(text).toContain('Accommodation');
expect(text).toContain('550'); // Transport total
expect(text).toContain('300'); // Accommodation total
// Totals group by the fixed Costs key, whatever case the expense was stored in (#2835).
expect(text).toContain('- transport: 250');
expect(text).toContain('- accommodation: 300');
expect(text).toContain('550');
});
it('prints a foreign-currency bill at the rate it was booked at, not as that many euros (#2525)', async () => {
@@ -588,8 +588,8 @@ describe('Prompt: budget-overview', () => {
const text = await invokePromptText(client, 'budget-overview', { tripId: trip.id });
// 801.76 USD at 1.17 is 685.26 EUR. Summed as stored it read "Total: 901.76 EUR".
expect(text).toContain('Total: 785.26 EUR');
expect(text).toContain('- Accommodation: 685.26 EUR');
expect(text).toContain('- Food: 100 EUR');
expect(text).toContain('- accommodation: 685.26 EUR');
expect(text).toContain('- food: 100 EUR');
expect(text).not.toContain('801.76');
});
+1 -1
View File
@@ -291,7 +291,7 @@ describe('MCP transit tools', () => {
arguments: { tripId: trip.id, dayId: day.id, from, to, itinerary, price: 230, budget_category: 'Transport' },
}),
) as { reservation: { id: number; metadata: string } };
expect((await findRow(orm, BudgetItems, { reservation: paid.reservation.id }))?.category).toBe('Transport');
expect((await findRow(orm, BudgetItems, { reservation: paid.reservation.id }))?.category).toBe('transport');
const free = parseToolResult(
await harness.client.callTool({
@@ -349,7 +349,8 @@ describe('Transport tools: the price link', () => {
const data = parseToolResult(result) as { reservation: { id: number } };
const item = await findRow(orm, BudgetItems, { reservation: data.reservation.id });
expect(item).toMatchObject({ name: 'ZRH → CDG', category: 'Flights', total_price: 240 });
// Stored as its fixed Costs key (#2835).
expect(item).toMatchObject({ name: 'ZRH → CDG', category: 'flights', total_price: 240 });
// The price also rides along in the reservation metadata, as the REST path does.
expect(broadcastMock.mock.calls.some((c) => c[1] === 'budget:created')).toBe(true);
});
@@ -365,7 +366,8 @@ describe('Transport tools: the price link', () => {
});
const data = parseToolResult(result) as { reservation: { id: number } };
const item = await findRow(orm, BudgetItems, { reservation: data.reservation.id });
expect(item?.category).toBe('train');
// The type is stored as its Costs key: a train is a transport (#2835).
expect(item?.category).toBe('transport');
});
});
+40
View File
@@ -0,0 +1,40 @@
// COST-CATEGORY-001 to COST-CATEGORY-005: a typed category read as one of the fixed keys.
import { COST_CATEGORY_HINT, costCategoryOf, storedCostCategory } from './cost-category';
import { describe, expect, it } from 'vitest';
describe('costCategoryOf', () => {
it('COST-CATEGORY-001: a key stays itself', () => {
expect(costCategoryOf('food')).toBe('food');
expect(costCategoryOf('accommodation')).toBe('accommodation');
});
it('COST-CATEGORY-002: another case or a common name reads as its key', () => {
expect(costCategoryOf('Food')).toBe('food');
expect(costCategoryOf(' FOOD ')).toBe('food');
expect(costCategoryOf('Flight')).toBe('flights');
expect(costCategoryOf('Hotel')).toBe('accommodation');
expect(costCategoryOf('Car park')).toBe('parking');
});
it('COST-CATEGORY-003: anything else is no key', () => {
expect(costCategoryOf('Comida')).toBeNull();
expect(costCategoryOf('')).toBeNull();
expect(costCategoryOf(null)).toBeNull();
expect(costCategoryOf(undefined)).toBeNull();
});
it('COST-CATEGORY-004: an expense is stored under the key, other text as given, nothing as other', () => {
expect(storedCostCategory('Food')).toBe('food');
expect(storedCostCategory('Train')).toBe('transport');
expect(storedCostCategory('Comida')).toBe('Comida');
expect(storedCostCategory('')).toBe('other');
expect(storedCostCategory(null)).toBe('other');
expect(storedCostCategory(undefined)).toBe('other');
});
it('COST-CATEGORY-005: the hint an assistant reads names every key', () => {
for (const key of ['accommodation', 'food', 'transport', 'flights', 'other'])
expect(COST_CATEGORY_HINT).toContain(key);
});
});
+78
View File
@@ -0,0 +1,78 @@
import { COST_CATEGORIES, type CostCategory } from './budget.schema';
/**
* Free-text and legacy categories (and booking type labels) mapped to the fixed
* keys. Bookings used to store labels like "Flight"/"Train"/"Other", and an
* assistant writing through the MCP says "Food" where the key is "food".
*/
const CATEGORY_ALIASES: Record<string, CostCategory> = {
flight: 'flights',
flights: 'flights',
plane: 'flights',
flug: 'flights',
train: 'transport',
bus: 'transport',
car: 'transport',
'car rental': 'transport',
ferry: 'transport',
boat: 'transport',
taxi: 'transport',
transfer: 'transport',
cable_car: 'transport',
transport: 'transport',
transportation: 'transport',
hotel: 'accommodation',
accommodation: 'accommodation',
lodging: 'accommodation',
hostel: 'accommodation',
restaurant: 'food',
food: 'food',
dining: 'food',
meal: 'food',
meals: 'food',
grocery: 'groceries',
groceries: 'groceries',
activity: 'activities',
activities: 'activities',
sightseeing: 'sightseeing',
sights: 'sightseeing',
shop: 'shopping',
shopping: 'shopping',
fee: 'fees',
fees: 'fees',
health: 'health',
medical: 'health',
tip: 'tips',
tips: 'tips',
gas: 'fuel',
fuel: 'fuel',
petrol: 'fuel',
parking: 'parking',
parkings: 'parking',
'car park': 'parking',
other: 'other',
misc: 'other',
};
const KEYS: ReadonlySet<string> = new Set(COST_CATEGORIES);
/**
* The fixed key a stored or typed category stands for, whatever its case, or
* null for a category that is none of them.
*/
export function costCategoryOf(category: string | null | undefined): CostCategory | null {
if (!category) return null;
if (KEYS.has(category)) return category as CostCategory;
return CATEGORY_ALIASES[category.trim().toLowerCase()] ?? null;
}
/**
* The category an expense is stored under: the fixed key when the text names one,
* so "Food" and "food" are one category, and the text itself otherwise.
*/
export function storedCostCategory(category: string | null | undefined): string {
return costCategoryOf(category) ?? (category || 'other');
}
/** What the MCP tells an assistant about the category field. */
export const COST_CATEGORY_HINT = `One of the Costs categories: ${COST_CATEGORIES.join(', ')}. Another case or a common name (Hotel, Flight, Food) is stored as its key; any other text is kept as given and shows as other`;
+1
View File
@@ -38,6 +38,7 @@ export * from './packing/packed-count';
export * from './packing/packing-placeholder';
export * from './todo/todo.schema';
export * from './budget/budget.schema';
export * from './budget/cost-category';
export * from './budget/receipt-scan.schema';
export * from './reservation/reservation.schema';
export * from './reservation/reservation-types';
+5 -1
View File
@@ -293,7 +293,11 @@ export const journeyEntryUpdateToolSchema = z.strictObject({
weather: z.string().max(100).nullable().optional(),
tags: z.array(z.string()).nullable().optional(),
pros_cons: journeyEntryProsConsSchema.nullable().optional().describe('Replaces the whole verdict; null clears it'),
visibility: journeyEntryVisibilitySchema.optional(),
visibility: journeyEntryVisibilitySchema
.optional()
.describe(
'Stored with the entry. It does not decide the share link: every entry that is not a draft shows there. Use is_draft to keep an entry to the contributors',
),
type: journeyEntryTypeSchema
.optional()
.describe('Promote a trip-derived "skeleton" to a real "entry" once it has been written up'),
+2
View File
@@ -42,6 +42,8 @@ Each entry corresponds to a day in your journey. The entry editor provides:
- **Weather** — choose one of six values: Sunny, Partly cloudy, Cloudy, Rainy, Stormy, Snowy. (Snowy is stored under the id `cold`.)
- **Photos and videos** — attach photos to the entry. The first photo becomes the card thumbnail in list views. A video clip goes on an entry the same way: `mp4`, `m4v`, `webm` or `mov`, up to 500 MB per file. The clip is stored as uploaded, without transcoding, and the editor shows its first frame as a preview before you save. The journey gallery takes the same clips.
> **Note on HEIC files:** HEIC is an Apple-only format that many browsers and platforms do not recognise as an image. To ensure broad compatibility, HEIC/HEIF files are automatically converted to JPEG before upload. This conversion may result in the loss of embedded metadata (EXIF data such as GPS coordinates, camera information, etc.).
> **Note on Android:** the photo picker of Android browsers removes the location from photos before they reach any website, so a photo uploaded from an Android phone usually arrives without GPS and stays off the map. Photos added from a connected Immich or Synology Photos library keep their location, because TREK reads it from the library instead of the uploaded file.
- **Pros / Cons** — optional verdict cards. Add items to a **Pros** list (thumbs-up) or a **Cons** list (thumbs-down) to summarise what you loved or what could have been better. These are stored in the `pros_cons.pros` and `pros_cons.cons` arrays on the entry.
- **Tags** — free-form labels (e.g. "hidden gem", "best meal").
- **Location** — pin the entry to a map location.
+1 -1
View File
@@ -199,7 +199,7 @@ Requires `journey:read` or `journey:write` scope.
| `list_journey_entries` | List the entries of a journey in reading order (date, text, mood, linked trip), with the journey's id and title. `from` and `to` (`YYYY-MM-DD`, both included) keep only the entries of those days. |
| `get_journey_entry` | One journal entry by its id, as `list_journey_entries` answers it (photos included), with the id and title of its journey. |
| `create_journey_entry` | Add an entry with date (required), optional title, story text, time of day, location name, mood, and sort order. `is_draft: true` creates it as a draft that the journey share link leaves out. |
| `update_journey_entry` | Edit a journey entry's title, story, date, time of day, place, coordinates, weather, tags, mood, pros/cons list or visibility. `stats_excluded: true` keeps the entry but takes it off the route and out of `get_journey_stats`; `dismissed: true` waves a trip-derived suggestion away without deleting it, so the trip sync does not offer it again. `is_draft` turns the entry into a draft or publishes it. Answers with what the changed fields held before under `previous`. |
| `update_journey_entry` | Edit a journey entry's title, story, date, time of day, place, coordinates, weather, tags, mood, pros/cons list or visibility. `stats_excluded: true` keeps the entry but takes it off the route and out of `get_journey_stats`; `dismissed: true` waves a trip-derived suggestion away without deleting it, so the trip sync does not offer it again. `is_draft` turns the entry into a draft or publishes it; `visibility` is stored with the entry but keeps nothing off the share link, only a draft stays off it. Answers with what the changed fields held before under `previous`. |
| `delete_journey_entry` | Remove an entry from a journey. |
| `reorder_journey_entries` | Reorder entries by providing the new ordered list of entry IDs. Answers with the ids in their order before under `previous_order`. |
| `reorder_journey_entry_photos` | Put the photos of one entry in a new order by passing every photo id of the entry once. The first photo is the entry's cover. |
+20
View File
@@ -347,6 +347,26 @@ docker logs <container> 2>&1 | grep -E "SMTP test email (sent|failed)|SMTP test
---
## My location does not work: "Location requires a secure (HTTPS) connection"
**Cause:** Browsers only hand out the device location in a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts). Over plain HTTP at an address other than `localhost`, the location button on the map shows *Location requires a secure (HTTPS) connection*, and the journal's current location stays empty. The same goes for a phone opening TREK by its LAN address.
**Fix:** Open TREK over HTTPS. Two common ways when TREK runs at home:
- **A reverse proxy with a certificate**, see [Reverse-Proxy](Reverse-Proxy).
- **An HTTPS tunnel** (Cloudflare Tunnel, Tailscale Serve, ngrok and the like) that ends at TREK's HTTP port.
With a tunnel or proxy in front and plain HTTP still used on the LAN, set:
- `APP_URL` to the HTTPS address, so links in emails and calendar feeds use it.
- `TRUST_PROXY=1`, so TREK sees that the request came in over HTTPS.
- `COOKIE_SECURE=false` only if you also sign in over plain HTTP on the LAN; without it, that sign-in keeps no session. HTTPS sign-ins work either way.
- `ALLOWED_ORIGINS` needs no entry for either address: the address in the browser's address bar is always allowed. Add origins only for other sites that call TREK.
See [Environment Variables](Environment-Variables) for each of them.
---
## CORS error — API requests blocked in the browser
**Cause:** If `ALLOWED_ORIGINS` is set, only those origins are permitted, plus the host the request was sent to (the address in the browser's address bar is never cross-origin to itself). A request from any other origin is refused with `403 Not allowed by CORS`, and the server logs a `CORS: refused origin ...` warning naming the origin to add.