feat: quality of life sweep from the feature discussions

Small requests from the discussions, each built end to end: planner and
map (cycling mode, date first headings, clear a day, stay times per day,
off-route stops, map lock, compact unplanned markers, pending stays,
cable car, transit quick picks), places (location filter, category from
OSM, open in maps, geo: link, image from an attachment, enrichment on
file import, places near a pin, place name language, Amap first in
China), lists and costs (packed counter, inherited sharing, Markdown
to-dos, payment notes, bookings CSV, my plan PDF), sharing (unplanned
places, travel only, without photos), journey (photo zoom, drafts,
photo order, trip suggestion, day jump), atlas (region counter, region
bucket list, places per country), vacay half company holidays, studio
copy and paste plus the book's own layouts, and RESTORE_FROM_BACKUP for
a first start restore.
This commit is contained in:
Maurice
2026-09-30 23:00:12 +02:00
parent 91dfab3f4b
commit 8fd46c4c18
691 changed files with 9491 additions and 927 deletions
+2
View File
@@ -304,6 +304,8 @@ export function deriveBackup(raw: RawEnv) {
maxDecompressedMb: positiveNumberOr(raw.BACKUP_MAX_DECOMPRESSED_MB, 5 * 1024),
/** backupService only bundles data/.encryption_key into archives when the key does NOT come from env. */
encryptionKeyFromEnv: !!raw.ENCRYPTION_KEY,
/** Archive restored on the first start, while no database exists yet (#1089). Null when unset or blank. */
restoreFromBackup: raw.RESTORE_FROM_BACKUP?.trim() || null,
};
}
+2
View File
@@ -161,6 +161,8 @@ export const envSchema = z.object({
TREK_PLACE_PHOTO_DIR: anyString,
BACKUP_UPLOAD_LIMIT_MB: positiveNumber,
BACKUP_MAX_DECOMPRESSED_MB: positiveNumber,
// A backup archive to restore on the very first start, before any setup (#1089).
RESTORE_FROM_BACKUP: anyString,
// Admin / demo
ADMIN_EMAIL: anyString,
+66
View File
@@ -5336,6 +5336,72 @@ function runMigrations(db: Database.Database): void {
* is a no-op.
*/
() => createPlaceRegionsFollowPlaceTrigger(db),
/*
* A stop kept on the day but left out of its route (#2532): shown as a pin, not
* driven to. 0 routes it like every stop before this existed.
*/
() => {
const has = db.prepare("SELECT 1 FROM pragma_table_info('day_assignments') WHERE name = 'route_excluded'").get();
if (!has) db.exec('ALTER TABLE day_assignments ADD COLUMN route_excluded INTEGER NOT NULL DEFAULT 0');
},
/*
* How many of an item are packed so far (#2296), for "7 of 10 shirts". NULL is the
* plain checkbox every item has had, and `checked` stays the answer to "done?".
*/
() => {
const has = db.prepare("SELECT 1 FROM pragma_table_info('packing_items') WHERE name = 'packed_quantity'").get();
if (!has) db.exec('ALTER TABLE packing_items ADD COLUMN packed_quantity INTEGER');
},
/*
* A bucket-list wish for one state or province rather than the whole country
* (#1901). NULL keeps every existing wish a country wish.
*/
() => {
const has = db.prepare("SELECT 1 FROM pragma_table_info('bucket_list') WHERE name = 'region_code'").get();
if (!has) db.exec('ALTER TABLE bucket_list ADD COLUMN region_code TEXT');
},
/*
* Half company holidays (#2439): 0.5 covers the morning or afternoon and leaves
* the other half open for a half vacation day. 1 is what every holiday was.
*/
() => {
const has = db.prepare("SELECT 1 FROM pragma_table_info('vacay_company_holidays') WHERE name = 'fraction'").get();
if (!has) db.exec('ALTER TABLE vacay_company_holidays ADD COLUMN fraction REAL NOT NULL DEFAULT 1');
},
/*
* A journey entry still being written (#696): contributors see it, the public
* share page does not. Its own flag, since `visibility` defaults to 'private' on
* every entry and filtering on it would empty every shared journey.
*/
() => {
const has = db.prepare("SELECT 1 FROM pragma_table_info('journey_entries') WHERE name = 'is_draft'").get();
if (!has) db.exec('ALTER TABLE journey_entries ADD COLUMN is_draft INTEGER NOT NULL DEFAULT 0');
},
/*
* Two narrower ways to share a trip (#1712): only the travel (flights, trains,
* stays) without the day's activities, and without the place photos. Both off
* keeps every existing link exactly as it was.
*/
() => {
const hasTravel = db.prepare("SELECT 1 FROM pragma_table_info('share_tokens') WHERE name = 'share_travel_only'").get();
if (!hasTravel) db.exec('ALTER TABLE share_tokens ADD COLUMN share_travel_only INTEGER NOT NULL DEFAULT 0');
const hasImages = db.prepare("SELECT 1 FROM pragma_table_info('share_tokens') WHERE name = 'share_hide_images'").get();
if (!hasImages) db.exec('ALTER TABLE share_tokens ADD COLUMN share_hide_images INTEGER NOT NULL DEFAULT 0');
},
/**
* A note on a settle-up payment (#2340), as expenses already have one:
* "paid back in cash at the airport". Null on every existing payment.
*/
() => {
const has = db.prepare("SELECT 1 FROM pragma_table_info('budget_settlements') WHERE name = 'note'").get();
if (!has) db.exec('ALTER TABLE budget_settlements ADD COLUMN note TEXT');
},
];
if (currentVersion < migrations.length) {
+44 -1
View File
@@ -8,7 +8,10 @@ import path from 'node:path';
import fs from 'node:fs';
import http from 'node:http';
import type { INestApplication } from '@nestjs/common';
import { buildApp, getHttpServer } from './bootstrap';
// bootstrap is required inside bootstrap() below, not imported here: importing
// it opens the database, and a first-start restore (#1089) has to put the
// backup's database in place before that happens.
import type * as Bootstrap from './bootstrap';
// data/tmp is the driver-agnostic global scratch dir (restore-upload spool,
// mirror stream staging) and stays boot-created here. Driver-owned roots — the
@@ -87,7 +90,10 @@ async function bootstrap(): Promise<void> {
// global pipeline + /uploads + every /api domain + the platform/transport routes
// (/mcp, /.well-known, OAuth SDK, SPA catch-all). buildApp() owns the composition
// order; it is shared with the integration-test harness so they can't drift.
const restore = await restoreBeforeTheDatabaseOpens();
const { buildApp, getHttpServer } = require('./bootstrap') as typeof Bootstrap;
nestApp = await buildApp();
if (restore.restored) await finishFirstBootRestore(nestApp, restore);
// The server buildApp created and bound /ws to. Creating a second one here
// would serve the REST API fine and leave the gateway attached to a socket
// nobody listens on.
@@ -110,6 +116,43 @@ async function bootstrap(): Promise<void> {
else server.listen(PORT, onListen);
}
/**
* RESTORE_FROM_BACKUP on a first start (#1089), before anything opens the
* database. See nest/backup/boot-restore.ts for when it runs and why it fails
* the start rather than coming up empty.
*/
async function restoreBeforeTheDatabaseOpens() {
const { restoreOnFirstBoot } = require('./nest/backup/boot-restore') as typeof import('./nest/backup/boot-restore');
const env = readEnv();
const dataDir = path.join(__dirname, '../data');
return restoreOnFirstBoot({
archive: env.backup.restoreFromBackup,
dbFile: env.db.trekDbFile || path.join(dataDir, 'travel.db'),
dataDir,
});
}
/**
* The uploads go in once storage is up, through the same driver every upload
* uses, so an instance on S3 gets them in S3. A failure here leaves the restored
* database running and says where the files are, rather than stopping a start
* whose data is already in place.
*/
async function finishFirstBootRestore(
app: INestApplication,
restore: { uploads: string | null; staging: string },
): Promise<void> {
const { StorageService } = require('./nest/storage/storage.service') as typeof import('./nest/storage/storage.service');
const { rehydrateUploads } = require('./nest/backup/backup.impl') as typeof import('./nest/backup/backup.impl');
try {
if (restore.uploads) await rehydrateUploads(app.get(StorageService), restore.uploads);
fs.rmSync(restore.staging, { recursive: true, force: true });
console.log('[restore] Backup restored. Sign in with an account from the backup.');
} catch (err) {
console.error(`[restore] The database was restored, but the uploads could not be copied (${err instanceof Error ? err.message : String(err)}). They are still in ${restore.staging}.`);
}
}
bootstrap().catch((err) => {
console.error('Fatal: failed to bootstrap server', err);
process.exit(1);
@@ -20,6 +20,7 @@ import {
AssignmentEndDayDto,
AssignmentNotesDto,
AssignmentTransportDto,
AssignmentRouteDto,
AssignmentParticipantsDto,
} from './assignments.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@@ -93,6 +94,26 @@ export class DayAssignmentsController {
return { success: true };
}
// Declared before ':id' so the bare collection path is never read as an id (#2470).
@RequirePermission('day_edit')
@Delete()
clear(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('dayId') dayId: string,
@Headers('x-socket-id') socketId?: string,
) {
if (!this.assignments.dayExists(dayId, tripId)) {
throw new HttpException({ error: 'Day not found' }, 404);
}
const removedIds = this.assignments.clearDay(dayId);
for (const assignmentId of removedIds) {
this.assignments.broadcast(tripId, 'assignment:deleted', { assignmentId, dayId: Number(dayId) }, socketId);
}
if (removedIds.length > 0) this.assignments.reconcile(tripId, socketId);
return { success: true, removedIds };
}
@RequirePermission('day_edit')
@Delete(':id')
remove(
@@ -237,6 +258,23 @@ export class AssignmentOpsController {
return { assignment };
}
@RequirePermission('day_edit')
@Put(':id/route')
route(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body() body: AssignmentRouteDto,
@Headers('x-socket-id') socketId?: string,
) {
if (!this.assignments.getAssignmentForTrip(id, tripId)) {
throw new HttpException({ error: 'Assignment not found' }, 404);
}
const assignment = this.assignments.setRouteExcluded(id, body.excluded);
this.assignments.broadcast(tripId, 'assignment:updated', { assignment }, socketId);
return { assignment };
}
@RequirePermission('day_edit')
@Put(':id/participants')
setParticipants(
@@ -7,6 +7,7 @@ import {
assignmentEndDayRequestSchema,
assignmentNotesRequestSchema,
assignmentTransportRequestSchema,
assignmentRouteRequestSchema,
assignmentParticipantsRequestSchema,
} from '@trek/shared';
@@ -24,4 +25,5 @@ export class AssignmentTimeDto extends createZodDto(assignmentTimeRequestSchema)
export class AssignmentEndDayDto extends createZodDto(assignmentEndDayRequestSchema) {}
export class AssignmentNotesDto extends createZodDto(assignmentNotesRequestSchema) {}
export class AssignmentTransportDto extends createZodDto(assignmentTransportRequestSchema) {}
export class AssignmentRouteDto extends createZodDto(assignmentRouteRequestSchema) {}
export class AssignmentParticipantsDto extends createZodDto(assignmentParticipantsRequestSchema) {}
@@ -107,6 +107,30 @@ export class AssignmentsMcp {
return ok({ success: true });
}
@Tool({
name: 'clear_day_assignments',
description: 'Remove every place from one day in a single step. The day itself, its notes and its bookings stay; the places stay in the trip and can be planned again. Returns the removed assignment ids.',
inputSchema: {
tripId: z.number().int().positive(),
dayId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
access: { group: 'places', mode: 'write' },
})
async clearDayAssignments(
{ tripId, dayId }: { tripId: number; dayId: number },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!this.assignments.dayExists(dayId, tripId)) return errorResult('Day not found.');
const removedIds = this.assignments.clearDay(dayId);
for (const assignmentId of removedIds) this.guards.safeBroadcast(tripId, 'assignment:deleted', { assignmentId, dayId });
if (removedIds.length > 0) this.assignments.reconcile(tripId);
return ok({ success: true, removedIds });
}
@Tool({
name: 'update_assignment_time',
description: 'Set the start and/or end time for a place assignment on a day (e.g. "09:00", "11:30"). Pass null to clear a time.',
@@ -199,6 +223,30 @@ export class AssignmentsMcp {
return ok({ assignment });
}
@Tool({
name: 'set_assignment_route_excluded',
description: 'Keep a place on its day but leave it out of that day route, or put it back. An excluded stop still shows in the day plan and on the map, and the route runs from the stop before it straight to the one after. Use it for a place only visited on foot from somewhere nearby, or one that is just a point of interest.',
inputSchema: {
tripId: z.number().int().positive(),
assignmentId: z.number().int().positive(),
excluded: z.boolean().describe('true leaves the stop out of the route, false routes it again'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
access: { group: 'places', mode: 'write' },
})
async setAssignmentRouteExcluded(
{ tripId, assignmentId, excluded }: { tripId: number; assignmentId: number; excluded: boolean },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.');
const assignment = this.assignments.setRouteExcluded(assignmentId, excluded);
this.guards.safeBroadcast(tripId, 'assignment:updated', { assignment });
return ok({ assignment });
}
@Tool({
name: 'move_assignment',
description: 'Move a place assignment to a different day.',
@@ -202,6 +202,19 @@ export class AssignmentsService {
this.dbs.run('DELETE FROM day_assignments WHERE id = ?', id);
}
/**
* Empties a day: every place comes off it in one transaction, while the day, its
* notes and its bookings stay (#2470). Returns the removed ids so each can be
* announced the way a single unassign is.
*/
clearDay(dayId: string | number): number[] {
return this.dbs.transaction(() => {
const ids = this.dbs.all<{ id: number }>('SELECT id FROM day_assignments WHERE day_id = ?', dayId).map(r => r.id);
this.dbs.run('DELETE FROM day_assignments WHERE day_id = ?', dayId);
return ids;
});
}
reorderAssignments(dayId: string | number, orderedIds: number[]): void {
const update = this.dbs.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ? AND day_id = ?');
this.dbs.transaction(() => {
@@ -411,6 +424,15 @@ export class AssignmentsService {
return this.getAssignmentWithPlace(Number(id));
}
/**
* Keep a stop on the day but out of its route (#2532): it stays in the list and on
* the map, and the route runs from the stop before it straight to the one after.
*/
setRouteExcluded(id: string | number, excluded: boolean) {
this.dbs.run('UPDATE day_assignments SET route_excluded = ? WHERE id = ?', excluded ? 1 : 0, id);
return this.getAssignmentWithPlace(Number(id));
}
/**
* Both callers have already proven the assignment sits on `tripId`; this
* settles the other half, that the ids in the body do too. Off-roster ids drop
+2 -2
View File
@@ -152,9 +152,9 @@ export class AtlasController {
if (!body.name?.trim()) {
throw new HttpException({ error: 'Name is required' }, 400);
}
const { name, lat, lng, country_code, notes, target_date } = body;
const { name, lat, lng, country_code, notes, target_date, region_code } = body;
try {
return { item: this.atlas.createBucketItem(user.id, { name, lat, lng, country_code, notes, target_date }) };
return { item: this.atlas.createBucketItem(user.id, { name, lat, lng, country_code, notes, target_date, region_code }) };
} catch (err) {
// #1898: the same wish twice is a conflict, not a server error. Bespoke
// { error } body like the neighbouring 400/404s.
+8 -3
View File
@@ -2,7 +2,7 @@ import {
McpController, Tool, Resource, type McpContext,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, ok,
demoDenied, errorResult, ok,
} from '../../nest-mcp';
import { z } from 'zod';
import { ADDON_IDS } from '../../addons';
@@ -77,18 +77,23 @@ export class AtlasMcp {
// "same place, different date" case the report calls for was unreachable.
// update_bucket_list_item has had the field all along.
target_date: z.string().nullable().optional().describe('When you plan to go, e.g. "2027-05"'),
region_code: z.string().regex(/^[A-Za-z]{2}-[A-Za-z0-9]{1,8}$/).optional().describe('ISO 3166-2 code of a state or province on the wish list, e.g. "US-CA"; needs country_code and has to belong to it. Hatches that region on the Atlas map'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: atlasAddonOn,
access: { group: 'atlas', mode: 'write' },
})
async createBucketListItem(
{ name, lat, lng, country_code, notes, target_date }: { name: string; lat?: number; lng?: number; country_code?: string; notes?: string; target_date?: string | null },
{ name, lat, lng, country_code, notes, target_date, region_code }: { name: string; lat?: number; lng?: number; country_code?: string; notes?: string; target_date?: string | null; region_code?: string },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
// The same rule the REST contract enforces: a region comes with its own country.
if (region_code && (!country_code || !region_code.toUpperCase().startsWith(`${country_code.toUpperCase()}-`))) {
return errorResult('region_code must belong to country_code.');
}
try {
const item = this.atlas.createBucketItem(ctx.userId, { name, lat, lng, country_code, notes, target_date });
const item = this.atlas.createBucketItem(ctx.userId, { name, lat, lng, country_code, notes, target_date, region_code });
return ok({ item });
} catch (err) {
if (err instanceof BucketItemExistsError) return bucketDuplicateResult();
+15 -1
View File
@@ -47,6 +47,7 @@ export type CreateBucketData = {
country_code?: string | null;
notes?: string | null;
target_date?: string | null;
region_code?: string | null;
};
export type UpdateBucketData = {
@@ -95,6 +96,18 @@ function blankToNull(value: string | null | undefined): string | null {
return value === undefined || value === null || value === '' ? null : value;
}
/**
* A wished-for region as stored (#1901): ISO 3166-2, upper case as the region
* boundaries carry it, and only when it lies in the item's own country. Anything
* else is no region, which keeps a caller that skips the contract (the plugin RPC)
* from pinning a wish to somebody else's map.
*/
export function bucketRegionCode(region: string | null | undefined, country: string | null): string | null {
const code = blankToNull(region)?.toUpperCase() ?? null;
if (!code || !country || !/^[A-Z]{2}-[A-Z0-9]{1,8}$/.test(code)) return null;
return code.startsWith(`${country.toUpperCase()}-`) ? code : null;
}
/**
* Who ticked a country off, for the detail sheet.
*
@@ -986,7 +999,7 @@ export class AtlasService {
if (this.findDuplicateBucketItem(userId, identity)) throw new BucketItemExistsError();
const result = this.db
.prepare(
'INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes, target_date) VALUES (?, ?, ?, ?, ?, ?, ?)',
'INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes, target_date, region_code) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
)
.run(
userId,
@@ -996,6 +1009,7 @@ export class AtlasService {
identity.country_code,
data.notes ?? null,
identity.target_date,
bucketRegionCode(data.region_code, identity.country_code),
);
return this.db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(result.lastInsertRowid);
}
+123
View File
@@ -0,0 +1,123 @@
import fs from 'fs';
import path from 'path';
import unzipper from 'unzipper';
import type Database from 'better-sqlite3';
import { readEnv } from '../../app-config';
import { openDatabase } from '../../db/connection';
/**
* Reading a backup archive, without touching the live database.
*
* Split out of backup.impl.ts so the restore that runs before the database is
* opened (boot-restore.ts, #1089) can use the same guards as the one in the
* admin panel: importing backup.impl.ts opens the database as a side effect,
* which is exactly what a first-boot restore has to happen before.
*/
// Frozen at import on purpose (legacy timing, see backup.impl.ts).
const backupEnv = readEnv().backup;
export const MAX_BACKUP_UPLOAD_SIZE = backupEnv.uploadLimitMb * 1024 * 1024; // compressed
export const MAX_BACKUP_DECOMPRESSED_SIZE = backupEnv.maxDecompressedMb * 1024 * 1024;
/** A refusal in the restore route's own words and status. */
export interface ArchiveRefusal {
error: string;
status: number;
}
/**
* Unpack an archive into `extractDir`, or say why not.
*
* Fast reject on the central-directory's declared size, then extract entry-by-entry
* enforcing the ACTUAL decompressed bytes. The declared uncompressedSize is
* attacker-declarable — a zip bomb can under-report it and expand past the cap during
* extraction — so the real guard counts bytes as they are written and aborts once the
* running total crosses the cap. Each entry's resolved path is also confined to
* extractDir (a `../` entry that escaped the root — zip-slip — is refused).
*
* A refusal leaves nothing behind. Any other failure is thrown, also with the
* directory removed.
*/
export async function extractBackupArchive(zipPath: string, extractDir: string): Promise<ArchiveRefusal | null> {
const directory = await unzipper.Open.file(zipPath);
const claimedSize = directory.files.reduce((sum, f) => sum + (f.uncompressedSize || 0), 0);
if (claimedSize > MAX_BACKUP_DECOMPRESSED_SIZE) {
return { error: 'Backup exceeds the maximum decompressed size.', status: 400 };
}
fs.mkdirSync(extractDir, { recursive: true });
let decompressedBytes = 0;
for (const entry of directory.files) {
if (entry.type === 'Directory') continue;
const dest = path.join(extractDir, entry.path);
const rel = path.relative(extractDir, dest);
if (rel.startsWith('..') || path.isAbsolute(rel)) {
fs.rmSync(extractDir, { recursive: true, force: true });
return { error: 'Invalid backup: an entry path escapes the archive root.', status: 400 };
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
try {
await new Promise<void>((resolve, reject) => {
const source = entry.stream();
const out = fs.createWriteStream(dest);
source.on('data', (chunk: Buffer) => {
decompressedBytes += chunk.length;
if (decompressedBytes > MAX_BACKUP_DECOMPRESSED_SIZE) {
source.destroy();
out.destroy();
reject(new Error('DECOMPRESSED_CAP_EXCEEDED'));
}
});
source.on('error', reject);
out.on('error', reject);
out.on('finish', resolve);
source.pipe(out);
});
} catch (err) {
fs.rmSync(extractDir, { recursive: true, force: true });
if (err instanceof Error && err.message === 'DECOMPRESSED_CAP_EXCEEDED') {
return { error: 'Backup exceeds the maximum decompressed size.', status: 400 };
}
throw err;
}
}
return null;
}
/**
* Whether an unpacked archive holds a TREK database that can be put in place:
* present, intact, and carrying the tables every TREK has. Read-only; the file
* is closed again before this returns.
*/
export function checkBackupDatabase(extractDir: string): ArchiveRefusal | null {
const extractedDb = path.join(extractDir, 'travel.db');
if (!fs.existsSync(extractedDb)) {
return { error: 'Invalid backup: travel.db not found', status: 400 };
}
let uploadedDb: InstanceType<typeof Database> | null = null;
try {
uploadedDb = openDatabase(extractedDb, { readonly: true });
const integrityResult = uploadedDb.prepare('PRAGMA integrity_check').get() as { integrity_check: string };
if (integrityResult.integrity_check !== 'ok') {
return { error: `Uploaded database failed integrity check: ${integrityResult.integrity_check}`, status: 400 };
}
const requiredTables = ['users', 'trips', 'trip_members', 'places', 'days'];
const existingTables = uploadedDb
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
.all() as { name: string }[];
const tableNames = new Set(existingTables.map(t => t.name));
for (const table of requiredTables) {
if (!tableNames.has(table)) {
return { error: `Uploaded database is missing required table: ${table}. This does not appear to be a TREK backup.`, status: 400 };
}
}
return null;
} catch {
return { error: 'Uploaded file is not a valid SQLite database', status: 400 };
} finally {
uploadedDb?.close();
}
}
+11 -93
View File
@@ -1,13 +1,11 @@
import archiver from 'archiver';
import unzipper from 'unzipper';
import path from 'path';
import { pipeline } from 'node:stream/promises';
import { readEnv } from '../../app-config';
import fs from 'fs';
import type Database from 'better-sqlite3';
import { openDatabase } from '../../db/connection';
import { db, closeDb, reinitialize } from '../../db/database';
import { VALID_INTERVALS } from './auto-backup.settings';
import { checkBackupDatabase, extractBackupArchive } from './backup-archive';
import { invalidatePermissionsCache } from '../permissions/permissions-cache';
import { pluginsCodeRoot, pluginsDataRoot } from '../plugins/paths';
import { stageExtractedPluginTrees, applyStagedRestoreNow } from '../plugins/plugin-backup';
@@ -24,16 +22,9 @@ const dataDir = path.join(__dirname, '../../../data');
// Compressed upload cap for restore archives. Defaults to 500 MB, raisable via
// BACKUP_UPLOAD_LIMIT_MB for instances whose backups (uploads/ included) grow
// past that. Malformed values abort boot (app-config fail-fast validation);
// frozen at import on purpose (legacy timing).
const backupEnv = readEnv().backup;
export const MAX_BACKUP_UPLOAD_SIZE = backupEnv.uploadLimitMb * 1024 * 1024; // compressed
// Upper bound on the TOTAL decompressed size of a restore archive (the upload
// limit only caps the compressed bytes). Default 5 GB, raisable via
// BACKUP_MAX_DECOMPRESSED_MB for an instance whose own backups (now including the
// plugin trees) legitimately grow past it — otherwise its own backups become
// unrestorable.
export const MAX_BACKUP_DECOMPRESSED_SIZE = backupEnv.maxDecompressedMb * 1024 * 1024;
// past that. Upper bound on the TOTAL decompressed size: default 5 GB, raisable
// via BACKUP_MAX_DECOMPRESSED_MB. Both live beside the archive reader now.
export { MAX_BACKUP_UPLOAD_SIZE, MAX_BACKUP_DECOMPRESSED_SIZE } from './backup-archive';
// ---------------------------------------------------------------------------
// Helpers
@@ -350,7 +341,7 @@ const isBackupCategory = (dir: string): dir is (typeof BACKUP_UPLOAD_CATEGORIES)
* with a warning (2026-08-17 decision): new archives never contain them, and
* the category mapping stays structural in both directions.
*/
async function rehydrateUploads(storage: StorageService, extractedUploads: string): Promise<void> {
export async function rehydrateUploads(storage: StorageService, extractedUploads: string): Promise<void> {
const walk = (dir: string): string[] =>
fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const p = path.join(dir, e.name);
@@ -380,88 +371,15 @@ export async function restoreFromZip(storage: StorageService, zipPath: string):
const extractDir = path.join(dataDir, `restore-${Date.now()}`);
let reinitFailed: unknown = null;
try {
// Fast reject on the central-directory's declared size, then extract entry-by-entry
// enforcing the ACTUAL decompressed bytes. The declared uncompressedSize is
// attacker-declarable — a zip bomb can under-report it and expand past the cap during
// extraction — so the real guard counts bytes as they are written and aborts once the
// running total crosses the cap. Each entry's resolved path is also confined to
// extractDir (a `../` entry that escaped the root — zip-slip — is refused).
const directory = await unzipper.Open.file(zipPath);
const claimedSize = directory.files.reduce((sum, f) => sum + (f.uncompressedSize || 0), 0);
if (claimedSize > MAX_BACKUP_DECOMPRESSED_SIZE) {
return { success: false, error: 'Backup exceeds the maximum decompressed size.', status: 400 };
}
const refused = await extractBackupArchive(zipPath, extractDir);
if (refused) return { success: false, ...refused };
fs.mkdirSync(extractDir, { recursive: true });
let decompressedBytes = 0;
for (const entry of directory.files) {
if (entry.type === 'Directory') continue;
const dest = path.join(extractDir, entry.path);
const rel = path.relative(extractDir, dest);
if (rel.startsWith('..') || path.isAbsolute(rel)) {
fs.rmSync(extractDir, { recursive: true, force: true });
return { success: false, error: 'Invalid backup: an entry path escapes the archive root.', status: 400 };
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
try {
await new Promise<void>((resolve, reject) => {
const source = entry.stream();
const out = fs.createWriteStream(dest);
source.on('data', (chunk: Buffer) => {
decompressedBytes += chunk.length;
if (decompressedBytes > MAX_BACKUP_DECOMPRESSED_SIZE) {
source.destroy();
out.destroy();
reject(new Error('DECOMPRESSED_CAP_EXCEEDED'));
}
});
source.on('error', reject);
out.on('error', reject);
out.on('finish', resolve);
source.pipe(out);
});
} catch (err) {
fs.rmSync(extractDir, { recursive: true, force: true });
if (err instanceof Error && err.message === 'DECOMPRESSED_CAP_EXCEEDED') {
return { success: false, error: 'Backup exceeds the maximum decompressed size.', status: 400 };
}
throw err;
}
const unreadable = checkBackupDatabase(extractDir);
if (unreadable) {
fs.rmSync(extractDir, { recursive: true, force: true });
return { success: false, ...unreadable };
}
const extractedDb = path.join(extractDir, 'travel.db');
if (!fs.existsSync(extractedDb)) {
fs.rmSync(extractDir, { recursive: true, force: true });
return { success: false, error: 'Invalid backup: travel.db not found', status: 400 };
}
let uploadedDb: InstanceType<typeof Database> | null = null;
try {
uploadedDb = openDatabase(extractedDb, { readonly: true });
const integrityResult = uploadedDb.prepare('PRAGMA integrity_check').get() as { integrity_check: string };
if (integrityResult.integrity_check !== 'ok') {
fs.rmSync(extractDir, { recursive: true, force: true });
return { success: false, error: `Uploaded database failed integrity check: ${integrityResult.integrity_check}`, status: 400 };
}
const requiredTables = ['users', 'trips', 'trip_members', 'places', 'days'];
const existingTables = uploadedDb
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
.all() as { name: string }[];
const tableNames = new Set(existingTables.map(t => t.name));
for (const table of requiredTables) {
if (!tableNames.has(table)) {
fs.rmSync(extractDir, { recursive: true, force: true });
return { success: false, error: `Uploaded database is missing required table: ${table}. This does not appear to be a TREK backup.`, status: 400 };
}
}
} catch (err) {
fs.rmSync(extractDir, { recursive: true, force: true });
return { success: false, error: 'Uploaded file is not a valid SQLite database', status: 400 };
} finally {
uploadedDb?.close();
}
closeDb();
+93
View File
@@ -0,0 +1,93 @@
import fs from 'fs';
import path from 'path';
import { stageExtractedPluginTrees } from '../plugins/plugin-backup';
import { checkBackupDatabase, extractBackupArchive } from './backup-archive';
/**
* Restoring a backup on the very first start (#1089).
*
* Moving an instance used to mean setting up a new one first: fetch the admin
* password from the logs, sign in, click through the welcome, then restore in
* the admin panel and throw the fresh admin away again. With
* RESTORE_FROM_BACKUP pointing at an archive, the first start restores it
* instead, and the instance comes up as the one the backup was taken from.
*
* ── When it runs ─────────────────────────────────────────────────────────
*
* Only while there is no database yet. The variable is typically left in the
* compose file after the move, and a restart must not roll the instance back to
* the day it moved; with a database present it is ignored with a warning.
*
* It runs before the database is opened, not after: the archive's database is
* migrated on the normal open, the admin seed finds its users and adds none, and
* the at-rest key the archive carries is in place before anything reads it. A
* restore after startup would need a restart for that key, which is the setup
* step this exists to remove.
*
* ── Why not an upload on the setup screen ────────────────────────────────
*
* A restore that anyone may run on an empty instance is a takeover: whoever
* reaches it first installs their own admins. The environment belongs to
* whoever runs the container, so that is where the switch lives.
*
* ── When it fails ────────────────────────────────────────────────────────
*
* Loudly, and without starting. An empty instance coming up instead would be
* worse than no instance: its new database would then stop the restore from
* ever running on the next start.
*/
export interface BootRestorePlan {
/** The archive to restore, or null when the variable is not set. */
archive: string | null;
/** Where the database lives: TREK_DB_FILE, or data/travel.db. */
dbFile: string;
/** The data directory, for the scratch space and the at-rest key. */
dataDir: string;
}
export type BootRestoreOutcome =
| { restored: false; reason: 'unset' | 'database-exists' }
/** `uploads` is the unpacked uploads tree, to hand to storage once it is up. Null when the archive had none. */
| { restored: true; uploads: string | null; staging: string };
export class BootRestoreError extends Error {}
export async function restoreOnFirstBoot(plan: BootRestorePlan): Promise<BootRestoreOutcome> {
if (!plan.archive) return { restored: false, reason: 'unset' };
if (fs.existsSync(plan.dbFile)) {
console.warn(`[restore] RESTORE_FROM_BACKUP is set, but ${plan.dbFile} already exists. The backup is only restored on a first start; remove the variable to silence this.`);
return { restored: false, reason: 'database-exists' };
}
if (!fs.existsSync(plan.archive) || !fs.statSync(plan.archive).isFile()) {
throw new BootRestoreError(`RESTORE_FROM_BACKUP: no backup archive at ${plan.archive}`);
}
console.log(`[restore] Restoring ${plan.archive} before the first start`);
const staging = path.join(plan.dataDir, `restore-boot-${Date.now()}`);
const refused = await extractBackupArchive(plan.archive, staging);
if (refused) throw new BootRestoreError(`RESTORE_FROM_BACKUP: ${refused.error}`);
const unreadable = checkBackupDatabase(staging);
if (unreadable) {
fs.rmSync(staging, { recursive: true, force: true });
throw new BootRestoreError(`RESTORE_FROM_BACKUP: ${unreadable.error}`);
}
fs.mkdirSync(path.dirname(plan.dbFile), { recursive: true });
// Copied in beside the target and renamed, so a crash leaves either nothing or a whole file.
const tmp = `${plan.dbFile}.restore-tmp`;
fs.copyFileSync(path.join(staging, 'travel.db'), tmp);
fs.renameSync(tmp, plan.dbFile);
// The key the archive's secrets were encrypted with. An ENCRYPTION_KEY in the
// environment still wins over the file, exactly as on any other start.
const key = path.join(staging, '.encryption_key');
if (fs.existsSync(key)) fs.copyFileSync(key, path.join(plan.dataDir, '.encryption_key'));
// Plugin trees are swapped in by the runtime's own boot reconcile.
stageExtractedPluginTrees(staging);
const uploads = path.join(staging, 'uploads');
return { restored: true, uploads: fs.existsSync(uploads) ? uploads : null, staging };
}
+2 -1
View File
@@ -99,7 +99,7 @@ export class BudgetController {
tripId,
{
from_user_id: body.from_user_id, to_user_id: body.to_user_id, amount: body.amount,
currency: body.currency, settled_at: body.settled_at, fallback_fx: body.fallback_fx,
currency: body.currency, settled_at: body.settled_at, note: body.note, fallback_fx: body.fallback_fx,
},
user.id,
);
@@ -127,6 +127,7 @@ export class BudgetController {
amount: body.amount,
currency: body.currency,
settled_at: body.settled_at,
note: body.note,
fallback_fx: body.fallback_fx,
});
if (!settlement) {
+8 -6
View File
@@ -432,7 +432,7 @@ export class BudgetMcp {
@Tool({
name: 'create_settlement',
description: "Record a settle-up payment: from_user_id paid to_user_id the given amount to settle shared expenses. The amount is in the trip's base currency unless `currency` says otherwise. Use get_settlement_summary first to find who owes whom and how much. When the server has no exchange rate for a foreign `currency`, the payment counts in no balance and is listed under `unconverted` in get_settlement_summary until freeze_budget_rates pins one.",
description: "Record a settle-up payment: from_user_id paid to_user_id the given amount to settle shared expenses, optionally with a note. The amount is in the trip's base currency unless `currency` says otherwise. Use get_settlement_summary first to find who owes whom and how much. When the server has no exchange rate for a foreign `currency`, the payment counts in no balance and is listed under `unconverted` in get_settlement_summary until freeze_budget_rates pins one.",
inputSchema: {
tripId: z.number().int().positive(),
from_user_id: z.number().int().positive().describe('User ID of the member who paid'),
@@ -440,13 +440,14 @@ export class BudgetMcp {
amount: z.number().positive().describe('Amount paid, in `currency`'),
currency: z.string().max(10).nullable().optional().describe("ISO currency code the payment was made in (e.g. \"USD\"); defaults to the trip currency. Its FX rate is frozen now, so the transfer keeps cancelling its expense when live rates drift."),
settled_at: z.string().max(40).nullable().optional().describe('Day the payment actually happened, YYYY-MM-DD. Omitted means no day is stored and the ledger files the payment under the day it was recorded.'),
note: z.string().max(500).nullable().optional().describe('A short note on the payment, such as how or where it was paid'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: budgetAddonOn,
access: { group: 'budget', mode: 'write' },
})
async createSettlement(
{ tripId, from_user_id, to_user_id, amount, currency, settled_at }: { tripId: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null },
{ tripId, from_user_id, to_user_id, amount, currency, settled_at, note }: { tripId: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null; note?: string | null },
ctx: McpContext,
) {
if (this.isDemoUser(ctx.userId)) return demoDenied();
@@ -454,7 +455,7 @@ export class BudgetMcp {
if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
// Freeze-then-write composite, same as the REST path: the rate for the display
// currency is frozen at entry time (#1445).
const settlement = await this.budget.createSettlement(tripId, { from_user_id, to_user_id, amount, currency, settled_at }, ctx.userId);
const settlement = await this.budget.createSettlement(tripId, { from_user_id, to_user_id, amount, currency, settled_at, note }, ctx.userId);
if (!settlement) return errorResult('Settlement not found.');
this.guards.safeBroadcast(tripId, 'budget:settlement-created', { settlement });
return ok({ settlement });
@@ -462,7 +463,7 @@ export class BudgetMcp {
@Tool({
name: 'update_settlement',
description: 'Update a recorded settle-up payment (who paid, who received, the amount and the currency it was made in). Every field is a full replace, so restate the ones that stay the same. When the server has no exchange rate for a foreign `currency`, the payment counts in no balance and is listed under `unconverted` in get_settlement_summary until freeze_budget_rates pins one.',
description: 'Update a recorded settle-up payment (who paid, who received, the amount, the currency it was made in and its note). Every field is a full replace, so restate the ones that stay the same. When the server has no exchange rate for a foreign `currency`, the payment counts in no balance and is listed under `unconverted` in get_settlement_summary until freeze_budget_rates pins one.',
inputSchema: {
tripId: z.number().int().positive(),
settlementId: z.number().int().positive(),
@@ -471,13 +472,14 @@ export class BudgetMcp {
amount: z.number().positive().describe('Amount paid, in `currency`'),
currency: z.string().max(10).nullable().optional().describe('ISO currency code the payment was made in (e.g. "USD"); null puts it back in the trip currency. Omit to leave it as recorded, which also keeps the rate frozen at settle time.'),
settled_at: z.string().max(40).nullable().optional().describe('Date the payment actually happened, YYYY-MM-DD. Omit to leave it as recorded.'),
note: z.string().max(500).nullable().optional().describe('A short note on the payment; null or blank clears it. Omit to leave it as recorded.'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
when: budgetAddonOn,
access: { group: 'budget', mode: 'write' },
})
async updateSettlement(
{ tripId, settlementId, from_user_id, to_user_id, amount, currency, settled_at }: { tripId: number; settlementId: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null },
{ tripId, settlementId, from_user_id, to_user_id, amount, currency, settled_at, note }: { tripId: number; settlementId: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null; note?: string | null },
ctx: McpContext,
) {
if (this.isDemoUser(ctx.userId)) return demoDenied();
@@ -485,7 +487,7 @@ export class BudgetMcp {
if (!this.guards.hasTripPermission('budget_edit', tripId, ctx.userId)) return permissionDenied();
// Freeze-then-write composite, same as the REST path: an edit that leaves the
// currency alone keeps the rate frozen at settle time.
const settlement = await this.budget.updateSettlement(settlementId, tripId, { from_user_id, to_user_id, amount, currency, settled_at });
const settlement = await this.budget.updateSettlement(settlementId, tripId, { from_user_id, to_user_id, amount, currency, settled_at, note });
if (!settlement) return errorResult('Settlement not found.');
this.guards.safeBroadcast(tripId, 'budget:settlement-updated', { settlement });
return ok({ settlement });
+18 -9
View File
@@ -13,11 +13,17 @@ type Trip = TripAccess;
type SettlementRow = {
id: number; trip_id: string; from_user_id: number; to_user_id: number;
amount: number; currency: string | null; exchange_rate: number | null;
created_at: string; settled_at: string | null; created_by_user_id: number | null;
created_at: string; settled_at: string | null; note?: string | null; created_by_user_id: number | null;
from_username: string; from_avatar: string | null;
to_username: string; to_avatar: string | null;
};
/** A settle-up note as stored (#2340): trimmed, and nothing at all when blank. */
function settlementNote(note: string | null | undefined): string | null {
const trimmed = (note ?? '').trim();
return trimmed ? trimmed : null;
}
/** How the costs UI used to smuggle an itemized receipt through the note field. */
const LEGACY_TICKET_PREFIX = 'TICKETJSON:';
@@ -1481,7 +1487,7 @@ export class BudgetService {
// Settlement usernames use COALESCE(display_name, username) like every item
// query (the legacy raw fu.username was the odd one out).
private static readonly SETTLEMENT_SELECT = `
SELECT s.id, s.trip_id, s.from_user_id, s.to_user_id, s.amount, s.currency, s.exchange_rate, s.created_at, s.settled_at, s.created_by_user_id,
SELECT s.id, s.trip_id, s.from_user_id, s.to_user_id, s.amount, s.currency, s.exchange_rate, s.created_at, s.settled_at, s.note, s.created_by_user_id,
COALESCE(fu.display_name, fu.username) AS from_username, fu.avatar AS from_avatar,
COALESCE(tu.display_name, tu.username) AS to_username, tu.avatar AS to_avatar
FROM budget_settlements s
@@ -1494,7 +1500,7 @@ export class BudgetService {
id: r.id, trip_id: r.trip_id,
from_user_id: r.from_user_id, to_user_id: r.to_user_id,
amount: r.amount, currency: r.currency ?? null, exchange_rate: r.exchange_rate ?? 1,
created_at: r.created_at, settled_at: r.settled_at ?? null, created_by_user_id: r.created_by_user_id,
created_at: r.created_at, settled_at: r.settled_at ?? null, note: r.note ?? null, created_by_user_id: r.created_by_user_id,
from_username: r.from_username, from_avatar_url: avatarUrl({ avatar: r.from_avatar }),
to_username: r.to_username, to_avatar_url: avatarUrl({ avatar: r.to_avatar }),
};
@@ -1521,15 +1527,16 @@ export class BudgetService {
/** Raw settlement insert (no FX freeze) — the REST path wraps it in createSettlement. */
insertSettlement(
tripId: string | number,
data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; exchange_rate?: number; settled_at?: string | null },
data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; exchange_rate?: number; settled_at?: string | null; note?: string | null },
createdByUserId?: number,
) {
const result = this.db.run(
'INSERT INTO budget_settlements (trip_id, from_user_id, to_user_id, amount, currency, exchange_rate, settled_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
'INSERT INTO budget_settlements (trip_id, from_user_id, to_user_id, amount, currency, exchange_rate, settled_at, note, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
tripId, data.from_user_id, data.to_user_id, Math.round(data.amount * 100) / 100,
data.currency ? data.currency.toUpperCase() : null,
data.exchange_rate != null ? data.exchange_rate : 1,
data.settled_at || null,
settlementNote(data.note),
createdByUserId ?? null,
);
return this.getSettlement(Number(result.lastInsertRowid), tripId);
@@ -1539,7 +1546,7 @@ export class BudgetService {
applySettlementUpdate(
id: string | number,
tripId: string | number,
data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; exchange_rate?: number; settled_at?: string | null },
data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; exchange_rate?: number; settled_at?: string | null; note?: string | null },
) {
const row = this.db.get('SELECT id FROM budget_settlements WHERE id = ? AND trip_id = ?', id, tripId);
if (!row) return null;
@@ -1548,13 +1555,15 @@ export class BudgetService {
from_user_id = ?, to_user_id = ?, amount = ?,
currency = CASE WHEN ? THEN ? ELSE currency END,
exchange_rate = CASE WHEN ? IS NOT NULL THEN ? ELSE exchange_rate END,
settled_at = CASE WHEN ? THEN ? ELSE settled_at END
settled_at = CASE WHEN ? THEN ? ELSE settled_at END,
note = CASE WHEN ? THEN ? ELSE note END
WHERE id = ?
`,
data.from_user_id, data.to_user_id, Math.round(data.amount * 100) / 100,
data.currency !== undefined ? 1 : 0, data.currency ? data.currency.toUpperCase() : null,
data.exchange_rate !== undefined ? 1 : null, data.exchange_rate !== undefined ? data.exchange_rate : 1,
data.settled_at !== undefined ? 1 : 0, data.settled_at || null,
data.note !== undefined ? 1 : 0, settlementNote(data.note),
id,
);
return this.getSettlement(id, tripId);
@@ -1629,7 +1638,7 @@ export class BudgetService {
return roster.has(data.from_user_id) && roster.has(data.to_user_id);
}
async createSettlement(tripId: string | number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null; fallback_fx?: BudgetFallbackFx }, userId: number) {
async createSettlement(tripId: string | number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null; note?: string | null; fallback_fx?: BudgetFallbackFx }, userId: number) {
if (!this.settlementPartiesOnTrip(tripId, data)) return null;
// Freeze the FX rate for the display currency the amount was entered in so the
// transfer keeps cancelling its expense when live rates drift (#1445).
@@ -1637,7 +1646,7 @@ export class BudgetService {
return this.insertSettlement(tripId, data, userId);
}
async updateSettlement(id: string | number, tripId: string | number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null; fallback_fx?: BudgetFallbackFx }) {
async updateSettlement(id: string | number, tripId: string | number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string | null; settled_at?: string | null; note?: string | null; fallback_fx?: BudgetFallbackFx }) {
// Pass the settlement's stored currency so an edit that doesn't change it keeps
// the already-frozen rate (#1445) — otherwise a live-rate drift would re-open a
// settled position on an unrelated edit.
+3 -2
View File
@@ -2,6 +2,8 @@ import path from 'path';
import type { Options } from 'multer';
export const MAX_PLACE_IMAGE_SIZE = 20 * 1024 * 1024; // 20 MB — same cap as covers.
/** The picture types a place image may be, whether uploaded or taken from an attachment. */
export const PLACE_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
/**
* fileFilter for the custom place-image upload (#1136), shared by the
@@ -11,8 +13,7 @@ export const MAX_PLACE_IMAGE_SIZE = 20 * 1024 * 1024; // 20 MB — same cap as c
*/
export const PLACE_IMAGE_FILE_FILTER: Options['fileFilter'] = (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const allowed = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
if (file.mimetype.startsWith('image/') && !file.mimetype.includes('svg') && allowed.includes(ext)) {
if (file.mimetype.startsWith('image/') && !file.mimetype.includes('svg') && PLACE_IMAGE_EXTENSIONS.includes(ext)) {
cb(null, true);
} else {
// Carry statusCode so TrekExceptionFilter maps the rejection to a 400 rather
+2
View File
@@ -21,6 +21,8 @@ export function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial<Tag>[]
end_day: a.end_day === 1,
leg_transport_mode: a.leg_transport_mode ?? null,
incoming_leg_transport_mode: a.incoming_leg_transport_mode ?? null,
// Kept on the day but not driven to (#2532).
route_excluded: a.route_excluded === 1,
// Which booking put this stop here, if a booking did. The day list has nothing
// else to tell it from a place the traveller added, and it must not draw the
// hotel a second time under the overnight block that already names it.
+1
View File
@@ -130,6 +130,7 @@ export class DaysService {
day_id: a.day_id,
order_index: a.order_index,
end_day: a.end_day === 1,
route_excluded: a.route_excluded === 1,
notes: a.notes,
// Repeated here for the same reason stop_type is: the day list hides the stop
// a booking wrote, and this copy is one of the paths that feed it.
@@ -1291,6 +1291,7 @@ export class JourneyDomainService {
pros_cons?: { pros: string[]; cons: string[] };
visibility?: string;
sort_order?: number;
is_draft?: boolean;
},
sid?: string,
): JourneyEntryWire | null {
@@ -1309,8 +1310,8 @@ export class JourneyDomainService {
const res = this.db
.prepare(
`
INSERT INTO journey_entries (journey_id, author_id, type, title, story, entry_date, entry_time, location_name, location_lat, location_lng, country_code, mood, weather, tags, pros_cons, visibility, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO journey_entries (journey_id, author_id, type, title, story, entry_date, entry_time, location_name, location_lat, location_lng, country_code, mood, weather, tags, pros_cons, visibility, sort_order, is_draft, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
)
.run(
@@ -1331,6 +1332,7 @@ export class JourneyDomainService {
prosConsJson,
data.visibility || 'private',
(maxOrder?.m ?? -1) + 1,
data.is_draft ? 1 : 0,
now,
now,
);
@@ -1364,6 +1366,7 @@ export class JourneyDomainService {
sort_order: number;
stats_excluded: boolean;
dismissed: boolean;
is_draft: boolean;
}>,
sid?: string,
): JourneyEntryWire | null {
@@ -1394,6 +1397,7 @@ export class JourneyDomainService {
'sort_order',
'stats_excluded',
'dismissed',
'is_draft',
]);
for (const [key, val] of Object.entries(data)) {
@@ -1405,7 +1409,7 @@ export class JourneyDomainService {
} else if (key === 'pros_cons') {
fields.push('pros_cons = ?');
values.push(val && typeof val === 'object' ? JSON.stringify(val) : val);
} else if (key === 'stats_excluded' || key === 'dismissed') {
} else if (key === 'stats_excluded' || key === 'dismissed' || key === 'is_draft') {
// INTEGER columns, and better-sqlite3 refuses to bind a boolean.
fields.push(`${key} = ?`);
values.push(val ? 1 : 0);
@@ -1474,6 +1478,30 @@ export class JourneyDomainService {
return true;
}
/**
* The photos of one entry in a new order (#824), in one transaction. The list has
* to be exactly the entry's photos, each once, so a stale or foreign id cannot
* slip in. The order is kept on this entry's own links: a photo that also sits on
* another entry keeps its place there.
*/
reorderEntryPhotos(entryId: number, userId: number, orderedIds: number[], sid?: string): boolean {
const entry = this.db.prepare('SELECT id, journey_id FROM journey_entries WHERE id = ?').get(entryId) as { id: number; journey_id: number } | undefined;
if (!entry || !this.canEdit(entry.journey_id, userId)) return false;
const held = (this.db.prepare('SELECT journey_photo_id FROM journey_entry_photos WHERE entry_id = ?').all(entryId) as { journey_photo_id: number }[])
.map(r => r.journey_photo_id);
const asked = new Set(orderedIds);
if (asked.size !== orderedIds.length || held.length !== orderedIds.length || held.some(id => !asked.has(id))) return false;
const update = this.db.prepare('UPDATE journey_entry_photos SET sort_order = ? WHERE entry_id = ? AND journey_photo_id = ?');
this.db.connection.transaction(() => {
orderedIds.forEach((id, index) => update.run(index, entryId, id));
this.db.prepare('UPDATE journey_entries SET updated_at = ? WHERE id = ?').run(this.ts(), entryId);
})();
const updated = decodeEntryRow(this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry);
this.broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entry: updated }, sid);
return true;
}
deleteEntry(entryId: number, userId: number, sid?: string): boolean {
const entry = this.db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return false;
+4 -2
View File
@@ -26,21 +26,23 @@ import type { JourneyEntry } from '../../types';
* the reason the JSON does, so that a client comparing it with `=== true` is
* comparing against something that can be true on every path.
*/
export interface JourneyEntryWire extends Omit<JourneyEntry, 'tags' | 'pros_cons' | 'stats_excluded' | 'dismissed'> {
export interface JourneyEntryWire extends Omit<JourneyEntry, 'tags' | 'pros_cons' | 'stats_excluded' | 'dismissed' | 'is_draft'> {
tags: string[];
pros_cons: { pros: string[]; cons: string[] } | null;
stats_excluded: boolean;
dismissed: boolean;
is_draft: boolean;
}
/** Decode a row's JSON columns and its flags. The one place that knows how they are stored. */
export function decodeEntryRow(row: JourneyEntry): JourneyEntryWire {
const { tags, pros_cons, stats_excluded, dismissed, ...rest } = row;
const { tags, pros_cons, stats_excluded, dismissed, is_draft, ...rest } = row;
return {
...rest,
tags: tags ? JSON.parse(tags) : [],
pros_cons: pros_cons ? JSON.parse(pros_cons) : null,
stats_excluded: !!stats_excluded,
dismissed: !!dismissed,
is_draft: !!is_draft,
};
}
@@ -6,6 +6,16 @@ import { decodeEntryRow } from './journey-entry-row';
import { GALLERY_CHRONOLOGICAL_ORDER } from './journey-gallery-order';
import { SettingsService } from '../settings/settings.service';
/**
* A photo whose every entry is a draft (#696) stays off the public page, and so do
* its bytes. One that also sits on a published entry, or on no entry at all (a
* plain gallery photo), is shown. `gp` is the journey_photos row.
*/
const NOT_DRAFT_ONLY_PHOTO = `NOT (
EXISTS (SELECT 1 FROM journey_entry_photos d JOIN journey_entries de ON de.id = d.entry_id WHERE d.journey_photo_id = gp.id AND de.is_draft = 1)
AND NOT EXISTS (SELECT 1 FROM journey_entry_photos p JOIN journey_entries pe ON pe.id = p.entry_id WHERE p.journey_photo_id = gp.id AND pe.is_draft = 0)
)`;
interface JourneySharePermissions {
share_timeline?: boolean;
share_gallery?: boolean;
@@ -126,7 +136,7 @@ export class JourneyShareService {
SELECT gp.photo_id, tkp.owner_id, gp.journey_id
FROM journey_photos gp
JOIN trek_photos tkp ON tkp.id = gp.photo_id
WHERE gp.photo_id = ? AND gp.journey_id = ?
WHERE gp.photo_id = ? AND gp.journey_id = ? AND ${NOT_DRAFT_ONLY_PHOTO}
`).get(photoId, row.journey_id) as any;
if (!photo) return null;
const journey = this.db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
@@ -144,7 +154,7 @@ export class JourneyShareService {
FROM journey_photos gp
JOIN trek_photos tkp ON tkp.id = gp.photo_id
JOIN journeys j ON j.id = gp.journey_id
WHERE tkp.asset_id = ? AND gp.journey_id = ?
WHERE tkp.asset_id = ? AND gp.journey_id = ? AND ${NOT_DRAFT_ONLY_PHOTO}
`).get(assetId, row.journey_id) as any;
// Only resolve assets that actually belong to this shared journey.
if (!photo) return null;
@@ -164,7 +174,7 @@ export class JourneyShareService {
// Entries with photos
const entries = this.db.prepare(`
SELECT je.* FROM journey_entries je
WHERE je.journey_id = ? AND je.type != 'skeleton' AND je.dismissed = 0
WHERE je.journey_id = ? AND je.type != 'skeleton' AND je.dismissed = 0 AND je.is_draft = 0
ORDER BY je.entry_date, je.sort_order
`).all(row.journey_id) as any[];
@@ -190,7 +200,7 @@ export class JourneyShareService {
tp.media_type, tp.duration_ms, tp.taken_at, tp.lat, tp.lng
FROM journey_photos gp
JOIN trek_photos tp ON tp.id = gp.photo_id
WHERE gp.journey_id = ?
WHERE gp.journey_id = ? AND ${NOT_DRAFT_ONLY_PHOTO}
${GALLERY_CHRONOLOGICAL_ORDER}
`).all(row.journey_id) as any[];
+10 -1
View File
@@ -36,7 +36,8 @@ import {
JourneyAddTripDto, JourneyContributorAddDto, JourneyContributorUpdateDto, JourneyCreateDto,
JourneyEntryCreateDto, JourneyEntryPhotoUploadDto, JourneyEntryUpdateDto, JourneyGalleryVideoDto,
JourneyLinkPhotoDto, JourneyPhotoUpdateDto, JourneyPreferencesDto, JourneyProviderPhotosDto,
JourneyReorderEntriesDto, JourneyShareLinkDto, JourneyUpdateDto,
JourneyReorderEntriesDto,
JourneyReorderEntryPhotosDto, JourneyShareLinkDto, JourneyUpdateDto,
BookSaveDto,
} from './journey.dto';
import { isVideoMime, isVideoExtension, MAX_VIDEO_SIZE } from '../files/files.constants';
@@ -206,6 +207,14 @@ export class JourneyController {
return result;
}
@Put('entries/:entryId/photos/reorder')
reorderEntryPhotos(@CurrentUser() user: User, @Param('entryId') entryId: string, @Body() body: JourneyReorderEntryPhotosDto, @Headers('x-socket-id') socketId?: string) {
if (!this.journey.reorderEntryPhotos(Number(entryId), user.id, body.orderedIds, socketId)) {
throw new HttpException({ error: 'Entry not found' }, 404);
}
return { success: true };
}
@Delete('entries/:entryId')
deleteEntry(@CurrentUser() user: User, @Param('entryId') entryId: string, @Headers('x-socket-id') socketId?: string) {
if (!this.journey.deleteEntry(Number(entryId), user.id, socketId)) {
+2
View File
@@ -13,6 +13,7 @@ import {
journeyPreferencesRequestSchema,
journeyProviderPhotosRequestSchema,
journeyReorderEntriesRequestSchema,
journeyReorderEntryPhotosRequestSchema,
journeyShareLinkRequestSchema,
journeyUpdateRequestSchema,
bookSaveRequestSchema,
@@ -41,6 +42,7 @@ export class JourneyAddTripDto extends createZodDto(journeyAddTripRequestSchema)
export class JourneyEntryCreateDto extends createZodDto(journeyEntryCreateRequestSchema) {}
export class JourneyEntryUpdateDto extends createZodDto(journeyEntryUpdateRequestSchema) {}
export class JourneyReorderEntriesDto extends createZodDto(journeyReorderEntriesRequestSchema) {}
export class JourneyReorderEntryPhotosDto extends createZodDto(journeyReorderEntryPhotosRequestSchema) {}
export class JourneyContributorAddDto extends createZodDto(journeyContributorRequestSchema) {}
export class JourneyContributorUpdateDto extends createZodDto(journeyContributorUpdateRequestSchema) {}
export class JourneyPreferencesDto extends createZodDto(journeyPreferencesRequestSchema) {}
+24 -3
View File
@@ -347,6 +347,7 @@ export class JourneyMcp {
visibility: ENTRY_VISIBILITY.optional().describe('Defaults to private; "shared" and "public" expose the entry through the journey share link'),
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'),
sort_order: z.number().int().min(0).optional(),
is_draft: z.boolean().optional().describe('True keeps the entry a draft: contributors see it, the journey share link does not'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: journeyAddonOn,
@@ -357,7 +358,7 @@ export class JourneyMcp {
journeyId: number; entry_date: string; title?: string; story?: string; entry_time?: string;
location_name?: string; location_lat?: number; location_lng?: number; mood?: string; weather?: string;
tags?: string[]; pros_cons?: { pros: string[]; cons: string[] }; visibility?: EntryVisibility;
type?: EntryType; sort_order?: number;
type?: EntryType; sort_order?: number; is_draft?: boolean;
},
ctx: McpContext,
) {
@@ -371,7 +372,7 @@ export class JourneyMcp {
@Tool({
name: 'update_journey_entry',
description: 'Update an existing journey entry: its text, date, place, coordinates, weather, tags, verdict, visibility, or whether it counts as a stop. Fields left out keep their value; pass null to clear one. To move an entry within its day use reorder_journey_entries rather than setting sort_order here.',
description: 'Update an existing journey entry: its text, date, place, coordinates, weather, tags, verdict, visibility, whether it is a draft, or whether it counts as a stop. Fields left out keep their value; pass null to clear one. To move an entry within its day use reorder_journey_entries rather than setting sort_order here.',
inputSchema: {
entryId: z.number().int().positive(),
title: z.string().max(300).nullable().optional(),
@@ -390,6 +391,7 @@ export class JourneyMcp {
sort_order: z.number().int().min(0).optional(),
stats_excluded: z.boolean().optional().describe('True leaves the entry in the journal but takes it off the journey route and out of its distance, countries and step count (see get_journey_stats); false puts it back. For the home airport, a stopover, the place the trip was planned from'),
dismissed: z.boolean().optional().describe('True waves a trip-derived suggestion away: it leaves the journey without being deleted, so the trip sync does not offer it again. restore_journey_suggestions brings every dismissed one back'),
is_draft: z.boolean().optional().describe('True turns the entry into a draft that the journey share link leaves out; false publishes it'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
when: journeyAddonOn,
@@ -402,7 +404,7 @@ export class JourneyMcp {
location_lng?: number | null; mood?: string | null; weather?: string | null;
tags?: string[] | null; pros_cons?: { pros: string[]; cons: string[] } | null;
visibility?: EntryVisibility; type?: EntryType; sort_order?: number; stats_excluded?: boolean;
dismissed?: boolean;
dismissed?: boolean; is_draft?: boolean;
},
ctx: McpContext,
) {
@@ -446,6 +448,25 @@ export class JourneyMcp {
return ok({ success: true });
}
@Tool({
name: 'reorder_journey_entry_photos',
description: 'Put the photos of one journey entry in a new order. Pass every photo id the entry holds (the journey photo ids list_journey_entries returns), each once, first photo first. The first one is the entry cover.',
inputSchema: {
entryId: z.number().int().positive(),
orderedIds: z.array(z.number().int().positive()).min(1).max(500),
},
annotations: TOOL_ANNOTATIONS_WRITE,
when: journeyAddonOn,
access: { group: 'journey', mode: 'write' },
})
reorderJourneyEntryPhotos({ entryId, orderedIds }: { entryId: number; orderedIds: number[] }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.journey.reorderEntryPhotos(entryId, ctx.userId, orderedIds, undefined)) {
return notFound('Entry not found, access denied, or the ids are not exactly the photos of this entry.');
}
return ok({ success: true });
}
@Tool({
name: 'add_journey_contributor',
description: 'Add a contributor to a journey. Owner only.',
@@ -60,6 +60,7 @@ export class JourneyService {
updateEntry(entryId: number, userId: number, data: Parameters<typeof this.journey.updateEntry>[2], sid?: string) { return this.journey.updateEntry(entryId, userId, data, sid); }
deleteEntry(entryId: number, userId: number, sid?: string) { return this.journey.deleteEntry(entryId, userId, sid); }
reorderEntries(id: number, userId: number, orderedIds: number[], sid?: string) { return this.journey.reorderEntries(id, userId, orderedIds, sid); }
reorderEntryPhotos(entryId: number, userId: number, orderedIds: number[], sid?: string) { return this.journey.reorderEntryPhotos(entryId, userId, orderedIds, sid); }
// Photos
addPhoto(entryId: number, userId: number, filePath: string, thumbnailPath: string | undefined, caption: string | undefined, media?: { mediaType?: string; durationMs?: number | null }) { return this.journey.addPhoto(entryId, userId, filePath, thumbnailPath, caption, media); }
+100
View File
@@ -0,0 +1,100 @@
import { normalizePlaceWebsite } from '@trek/shared';
import { haversineMetres, OVERPASS_QUERY_TIMEOUT_S } from './maps.helpers';
/** What "near" means when the caller does not say: a short walk. */
export const NEARBY_DEFAULT_RADIUS_M = 500;
/** Google's own ceiling for one nearby answer, and the most a picker lists. */
export const NEARBY_DEFAULT_LIMIT = 20;
/** The OSM keys a place worth visiting is tagged with. Street furniture is not among them. */
const NEARBY_OSM_KEYS = 'amenity|tourism|shop|leisure|historic';
export interface NearbyOsmElement {
type: string;
id: number;
lat?: number;
lon?: number;
center?: { lat: number; lon: number };
tags?: Record<string, string>;
}
/**
* Named places of any kind around a point (#976). Without a category the answer
* can be large in a city centre, so the circle is the caller's and the row count
* is capped at a few times the limit: enough to sort by distance, not the square
* kilometre.
*/
export function nearbyOverpassQuery(lat: number, lng: number, radius: number, limit: number): string {
const around = `(around:${Math.round(radius)},${lat},${lng})`;
return `[out:json][timeout:${OVERPASS_QUERY_TIMEOUT_S}];\nnwr${around}[name][~"^(${NEARBY_OSM_KEYS})$"~"."];\nout center tags ${limit * 3};`;
}
/** The OSM kind of a place, as `category` in the other search answers: a shop reads `shop_<type>`. */
function osmKind(tags: Record<string, string>): string | null {
for (const key of NEARBY_OSM_KEYS.split('|')) {
const value = tags[key];
if (!value || value === 'yes') continue;
return key === 'shop' ? `shop_${value}` : value;
}
return null;
}
/** Overpass elements as the place records a search answers with, nearest first. */
export function overpassNearbyRecords(
elements: NearbyOsmElement[],
origin: { lat: number; lng: number },
lang: string,
limit: number,
): Record<string, unknown>[] {
const out: Record<string, unknown>[] = [];
for (const el of elements) {
const tags = el.tags || {};
const name = tags[`name:${lang}`] || tags.int_name || tags.name;
const lat = el.lat ?? el.center?.lat;
const lng = el.lon ?? el.center?.lon;
if (!name || lat == null || lng == null) continue;
// Shut for good is not somewhere to go, the same rule the POI search keeps.
if (tags.disused === 'yes' || tags.abandoned === 'yes') continue;
const address = [tags['addr:street'], tags['addr:housenumber'], tags['addr:postcode'], tags['addr:city']]
.filter(Boolean)
.join(' ');
out.push({
google_place_id: null,
osm_id: `${el.type}:${el.id}`,
name,
address,
lat,
lng,
rating: null,
website: normalizePlaceWebsite(tags.website) ?? normalizePlaceWebsite(tags['contact:website']),
phone: tags.phone || tags['contact:phone'] || null,
category: osmKind(tags),
source: 'openstreetmap',
});
}
return nearestFirst(out, origin, limit);
}
/**
* Stamps each record with its distance from the point and keeps the nearest.
* Every source goes through here, so the answer reads the same whoever gave it.
*/
export function nearestFirst(
records: Record<string, unknown>[],
origin: { lat: number; lng: number },
limit: number,
): Record<string, unknown>[] {
return records
.filter(r => typeof r.lat === 'number' && typeof r.lng === 'number')
.map(r => ({ ...r, distance_m: Math.round(haversineMetres(origin.lat, origin.lng, r.lat as number, r.lng as number)) }))
.sort((a, b) => a.distance_m - b.distance_m)
.slice(0, limit);
}
/**
* The cache key for one nearby question. Rounded to about ten metres, so the same
* photo location asked twice (an import of a whole album) costs one lookup.
*/
export function nearbyCacheKey(lat: number, lng: number, radius: number, limit: number, lang: string): string {
return `${lat.toFixed(4)},${lng.toFixed(4)}|${radius}|${limit}|${lang}`;
}
+16 -1
View File
@@ -27,7 +27,7 @@ import { StorageService } from '../storage/storage.service';
import { isClientAbortError } from '../storage/storage.types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { MapsSearchDto, MapsAutocompleteDto, MapsResolveUrlDto } from './maps.dto';
import { MapsSearchDto, MapsNearbyDto, MapsAutocompleteDto, MapsResolveUrlDto } from './maps.dto';
/** Google's session-token shape: URL-safe ASCII, at most 36 characters. The
* autocomplete body is validated by the Zod pipe; the details query is not,
@@ -83,6 +83,21 @@ export class MapsController {
}
}
// Places of any kind around a point, nearest first (#976).
@Post('nearby')
@HttpCode(200)
async nearby(
@CurrentUser() user: User,
@Body() body: MapsNearbyDto,
@Query('lang') lang?: string,
): Promise<MapsSearchResult> {
try {
return await this.maps.nearbyPlaces(user.id, body.lat, body.lng, { radius: body.radius, limit: body.limit, lang });
} catch (err: unknown) {
throw toHttpException(err, 'Nearby search error', 500);
}
}
// OSM-only POI explore: places of a category within the current map viewport.
@Get('pois')
async pois(
+2
View File
@@ -1,6 +1,7 @@
import { createZodDto } from 'nestjs-zod';
import {
mapsSearchRequestSchema,
mapsNearbyRequestSchema,
mapsAutocompleteRequestSchema,
mapsResolveUrlRequestSchema,
} from '@trek/shared';
@@ -12,5 +13,6 @@ import {
* shared/ remain the single source of truth for the wire contract.
*/
export class MapsSearchDto extends createZodDto(mapsSearchRequestSchema) {}
export class MapsNearbyDto extends createZodDto(mapsNearbyRequestSchema) {}
export class MapsAutocompleteDto extends createZodDto(mapsAutocompleteRequestSchema) {}
export class MapsResolveUrlDto extends createZodDto(mapsResolveUrlRequestSchema) {}
+25
View File
@@ -82,6 +82,31 @@ export class MapsMcp {
return ok(result);
}
/** The MCP side of POST /api/maps/nearby (#976), with the same caps. */
@Tool({
name: 'search_nearby_places',
description: 'List named places of any kind around a coordinate, nearest first, each with its distance in metres (`distance_m`). No category and no name needed: use it for "what is at this spot", e.g. where a photo was taken. Answers from the TREK place index first, from Google Places only when the instance has a key and the index found nothing, and from OpenStreetMap otherwise; `source` says which. Prefer search_pois for one kind of place across a whole area, and search_place when the user named the place.',
inputSchema: {
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
radius: z.number().int().min(50).max(5000).optional().describe('Search radius in metres, 500 when left out'),
limit: z.number().int().min(1).max(20).optional().describe('How many places, 20 when left out'),
lang: z.string().max(35).optional().describe('Language for the place names, e.g. "de" or "ja"'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'geo', mode: 'read' },
})
async searchNearbyPlaces(
{ lat, lng, radius, limit, lang }: { lat: number; lng: number; radius?: number; limit?: number; lang?: string },
ctx: McpContext,
) {
try {
return ok(await this.maps.nearbyPlaces(ctx.userId, lat, lng, { radius, limit, lang }));
} catch {
return errorResult('Nearby search failed.');
}
}
/**
* The MCP side of GET /api/maps/pois, the "explore" pill on the trip map. Not
* part of the 1:1 move above: the route had no tool at all, which left the
+160 -18
View File
@@ -64,6 +64,14 @@ import {
type GoogleOpeningHours,
type OverpassPoi,
} from './maps.helpers';
import {
NEARBY_DEFAULT_LIMIT,
NEARBY_DEFAULT_RADIUS_M,
nearbyCacheKey,
nearbyOverpassQuery,
nearestFirst,
overpassNearbyRecords,
} from './maps-nearby.helpers';
// ── Google API call counter ───────────────────────────────────────────────────
@@ -125,9 +133,17 @@ function googleFetch(rawEndpoint: string, label: string, init?: RequestInit): Pr
// ── Interfaces ───────────────────────────────────────────────────────────────
function nominatimCategory(item: { class?: string; type?: string }): string | null {
if (!item.class || !item.type || item.type === 'yes') return null;
return item.class === 'shop' ? `shop_${item.type}` : item.type;
}
interface NominatimResult {
osm_type: string;
osm_id: string;
/** The main OSM tag, key and value: `class` "tourism" with `type` "hotel". */
class?: string;
type?: string;
name?: string;
display_name?: string;
lat: string;
@@ -345,6 +361,27 @@ interface GooglePlaceResult {
googleMapsUri?: string;
}
/** A Google place as the record every search answers with. */
function googlePlaceRecord(p: GooglePlaceResult): Record<string, unknown> {
return {
google_place_id: p.id,
google_ftid: googleFtidFromMapsUrl(p.googleMapsUri),
name: p.displayName?.text || '',
address: p.formattedAddress || '',
// `?? null`, not `|| null`: 0 is a real coordinate (equator / prime meridian).
lat: p.location?.latitude ?? null,
lng: p.location?.longitude ?? null,
rating: p.rating || null,
website: normalizePlaceWebsite(p.websiteUri),
phone: p.nationalPhoneNumber || null,
types: p.types || [],
source: 'google',
};
}
/** A place that has shut down for good is never the answer to "where should we go" (#1341). */
const isOpenGooglePlace = (p: GooglePlaceResult) => p.businessStatus !== 'CLOSED_PERMANENTLY';
interface GoogleAutocompleteSuggestion {
placePrediction?: {
placeId: string;
@@ -587,6 +624,12 @@ const POI_CACHE_MAX = 500;
* nobody reads and every mirror pays for.
*/
const POI_RESULT_CAP = 240;
// Places near a point (#976): cached longer than the POI boxes, because the
// likely caller is an import asking the same photo location again, and Google
// bills every one of those.
const NEARBY_CACHE = new Map<string, { at: number; value: { places: Record<string, unknown>[]; source: string } }>();
const NEARBY_CACHE_TTL_MS = 30 * 60 * 1000;
const NEARBY_CACHE_MAX = 500;
// POST the query to all mirrors at once and return the first one that answers with
// valid JSON. Throws {status:502} only if every mirror fails. Racing (rather than
@@ -1116,6 +1159,18 @@ export class MapsService {
return row?.value === 'true';
}
/**
* Whether Amap answers before the index and OpenStreetMap (#1636): only when
* the admin picked Amap outright, not when it holds the slot by default, and
* only for a search centred inside China. Outside it Amap still answers, with
* the wrong place (the Eiffel Tower lands in Macau), so the gate is the point,
* not the provider.
*/
private amapAnswersFirst(point?: { lat: number; lng: number }): boolean {
if (!point || !Number.isFinite(point.lat) || !Number.isFinite(point.lng)) return false;
return this.placesProviderChoice() === 'amap' && !isOutsideChina(point.lat, point.lng);
}
/** The Amap provider, when Amap holds the keyed slot; null otherwise. */
resolvePlacesProvider(userId: number): AmapPlacesProvider | null {
const keyed = this.keyedProvider(userId);
@@ -1235,6 +1290,9 @@ export class MapsService {
rating: null,
website: null,
phone: null,
// What the place is, so the add form can preselect a category (#2282). A shop's
// value is the ware ("bakery"), so the key goes first and says it is a shop.
category: nominatimCategory(item),
source: 'openstreetmap',
...readWikiIdentity(item.extratags),
};
@@ -2088,6 +2146,19 @@ export class MapsService {
// the same question twice. `null` means it never ran.
let osmAnswer: Record<string, unknown>[] | null = null;
// Amap first where the admin picked it and the search is about China (#1636):
// there the index and OpenStreetMap are thin and Amap is the map people use.
// Its answer is kept, so the Amap slot further down never pays for the same
// question twice. A failure drops through to the usual order.
let amapAnswer: Record<string, unknown>[] | null = null;
if (keyed?.id === 'amap' && this.amapAnswersFirst(locationBias)) {
amapAnswer = await keyed.provider.searchText(query, lang, locationBias).catch((err: unknown) => {
console.warn('Amap search failed, falling back:', (err as Error).message);
return null;
});
if (amapAnswer && amapAnswer.length > 0) return { places: amapAnswer, source: 'amap' };
}
// A search sent to Google on purpose, or the admin's "Google only" switch,
// skips the pair the same way: the search then reads exactly as it did
// before 4.3.0 on an install with a key.
@@ -2139,7 +2210,7 @@ export class MapsService {
// Amap in the slot Google otherwise holds: asked only once the index and
// OpenStreetMap came back empty, exactly like the Google call below.
if (keyed?.id === 'amap') {
const places = await keyed.provider.searchText(query, lang, locationBias);
const places = amapAnswer ?? (await keyed.provider.searchText(query, lang, locationBias));
return { places, source: 'amap' };
}
@@ -2193,26 +2264,84 @@ export class MapsService {
// go" (#1341). Temporarily closed stays: a restaurant on holiday next month is
// still worth planning around. Anything without the field is a non-business
// result (a park, a viewpoint) and is kept.
const places = (data.places || [])
.filter((p: GooglePlaceResult) => p.businessStatus !== 'CLOSED_PERMANENTLY')
.map((p: GooglePlaceResult) => ({
google_place_id: p.id,
google_ftid: googleFtidFromMapsUrl(p.googleMapsUri),
name: p.displayName?.text || '',
address: p.formattedAddress || '',
// `?? null`, not `|| null`: 0 is a real coordinate (equator / prime meridian).
lat: p.location?.latitude ?? null,
lng: p.location?.longitude ?? null,
rating: p.rating || null,
website: normalizePlaceWebsite(p.websiteUri),
phone: p.nationalPhoneNumber || null,
types: p.types || [],
source: 'google',
}));
const places = (data.places || []).filter(isOpenGooglePlace).map(googlePlaceRecord);
return { places, source: 'google' };
}
// ── Places near a point (#976) ─────────────────────────────────────────────
/**
* Places of any kind around a coordinate, nearest first, in the shape a search
* answers with plus `distance_m`. For "what is here" where no name was typed:
* a photo's location, a pin on the map.
*
* Same order as the search: the TREK index first, free and storable; Google
* only with a key and only when the index has nothing (or the admin set Google
* only); OpenStreetMap otherwise. Google bills per call, so every answer is
* cached for a while and the circle and count are capped by the contract.
*/
async nearbyPlaces(
userId: number,
lat: number,
lng: number,
opts: { radius?: number; limit?: number; lang?: string } = {},
): Promise<{ places: Record<string, unknown>[]; source: string }> {
const radius = opts.radius ?? NEARBY_DEFAULT_RADIUS_M;
const limit = opts.limit ?? NEARBY_DEFAULT_LIMIT;
const lang = toApiLang(opts.lang);
const key = nearbyCacheKey(lat, lng, radius, limit, lang);
const cached = NEARBY_CACHE.get(key);
if (cached && Date.now() - cached.at < NEARBY_CACHE_TTL_MS) return cached.value;
const value = await this.lookUpNearby(userId, { lat, lng }, radius, limit, lang);
if (NEARBY_CACHE.size >= NEARBY_CACHE_MAX) NEARBY_CACHE.delete(NEARBY_CACHE.keys().next().value as string);
NEARBY_CACHE.set(key, { at: Date.now(), value });
return value;
}
private async lookUpNearby(
userId: number,
origin: { lat: number; lng: number },
radius: number,
limit: number,
lang: string,
): Promise<{ places: Record<string, unknown>[]; source: string }> {
const keyed = this.keyedProvider(userId);
if (this.trekPlacesEnabled() && !this.googleOnly(keyed)) {
// Never throws upward, like the search: a slow index drops to the next source.
const found = await trekPlacesNearby(origin.lat, origin.lng, { radius, limit }).catch((err: unknown) => {
console.warn('TREK Places nearby failed, falling back:', (err as Error).message);
return [];
});
if (found.length > 0) return { places: nearestFirst(found.map(toPlaceRecord), origin, limit), source: 'trek-places' };
}
if (keyed?.id === 'google') {
const response = await googleFetch('https://places.googleapis.com/v1/places:searchNearby', 'searchNearby', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Goog-Api-Key': keyed.key, 'X-Goog-FieldMask': SEARCH_TEXT_FIELD_MASK },
body: JSON.stringify({
maxResultCount: limit,
rankPreference: 'DISTANCE',
languageCode: lang,
locationRestriction: { circle: { center: { latitude: origin.lat, longitude: origin.lng }, radius } },
}),
});
const data = (await response.json()) as { places?: GooglePlaceResult[]; error?: { message?: string } };
if (!response.ok) {
logKeyFailure('searchNearby', response.status, userId, keyed.source);
throw Object.assign(new Error(data.error?.message || 'Google Places API error'), { status: response.status });
}
const places = (data.places || []).filter(isOpenGooglePlace).map(googlePlaceRecord);
return { places: nearestFirst(places, origin, limit), source: 'google' };
}
const osmLang = lang.split('-')[0].toLowerCase();
const elements = await overpassFetch(nearbyOverpassQuery(origin.lat, origin.lng, radius, limit));
return { places: overpassNearbyRecords(elements, origin, osmLang, limit), source: 'openstreetmap' };
}
// ── Autocomplete (Google or Nominatim fallback) ────────────────────────────
async autocompletePlaces(
@@ -2233,6 +2362,19 @@ export class MapsService {
// Same contract as search: never throws upward, falls through to what this
// method did before. The admin's "Google only" switch skips the index here
// too, so the suggestions and the search agree on where they come from.
// Amap first inside China when the admin picked it, as in the search (#1636).
let amapTips: MapsAutocompleteResult['suggestions'] | null = null;
const boxCentre = locationBias
? { lat: (locationBias.low.lat + locationBias.high.lat) / 2, lng: (locationBias.low.lng + locationBias.high.lng) / 2 }
: undefined;
if (keyed?.id === 'amap' && this.amapAnswersFirst(boxCentre)) {
amapTips = await keyed.provider.autocomplete(input, lang, locationBias).catch((err: unknown) => {
console.warn('Amap autocomplete failed, falling back:', (err as Error).message);
return null;
});
if (amapTips && amapTips.length > 0) return { suggestions: amapTips, source: 'amap' };
}
if (this.trekPlacesEnabled() && !this.googleOnly(keyed)) {
try {
const centre = locationBias
@@ -2303,7 +2445,7 @@ export class MapsService {
}
if (keyed?.id === 'amap') {
const suggestions = await keyed.provider.autocomplete(input, lang, locationBias);
const suggestions = amapTips ?? (await keyed.provider.autocomplete(input, lang, locationBias));
return { suggestions, source: 'amap' };
}
@@ -134,11 +134,11 @@ export class PackingController {
// Privacy state before the change, so a public↔private toggle (#858) can route
// the broadcast correctly instead of leaking a freshly-privatized item.
const before = this.packing.getItemPrivacy(tripId, id);
const { name, checked, category, weight_grams, bag_id, quantity, is_private } = body;
const { name, checked, category, weight_grams, bag_id, quantity, packed_quantity, is_private } = body;
// bodyKeys carries which keys the request actually provided (the presence-
// sentinel protocol); the parsed body only ever holds known schema keys.
// checked arrives as boolean or legacy 0/1 — normalize to the 0/1 the SQL binds.
const updated = this.packing.updateItem(tripId, id, { name, checked: checked === undefined ? undefined : checked ? 1 : 0, category, weight_grams, bag_id, quantity, is_private }, Object.keys(body), ifMatch, user.id);
const updated = this.packing.updateItem(tripId, id, { name, checked: checked === undefined ? undefined : checked ? 1 : 0, category, weight_grams, bag_id, quantity, packed_quantity, is_private }, Object.keys(body), ifMatch, user.id);
if (!updated) {
throw new HttpException({ error: 'Item not found' }, 404);
}
+4 -3
View File
@@ -155,7 +155,7 @@ export class PackingMcp {
@Tool({
name: 'update_packing_item',
description: 'Change a packing item: rename it, recategorise it, move it into a bag, set how many are needed, record its weight, or flip it between the common list and your own. Ticking it off is toggle_packing_item; choosing who a private item is shared with is set_packing_item_sharing.',
description: 'Change a packing item: rename it, recategorise it, move it into a bag, set how many are needed, count how many are already packed, record its weight, or flip it between the common list and your own. Ticking it off is toggle_packing_item; choosing who a private item is shared with is set_packing_item_sharing.',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
@@ -163,6 +163,7 @@ export class PackingMcp {
category: z.string().max(100).optional(),
bag_id: packingUpdateItemRequestSchema.shape.bag_id.describe('Bag to pack the item into (ids come from list_packing_bags); null takes it out of its bag'),
quantity: packingUpdateItemRequestSchema.shape.quantity.describe('How many to pack, clamped to 1-999'),
packed_quantity: packingUpdateItemRequestSchema.shape.packed_quantity.describe('How many of the quantity are already packed; reaching the quantity ticks the item off, 0 or null clears the count'),
weight_grams: packingUpdateItemRequestSchema.shape.weight_grams.describe('Weight in grams, which feeds the bag fill bar; null clears it'),
is_private: packingUpdateItemRequestSchema.shape.is_private.describe('true takes the item off the common list and onto the caller\'s own'),
},
@@ -171,13 +172,13 @@ export class PackingMcp {
access: { group: 'packing', mode: 'write' },
})
async updatePackingItem(
{ tripId, itemId, name, category, bag_id, quantity, weight_grams, is_private }: { tripId: number; itemId: number; name?: string; category?: string; bag_id?: number | null; quantity?: number; weight_grams?: number | null; is_private?: boolean },
{ tripId, itemId, name, category, bag_id, quantity, packed_quantity, weight_grams, is_private }: { tripId: number; itemId: number; name?: string; category?: string; bag_id?: number | null; quantity?: number; packed_quantity?: number | null; weight_grams?: number | null; is_private?: boolean },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
const fields = { name, category, bag_id, quantity, weight_grams, is_private };
const fields = { name, category, bag_id, quantity, packed_quantity, weight_grams, is_private };
// The service reads presence from bodyKeys, so a field has to be named there
// for an explicit null to clear it rather than read as "leave it alone".
const bodyKeys = Object.keys(fields).filter(k => fields[k as keyof typeof fields] !== undefined);
+17 -9
View File
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
import { resolvePackedState, type TrekWsPayload, type TrekWsTripEventName } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { PermissionsService } from '../permissions/permissions.service';
import { avatarUrl } from '../common/avatarUrl';
@@ -276,7 +276,7 @@ export class PackingService {
updateItem(
tripId: string | number,
id: string | number,
data: { name?: string; checked?: number; category?: string; weight_grams?: number | null; bag_id?: number | null; quantity?: number; is_private?: boolean },
data: { name?: string; checked?: number; category?: string; weight_grams?: number | null; bag_id?: number | null; quantity?: number; packed_quantity?: number | null; is_private?: boolean },
bodyKeys: string[],
ifMatch?: string,
actingUserId?: number,
@@ -302,29 +302,37 @@ export class PackingService {
// the visibility filter still has someone to match (#858).
const claimOwner = bodyKeys.includes('is_private') && !!data.is_private && item.owner_id == null && actingUserId != null;
// The box and the packed count (#2296) are settled together, so a count
// that reaches the quantity ticks the item and a tick clears the count.
const quantity = bodyKeys.includes('quantity') ? Math.max(1, Math.min(999, Number(data.quantity) || 1)) : (item.quantity || 1);
const packed = resolvePackedState(
{ checked: item.checked ? 1 : 0, packed_quantity: item.packed_quantity ?? null },
{ bodyKeys, checked: data.checked, packed_quantity: data.packed_quantity, quantity },
);
this.db.run(`
UPDATE packing_items SET
name = COALESCE(?, name),
checked = CASE WHEN ? IS NOT NULL THEN ? ELSE checked END,
checked = ?,
packed_quantity = ?,
category = COALESCE(?, category),
weight_grams = CASE WHEN ? THEN ? ELSE weight_grams END,
bag_id = CASE WHEN ? THEN ? ELSE bag_id END,
quantity = CASE WHEN ? THEN ? ELSE quantity END,
quantity = ?,
is_private = CASE WHEN ? THEN ? ELSE is_private END,
owner_id = CASE WHEN ? THEN ? ELSE owner_id END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`,
data.name || null,
data.checked !== undefined ? 1 : null,
data.checked ? 1 : 0,
packed.checked,
packed.packed_quantity,
data.category || null,
bodyKeys.includes('weight_grams') ? 1 : 0,
data.weight_grams ?? null,
bodyKeys.includes('bag_id') ? 1 : 0,
data.bag_id ?? null,
bodyKeys.includes('quantity') ? 1 : 0,
Math.max(1, Math.min(999, Number(data.quantity) || 1)),
quantity,
bodyKeys.includes('is_private') ? 1 : 0,
data.is_private ? 1 : 0,
claimOwner ? 1 : 0,
@@ -361,7 +369,7 @@ export class PackingService {
*/
private getItemInTrip(tripId: string | number, id: string | number, actorId: number | undefined) {
if (actorId == null) return undefined;
return this.db.get<{ id: number; owner_id: number | null; is_private: number; name: string; category: string | null; quantity: number; weight_grams: number | null; bag_id: number | null; updated_at?: string | null }>(
return this.db.get<{ id: number; owner_id: number | null; is_private: number; name: string; category: string | null; checked: number; quantity: number; packed_quantity?: number | null; weight_grams: number | null; bag_id: number | null; updated_at?: string | null }>(
`SELECT * FROM packing_items WHERE id = ? AND trip_id = ? AND ${PackingService.VISIBLE_TO_ACTOR}`,
id, tripId, actorId, actorId,
);
@@ -41,6 +41,7 @@ import {
PlaceImportListDto,
PlaceImportMapDto,
PlaceRatingDto,
PlaceImageFromFileDto,
PlaceUpdateDto,
} from './places.dto';
@@ -193,6 +194,7 @@ export class PlacesController {
for (const place of result.places) {
this.places.broadcast(tripId, 'place:created', { place }, socketId);
}
if (parseBool(body.enrich, false)) this.places.enrichImportedFilePlaces(tripId, user.id, result.places);
return { places: result.places, count: result.count, skipped: result.skipped };
}
@@ -251,6 +253,7 @@ export class PlacesController {
for (const place of result.places) {
this.places.broadcast(tripId, 'place:created', { place }, socketId);
}
if (parseBool(body.enrich, false)) this.places.enrichImportedFilePlaces(tripId, user.id, result.places);
return result;
} catch (err: unknown) {
if (err instanceof HttpException) throw err;
@@ -418,6 +421,29 @@ export class PlacesController {
return { place };
}
@Put(':id/image/from-file')
async imageFromFile(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body() body: PlaceImageFromFileDto,
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
if (isDemoWriteBlocked(this.env, user.email)) {
throw new HttpException(DEMO_WRITE_ERROR, 403);
}
const result = await this.places.setImageFromFile(tripId, id, body.file_id);
if (result === 'not_found') throw new HttpException({ error: 'File not found' }, 404);
if (result === 'not_image') throw new HttpException({ error: 'Only jpg, png, gif, webp images allowed' }, 400);
if (result === 'too_large') throw new HttpException({ error: 'Image too large' }, 400);
if (!result || isUpdateConflict(result)) throw new HttpException({ error: 'Place not found' }, 404);
this.places.broadcast(tripId, 'place:updated', { place: result }, socketId);
this.places.onUpdated(result.id);
return { place: result };
}
@Put(':id/rating')
rate(
@CurrentUser() user: User,
+2
View File
@@ -9,6 +9,7 @@ import {
placeExportGpxRequestSchema,
placeRatingRequestSchema,
placeUpdateRequestSchema,
placeImageFromFileRequestSchema,
} from '@trek/shared';
/**
@@ -28,6 +29,7 @@ import {
export class PlaceCreateDto extends createZodDto(placeCreateRequestSchema) {}
export class PlaceUpdateDto extends createZodDto(placeUpdateRequestSchema) {}
export class PlaceRatingDto extends createZodDto(placeRatingRequestSchema) {}
export class PlaceImageFromFileDto extends createZodDto(placeImageFromFileRequestSchema) {}
export class PlaceBulkDeleteDto extends createZodDto(placeBulkDeleteRequestSchema) {}
export class PlaceBulkUpdateDto extends createZodDto(placeBulkUpdateRequestSchema) {}
export class PlaceImportListDto extends createZodDto(placeImportListRequestSchema) {}
+29
View File
@@ -23,6 +23,7 @@ import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
import { DatabaseService } from '../database/database.service';
import { MapsService } from '../maps/maps.service';
import { PlacesService } from './places.service';
import { isUpdateConflict } from '../common/conflictResult';
import { isDirectionsUrl } from './maps-dir.helpers';
function parseId(value: string | string[]): number | null {
@@ -211,6 +212,33 @@ export class PlacesMcp {
return ok({ place });
}
@Tool({
name: 'set_place_image_from_file',
description: 'Use a picture already attached in the trip (a jpg, png, gif or webp from list_files) as the image of the place. The file is copied, so deleting the attachment later keeps the image.',
inputSchema: {
tripId: z.number().int().positive(),
placeId: z.number().int().positive(),
fileId: z.number().int().positive().describe('Id of a trip file that is an image'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
access: { group: 'places', mode: 'write' },
})
async setPlaceImageFromFile(
{ tripId, placeId, fileId }: { tripId: number; placeId: number; fileId: number },
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
const result = await this.places.setImageFromFile(String(tripId), String(placeId), fileId);
if (result === 'not_found') return errorResult('File not found.');
if (result === 'not_image') return errorResult('That file is not a jpg, png, gif or webp image.');
if (result === 'too_large') return errorResult('That image is too large.');
if (!result || isUpdateConflict(result)) return errorResult('Place not found.');
this.guards.safeBroadcast(tripId, 'place:updated', { place: result });
return ok({ place: result });
}
@Tool({
name: 'rate_place',
description: "Set or clear the current user's 1-5 star rating on a trip place (#1435). Every trip member rates independently; the place shows the average. Omit rating (or pass null) to remove the user's vote. Use the ratings to capture the user's preferences and shape the itinerary around highly-rated places.",
@@ -380,6 +408,7 @@ export class PlacesMcp {
const imported = this.places.importGpx(String(input.tripId), Buffer.from(input.gpx, 'utf8'), { importWaypoints: input.importWaypoints, importRoutes: input.importRoutes, importTracks: input.importTracks, defaultName: input.name });
if (!imported) return errorResult('No matching places found in GPX.');
for (const place of imported.places) this.guards.safeBroadcast(input.tripId, 'place:created', { place });
if (input.enrich) this.places.enrichImportedFilePlaces(String(input.tripId), ctx.userId, imported.places);
return ok(imported);
} catch {
return errorResult('Could not import GPX. Check the XML and coordinates.');
+45 -2
View File
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { resolveCountryCodeSync } from '../atlas/atlas-geo';
import { XMLValidator } from 'fast-xml-parser';
import { TRACK_COLORS, placeMatchStrategies, type PlaceMatchCandidate } from '@trek/shared';
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
@@ -25,7 +26,10 @@ import type { GpxExportDay, GpxExportOptions, GpxExportPlace } from './gpx-expor
import { UnsplashService } from '../unsplash/unsplash.service';
import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service';
import { type UpdateConflict, isUpdateConflict } from '../common/conflictResult';
import { reclaimPlaceImage } from './place-image';
import { placeImageUrl, reclaimPlaceImage } from './place-image';
import { MAX_PLACE_IMAGE_SIZE, PLACE_IMAGE_EXTENSIONS } from '../common/place-image-upload';
import { randomUUID } from 'node:crypto';
import path from 'node:path';
import { JourneyDomainService } from '../journey/journey-domain.service';
import { StorageService } from '../storage/storage.service';
import { AccommodationsService } from '../accommodations/accommodations.service';
@@ -179,9 +183,11 @@ export class PlacesService {
filters: { search?: string; category?: string; tag?: string; assignment?: 'all' | 'unassigned' | 'assigned' },
) {
let query = `
SELECT DISTINCT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
SELECT DISTINCT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon,
pr.country_code as country_code, pr.region_name as region_name
FROM places p
LEFT JOIN categories c ON p.category_id = c.id
LEFT JOIN place_regions pr ON pr.place_id = p.id
WHERE p.trip_id = ?
`;
const params: (string | number)[] = [tripId];
@@ -222,6 +228,9 @@ export class PlacesService {
return places.map(p => ({
...p,
// The cached region row when the atlas resolved one, else the country the bundled
// borders place it in, which needs no network (#2537).
country_code: (p as { country_code?: string | null }).country_code ?? resolveCountryCodeSync(p),
category: p.category_id ? {
id: p.category_id,
name: p.category_name,
@@ -234,6 +243,30 @@ export class PlacesService {
}));
}
/**
* Makes a picture already attached in the trip the place's own image (#1242). The file
* is copied, not pointed at: the place keeps its picture when the attachment is deleted,
* and the update path reclaims the copy like any other uploaded image. Returns a reason
* string when the file cannot serve, the updated place otherwise.
*/
async setImageFromFile(tripId: string, placeId: string, fileId: number): Promise<'not_found' | 'not_image' | 'too_large' | Awaited<ReturnType<PlacesService['update']>>> {
const file = this.dbs.get<{ filename: string; original_name: string; mime_type: string | null; file_size: number | null }>(
'SELECT filename, original_name, mime_type, file_size FROM trip_files WHERE id = ? AND trip_id = ? AND deleted_at IS NULL',
fileId, tripId,
);
if (!file) return 'not_found';
const ext = path.extname(file.original_name || file.filename).toLowerCase();
const mime = file.mime_type ?? '';
if (!mime.startsWith('image/') || mime.includes('svg') || !PLACE_IMAGE_EXTENSIONS.includes(ext)) return 'not_image';
if (file.file_size != null && file.file_size > MAX_PLACE_IMAGE_SIZE) return 'too_large';
const name = `${randomUUID()}${ext}`;
const { stream } = await this.storage.getStream('files', path.basename(file.filename));
await this.storage.put('places', name, stream, { contentType: mime });
const updated = await this.update(tripId, placeId, { image_url: placeImageUrl(name) } as never);
if (!updated || isUpdateConflict(updated)) await reclaimPlaceImage(this.storage, placeImageUrl(name));
return updated;
}
// -------------------------------------------------------------------------
// Create place
// -------------------------------------------------------------------------
@@ -1499,6 +1532,16 @@ export class PlacesService {
}
}
/**
* The Google pass for places a file brought in (#2536). Only its points: a track
* or a drawn path is a line, and looking a line up by its name finds a stranger.
* Detached like the list imports, and just as quietly a no-op without a key.
*/
enrichImportedFilePlaces(tripId: string, userId: number, places: ImportedPlace[]): void {
const points = (places as (ImportedPlace & EnrichablePlace)[]).filter(p => !p.route_geometry);
void this.enrichImportedPlaces(tripId, userId, points);
}
/**
* Everything a just-imported list gets in the background, in one place so the
* Google and Naver call sites cannot drift apart.
@@ -19,7 +19,7 @@ import { transportLegInputSchema, reservationUrlSchema, type TransportLegInput }
// What counts as a transport booking, for the update_transport gate. Every value
// ReservationsPanel renders with a transport icon, so a stored `transit` row is
// editable through the transport tools like any other.
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'] as const;
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'cable_car', 'transit', 'transport_other'] as const;
// What a caller may ASK for, which is the transport form's own picker
// (client/src/components/Planner/TransportModal.tsx), in its order. The tools
// below used to accept four of these nine, so a bus or a ferry could be planned
@@ -29,7 +29,7 @@ const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cr
// booking carries a provider itinerary in metadata.transit, and create_transit_journey
// is what writes one. A hand-made `transit` row would be a shape the transit UI
// does not expect.
const CREATABLE_TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'] as const;
const CREATABLE_TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'cable_car', 'transport_other'] as const;
/** Only these two carry per-segment detail: the transport form writes metadata.legs for a flight or a train and for nothing else. */
const LEG_TRANSPORT_TYPES = ['flight', 'train'] as const;
/** Everything the picker offers that is not a transport: create_reservation's half. */
+5
View File
@@ -44,6 +44,11 @@ const DISPLAY_PREFERENCES = {
(value) => SUPPORTED_LANGUAGE_CODES.includes(value),
{ message: `Expected one of: ${SUPPORTED_LANGUAGE_CODES.join(', ')}` },
),
// Place names and addresses in search (#1799); '' follows `language`.
place_language: z.union([
z.literal(''),
z.string().refine((value) => SUPPORTED_LANGUAGE_CODES.includes(value), { message: `Expected "" or one of: ${SUPPORTED_LANGUAGE_CODES.join(', ')}` }),
]),
temperature_unit: z.enum(['celsius', 'fahrenheit']),
distance_unit: z.enum(['metric', 'imperial']),
time_format: z.enum(['12h', '24h']),
@@ -0,0 +1,51 @@
/**
* The two narrowing options of a public share link (#1712), as pure functions over
* the snapshot the service has already read, so they can be tested apart from SQL.
*/
/** Getting there and sleeping there: every transport type, and the hotel. */
const TRAVEL_BOOKING_TYPES = new Set([
'flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'cable_car', 'transit', 'transport_other',
'hotel',
]);
export function isTravelBooking(type: unknown): boolean {
return typeof type === 'string' && TRAVEL_BOOKING_TYPES.has(type);
}
interface ShareSnapshot {
assignments: Record<number, Array<{ accommodation_id?: number | null; place?: { id: number; image_url?: string | null } }>>;
dayNotes: Record<number, unknown[]>;
places: Array<{ id: number; image_url?: string | null }>;
reservations: Array<{ type?: unknown }>;
}
/**
* "Only travel and stays": what family or an emergency contact needs to know,
* without the museum and the restaurant. A day keeps only the stop its night's
* booking wrote, the notes go, the place pool shrinks to where the trip sleeps,
* and the bookings to transport and hotels.
*/
export function travelOnly<T extends ShareSnapshot>(data: T, stayPlaceIds: ReadonlySet<number>): T {
const assignments: T['assignments'] = {};
for (const [dayId, rows] of Object.entries(data.assignments)) {
const kept = rows.filter(a => a.accommodation_id != null);
if (kept.length) assignments[Number(dayId)] = kept;
}
return {
...data,
assignments,
dayNotes: {},
places: data.places.filter(p => stayPlaceIds.has(p.id)),
reservations: data.reservations.filter(r => isTravelBooking(r.type)),
};
}
/** Without the place photos: a link to pass around without the preview images. */
export function withoutImages<T extends ShareSnapshot>(data: T): T {
const assignments: T['assignments'] = {};
for (const [dayId, rows] of Object.entries(data.assignments)) {
assignments[Number(dayId)] = rows.map(a => (a.place ? { ...a, place: { ...a.place, image_url: null } } : a));
}
return { ...data, assignments, places: data.places.map(p => ({ ...p, image_url: null })) };
}
@@ -48,6 +48,8 @@ export class TripShareController {
share_packing: body.share_packing,
share_budget: body.share_budget,
share_collab: body.share_collab,
share_travel_only: body.share_travel_only,
share_hide_images: body.share_hide_images,
});
// 201 only on first creation; an update answers 200, mirroring the legacy route.
res.status(result.created ? 201 : 200);
+5 -2
View File
@@ -56,13 +56,16 @@ export class ShareMcp {
share_packing: z.boolean().optional().default(false).describe('Share packing list'),
share_budget: z.boolean().optional().default(false).describe('Share budget'),
share_collab: z.boolean().optional().default(false).describe('Share collab messages'),
share_travel_only: z.boolean().optional().default(false).describe('Narrow the shared plan to transport and stays: no activities, no day notes, only transport and hotel bookings'),
share_hide_images: z.boolean().optional().default(false).describe('Leave the place photos out of the shared page'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
access: (ctx) => canShareTrips(ctx.scopes),
})
async createShareLink(
{ tripId, share_map, share_bookings, share_packing, share_budget, share_collab }: {
{ tripId, share_map, share_bookings, share_packing, share_budget, share_collab, share_travel_only, share_hide_images }: {
tripId: number; share_map?: boolean; share_bookings?: boolean; share_packing?: boolean; share_budget?: boolean; share_collab?: boolean;
share_travel_only?: boolean; share_hide_images?: boolean;
},
ctx: McpContext,
) {
@@ -72,7 +75,7 @@ export class ShareMcp {
// The zod .default()s above fill omitted flags, and ShareService applies
// the same defaults again for undefined — no re-defaulting needed here.
const { token, created } = this.share.createOrUpdate(String(tripId), ctx.userId, {
share_map, share_bookings, share_packing, share_budget, share_collab,
share_map, share_bookings, share_packing, share_budget, share_collab, share_travel_only, share_hide_images,
});
return ok({ token, created });
}
+32 -8
View File
@@ -8,6 +8,7 @@ import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.servic
import { publicReservationSql, publicStaySql } from '../reservations/reservation-visibility';
import { SettingsService } from '../settings/settings.service';
import type { User } from '../../types';
import { travelOnly, withoutImages } from './share-view.helpers';
type Trip = TripAccess;
@@ -34,6 +35,8 @@ export interface SharePermissions {
share_packing?: boolean;
share_budget?: boolean;
share_collab?: boolean;
share_travel_only?: boolean;
share_hide_images?: boolean;
}
export interface ShareTokenInfo {
@@ -44,6 +47,8 @@ export interface ShareTokenInfo {
share_packing: boolean;
share_budget: boolean;
share_collab: boolean;
share_travel_only: boolean;
share_hide_images: boolean;
}
/**
@@ -181,23 +186,26 @@ export class ShareService {
share_packing = false,
share_budget = false,
share_collab = false,
share_travel_only = false,
share_hide_images = false,
} = permissions;
const flags = [share_map, share_bookings, share_packing, share_budget, share_collab, share_travel_only, share_hide_images].map(f => (f ? 1 : 0));
const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
return this.dbs.transaction(() => {
const existing = this.dbs.get<{ token: string }>('SELECT token FROM share_tokens WHERE trip_id = ?', tripId);
if (existing) {
this.dbs.run(
'UPDATE share_tokens SET share_map = ?, share_bookings = ?, share_packing = ?, share_budget = ?, share_collab = ?, expires_at = ? WHERE trip_id = ?',
share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0, expiresAt, tripId,
'UPDATE share_tokens SET share_map = ?, share_bookings = ?, share_packing = ?, share_budget = ?, share_collab = ?, share_travel_only = ?, share_hide_images = ?, expires_at = ? WHERE trip_id = ?',
...flags, expiresAt, tripId,
);
return { token: existing.token, created: false };
}
const token = crypto.randomBytes(24).toString('base64url');
this.dbs.run(
'INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
tripId, token, userId, share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0, expiresAt,
'INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab, share_travel_only, share_hide_images, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
tripId, token, userId, ...flags, expiresAt,
);
return { token, created: true };
});
@@ -217,6 +225,8 @@ export class ShareService {
share_packing: !!row.share_packing,
share_budget: !!row.share_budget,
share_collab: !!row.share_collab,
share_travel_only: !!row.share_travel_only,
share_hide_images: !!row.share_hide_images,
};
}
@@ -280,6 +290,8 @@ export class ShareService {
share_packing: !!shareRow.share_packing,
share_budget: !!shareRow.share_budget,
share_collab: !!shareRow.share_collab,
share_travel_only: !!shareRow.share_travel_only,
share_hide_images: !!shareRow.share_hide_images,
};
// Itinerary — days with assignments/notes, and the place pool
@@ -449,10 +461,20 @@ export class ShareService {
const ownerCartoKey = ownerSettings['carto_api_key'];
const cartoApiKey = typeof ownerCartoKey === 'string' ? ownerCartoKey.trim() : '';
// The owner's narrowing options (#1712) apply last, over what the flags
// above already let through.
let view = { assignments, dayNotes, places, reservations };
if (permissions.share_travel_only) {
const stayPlaceIds = new Set(this.dbs.all<{ place_id: number }>(
`SELECT DISTINCT a.place_id FROM day_accommodations a WHERE a.trip_id = ? AND ${publicStaySql('a')}`, tripId,
).map(r => r.place_id));
view = travelOnly(view, stayPlaceIds);
}
if (permissions.share_hide_images) view = withoutImages(view);
return {
trip, baseCurrency, cartoApiKey, categories, permissions,
days, assignments, dayNotes, places,
reservations, accommodations,
days, ...view, accommodations,
packing, budget,
collab: collabMessages,
};
@@ -468,11 +490,13 @@ export class ShareService {
* mirroring the authenticated bytes endpoint.
*/
async getSharedPlacePhotoKey(token: string, placeId: string): Promise<string | null> {
const shareRow = this.dbs.get<{ trip_id: string; share_map: number }>(
"SELECT trip_id, share_map FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))",
const shareRow = this.dbs.get<{ trip_id: string; share_map: number; share_hide_images?: number }>(
"SELECT trip_id, share_map, share_hide_images FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))",
token,
);
if (!shareRow) return null;
// A link that leaves the photos out (#1712) does not serve them either.
if (shareRow.share_hide_images) return null;
// Place photos belong to the map/itinerary section — withhold them when the
// owner disabled the map, matching getSharedTripData which no longer returns
// the places (and thus their ids) in that case.
+1 -1
View File
@@ -244,7 +244,7 @@ export class VacayController {
@Headers('x-socket-id') socketId?: string,
) {
const planId = this.vacay.getActivePlanId(user.id);
return this.vacay.toggleCompanyHoliday(planId, body.date, body.note, socketId);
return this.vacay.toggleCompanyHoliday(planId, body.date, body.note, socketId, body.fraction);
}
@Get('stats/:year')
+4 -3
View File
@@ -353,19 +353,20 @@ export class VacayMcp {
@Tool({
name: 'toggle_company_holiday',
description: 'Toggle a date as a company holiday for the whole plan.',
description: 'Toggle a date as a company holiday for the whole plan, whole or half. The same size again removes it; the other size converts it. A half company holiday leaves room for a half vacation day.',
inputSchema: {
date: z.string(),
note: z.string().optional(),
half: z.boolean().optional().describe('True makes it a half company holiday (e.g. a free afternoon)'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: vacayAddonOn,
access: { group: 'vacay', mode: 'write' },
})
async toggleCompanyHoliday({ date, note }: { date: string; note?: string }, ctx: McpContext) {
async toggleCompanyHoliday({ date, note, half }: { date: string; note?: string; half?: boolean }, ctx: McpContext) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
const result = this.vacay.toggleCompanyHoliday(planId, date, note, undefined);
const result = this.vacay.toggleCompanyHoliday(planId, date, note, undefined, half ? 0.5 : 1);
return ok(result);
}
+1 -1
View File
@@ -40,7 +40,7 @@ export class VacayRpc {
const date = this.dateStr(params.date);
const note = typeof params.note === 'string' ? params.note.slice(0, 256) : undefined;
this.requireVacayAddon();
return this.vacay.toggleCompanyHoliday(this.vacay.getActivePlanId(userId), date, note, undefined);
return this.vacay.toggleCompanyHoliday(this.vacay.getActivePlanId(userId), date, note, undefined, params.fraction);
}
private requireVacayUser(ctx: PluginRpcContext, kind: 'reads' | 'writes'): number {
+41 -22
View File
@@ -520,10 +520,8 @@ export class VacayService {
}
if (company_holidays_enabled === true) {
const companyDates = this.db.all<{ date: string }>('SELECT date FROM vacay_company_holidays WHERE plan_id = ?', planId);
for (const { date } of companyDates) {
this.db.run('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?', planId, date);
}
const companyDates = this.db.all<{ date: string; fraction: number | null }>('SELECT date, fraction FROM vacay_company_holidays WHERE plan_id = ?', planId);
for (const { date, fraction } of companyDates) this.makeRoomForCompanyHoliday(planId, date, fraction ?? 1);
}
const updatedPlan = this.db.get<VacayPlan>('SELECT * FROM vacay_plans WHERE id = ?', planId)!;
@@ -760,7 +758,7 @@ export class VacayService {
const isOwnerFlag = plan.owner_id === userId;
const userIds = this.getPlanUsers(plan.id).map(u => u.id);
const companyHolidays = this.db.all<{ date: string; note: string }>('SELECT date, note FROM vacay_company_holidays WHERE plan_id = ?', plan.id);
const companyHolidays = this.db.all<{ date: string; note: string; fraction: number | null }>('SELECT date, note, fraction FROM vacay_company_holidays WHERE plan_id = ?', plan.id);
if (isOwnerFlag) {
const members = this.db.all<{ user_id: number }>("SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted'", plan.id);
@@ -768,7 +766,7 @@ export class VacayService {
const memberPlan = this.getOwnPlan(m.user_id);
this.db.run('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?', memberPlan.id, plan.id, m.user_id);
for (const ch of companyHolidays) {
this.db.run('INSERT OR IGNORE INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)', memberPlan.id, ch.date, ch.note);
this.db.run('INSERT OR IGNORE INTO vacay_company_holidays (plan_id, date, note, fraction) VALUES (?, ?, ?, ?)', memberPlan.id, ch.date, ch.note, ch.fraction ?? 1);
}
}
this.db.run('DELETE FROM vacay_plan_members WHERE plan_id = ?', plan.id);
@@ -776,7 +774,7 @@ export class VacayService {
const ownPlan = this.getOwnPlan(userId);
this.db.run('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?', ownPlan.id, plan.id, userId);
for (const ch of companyHolidays) {
this.db.run('INSERT OR IGNORE INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)', ownPlan.id, ch.date, ch.note);
this.db.run('INSERT OR IGNORE INTO vacay_company_holidays (plan_id, date, note, fraction) VALUES (?, ?, ?, ?)', ownPlan.id, ch.date, ch.note, ch.fraction ?? 1);
}
this.db.run('DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?', plan.id, userId);
}
@@ -983,7 +981,7 @@ export class VacayService {
// only exposed while the owner has the feature enabled, and dates only —
// the note text may be authored by plan members who aren't part of the share.
const companyHolidays = plan.company_holidays_enabled
? this.db.all('SELECT date FROM vacay_company_holidays WHERE plan_id = ? AND date >= ? AND date < ? ORDER BY date', plan.id, start, end)
? this.db.all('SELECT date, fraction FROM vacay_company_holidays WHERE plan_id = ? AND date >= ? AND date < ? ORDER BY date', plan.id, start, end)
: [];
return { share_id: s.id, owner_id: s.owner_id, owner_name: s.username, color, hidden: !!s.hidden, entries, companyHolidays };
});
@@ -1104,9 +1102,13 @@ export class VacayService {
}
toggleEntry(userId: number, planId: number, date: string, fraction?: unknown, kind?: unknown, socketId?: string): { action?: string; fraction?: number; kind?: string; error?: string } {
const frac = normalizeFraction(fraction);
const knd = normalizeKind(kind);
const plan = this.db.get<VacayPlan>('SELECT * FROM vacay_plans WHERE id = ?', planId);
// Half the day is the company's already (#2439): what is left is half a day.
const company = plan?.company_holidays_enabled
? this.db.get<{ fraction: number | null }>('SELECT fraction FROM vacay_company_holidays WHERE plan_id = ? AND date = ?', planId, date)
: undefined;
const frac = company && (company.fraction ?? 1) < 1 ? 0.5 : normalizeFraction(fraction);
const knd = normalizeKind(kind);
const weekendBlocked = plan ? isBlockedWeekend(plan, date) : false;
const existing = this.db.get<{ id: number; fraction: number; kind: string | null }>('SELECT id, fraction, kind FROM vacay_entries WHERE user_id = ? AND date = ? AND plan_id = ?', userId, date, planId);
if (existing) {
@@ -1130,18 +1132,35 @@ export class VacayService {
return { action: 'added', fraction: frac, kind: knd };
}
toggleCompanyHoliday(planId: number, date: string, note: string | undefined, socketId: string | undefined): { action: string } {
const existing = this.db.get<{ id: number }>('SELECT id FROM vacay_company_holidays WHERE plan_id = ? AND date = ?', planId, date);
if (existing) {
this.db.run('DELETE FROM vacay_company_holidays WHERE id = ?', existing.id);
this.notifyPlanUsers(planId, socketId);
return { action: 'removed' };
} else {
this.db.run('INSERT INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)', planId, date, note || '');
this.db.run('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?', planId, date);
this.notifyPlanUsers(planId, socketId);
return { action: 'added' };
}
/**
* A company holiday on or off, whole or half (#2439). The same click again clears
* it; the other size converts it in place, the way a vacation entry toggles. A
* whole company holiday leaves no room for leave that day; a half one leaves half.
*/
toggleCompanyHoliday(planId: number, date: string, note: string | undefined, socketId: string | undefined, fraction?: unknown): { action: string; fraction?: number } {
const frac = normalizeFraction(fraction);
const result = this.db.transaction(() => {
const existing = this.db.get<{ id: number; fraction: number | null }>('SELECT id, fraction FROM vacay_company_holidays WHERE plan_id = ? AND date = ?', planId, date);
if (existing && (existing.fraction ?? 1) === frac) {
this.db.run('DELETE FROM vacay_company_holidays WHERE id = ?', existing.id);
return { action: 'removed' };
}
if (existing) {
this.db.run('UPDATE vacay_company_holidays SET fraction = ? WHERE id = ?', frac, existing.id);
} else {
this.db.run('INSERT INTO vacay_company_holidays (plan_id, date, note, fraction) VALUES (?, ?, ?, ?)', planId, date, note || '', frac);
}
this.makeRoomForCompanyHoliday(planId, date, frac);
return { action: existing ? 'updated' : 'added', fraction: frac };
});
this.notifyPlanUsers(planId, socketId);
return result;
}
/** What a company holiday leaves of the day's leave: nothing for a whole one, half for a half one. */
private makeRoomForCompanyHoliday(planId: number, date: string, fraction: number): void {
if (fraction >= 1) this.db.run('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?', planId, date);
else this.db.run('UPDATE vacay_entries SET fraction = 0.5 WHERE plan_id = ? AND date = ? AND fraction > 0.5', planId, date);
}
// -------------------------------------------------------------------------
+3
View File
@@ -119,6 +119,7 @@ export interface DayAssignment {
assignment_end_time?: string | null;
end_day?: number;
leg_transport_mode?: string | null;
route_excluded?: number;
incoming_leg_transport_mode?: string | null;
/** The lodging booking that put this stop on the day, when one did. */
accommodation_id?: number | null;
@@ -425,6 +426,8 @@ export interface JourneyEntry {
* The wire carries a boolean; journey-entry-row.ts is where the two meet.
*/
stats_excluded: number;
/** 1 while the entry is a draft (#696), kept off the public share page. */
is_draft?: number;
created_at: number;
updated_at: number;
}