mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
feat(api): expose shortlisted places, detached bookings and the bucket list
Reading the surface back against real data turned up three things it dropped without saying so. Places with no day were invisible, because the payload only reached places through day_assignments. On a live instance that is half of them, and they are the ones carrying a coordinate and no claim about when — exactly what an integration can act on. They come back under `unplanned_places`, minus the hotel, which is already reported as an accommodation. Bookings with no day were dropped by the day filter. reservations.day_id is nullable and deleting a day detaches its bookings rather than removing them, so this is not a hypothetical. The bucket list had no endpoint at all. It hangs off the user rather than a trip, so it gets its own, and it answers whether or not the Atlas addon is switched on: the addon decides what TREK shows, not what a key can read. Also fixes ?include=notes returning the bare trip: places, notes and reservations are reported on days, so asking for one now implies days instead of quietly answering with nothing. The module stays a leaf. Reaching for AtlasService pulled AuthModule and the storage registry in behind it and broke the surface's own boot, so the one SELECT lives here like every other query in the file.
This commit is contained in:
@@ -3,6 +3,7 @@ import type { Request } from 'express';
|
||||
import {
|
||||
PUBLIC_API_INCLUDES,
|
||||
publicApiIncludeQuerySchema,
|
||||
type PublicApiBucketList,
|
||||
type PublicApiInclude,
|
||||
type PublicApiTrip,
|
||||
type PublicApiTripList,
|
||||
@@ -63,6 +64,19 @@ export class PublicApiController {
|
||||
return { trips: this.api.listTrips(requireUserId(req)) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller's bucket list: places they want to reach, with no trip attached.
|
||||
*
|
||||
* Its own endpoint rather than an `include`, because it hangs off the user and
|
||||
* not off any trip. For a consumer that knows where someone has actually been,
|
||||
* this is the one list in TREK it can answer questions about.
|
||||
*/
|
||||
@Get('bucket-list')
|
||||
listBucketList(@Req() req: Request): PublicApiBucketList {
|
||||
this.limit(req);
|
||||
return { items: this.api.listBucketList(requireUserId(req)) };
|
||||
}
|
||||
|
||||
/**
|
||||
* One trip, with the sections named in `?include=`.
|
||||
*
|
||||
@@ -122,6 +136,10 @@ function parseTripId(raw: string): number {
|
||||
* An unknown section is a 400 rather than being ignored: silently dropping a typo
|
||||
* would hand the caller a payload that is missing exactly what they asked for, and
|
||||
* they would debug their own code for it.
|
||||
*
|
||||
* `places`, `notes` and `reservations` are reported on days, so asking for one of
|
||||
* them brings `days` along; the service handles that rather than this parser, so
|
||||
* the echo of what the caller asked for stays honest.
|
||||
*/
|
||||
function parseInclude(raw?: string): PublicApiInclude[] {
|
||||
if (raw === undefined || raw.trim() === '') return [...PUBLIC_API_INCLUDES];
|
||||
|
||||
@@ -13,6 +13,11 @@ import { RateLimitModule } from '../common/rate-limit.module';
|
||||
* trip access from TripMembershipModule and DatabaseService. This module owns the
|
||||
* payload shaping and nothing else, so there is no second place where "who may read
|
||||
* this trip" is decided.
|
||||
*
|
||||
* Deliberately a leaf. Pulling in a domain module for one table drags its whole
|
||||
* graph along: AtlasModule needs AuthModule, which boots the storage registry,
|
||||
* which reads app_settings on init. A read-only surface that cannot start without
|
||||
* half the application is one that breaks for reasons it has nothing to do with.
|
||||
*/
|
||||
@Module({
|
||||
imports: [TokensModule, TripMembershipModule, RateLimitModule],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
PublicApiAccommodation,
|
||||
PublicApiBucketListItem,
|
||||
PublicApiDay,
|
||||
PublicApiDayNote,
|
||||
PublicApiInclude,
|
||||
@@ -70,9 +71,18 @@ export class PublicApiService {
|
||||
if (!row) return null;
|
||||
|
||||
const trip: PublicApiTrip = toTripSummary(row);
|
||||
if (include.includes('days')) {
|
||||
// Places, notes and reservations hang off days, so asking for one of them
|
||||
// and not for `days` used to return the trip and nothing else — silently,
|
||||
// which is the worst way to answer. Days are implied instead.
|
||||
if (DAY_SCOPED.some((section) => include.includes(section))) {
|
||||
trip.days = this.buildDays(tripId, include);
|
||||
}
|
||||
if (include.includes('places')) {
|
||||
trip.unplanned_places = this.buildUnplannedPlaces(tripId);
|
||||
}
|
||||
if (include.includes('reservations')) {
|
||||
trip.unscheduled_reservations = this.buildUnscheduledReservations(tripId);
|
||||
}
|
||||
if (include.includes('accommodations')) {
|
||||
trip.accommodations = this.buildAccommodations(tripId);
|
||||
}
|
||||
@@ -135,18 +145,7 @@ export class PublicApiService {
|
||||
ORDER BY da.day_id ASC, da.order_index ASC`,
|
||||
tripId,
|
||||
);
|
||||
return groupBy(rows, (r) => r.day_id, (r) => ({
|
||||
name: r.name,
|
||||
address: r.address ?? null,
|
||||
lat: r.lat ?? null,
|
||||
lng: r.lng ?? null,
|
||||
time: r.place_time ?? null,
|
||||
end_time: r.end_time ?? null,
|
||||
duration_minutes: r.duration_minutes ?? null,
|
||||
category: r.category ?? null,
|
||||
notes: r.notes ?? null,
|
||||
transport_mode: r.transport_mode ?? null,
|
||||
}));
|
||||
return groupBy(rows, (r: PlaceRow) => r.day_id, toPlace);
|
||||
}
|
||||
|
||||
private dayNotesByDay(tripId: number): Map<number, PublicApiDayNote[]> {
|
||||
@@ -178,15 +177,7 @@ export class PublicApiService {
|
||||
ORDER BY day_id ASC, reservation_time ASC`,
|
||||
tripId,
|
||||
);
|
||||
return groupBy(rows, (r) => r.day_id, (r) => ({
|
||||
type: r.type ?? null,
|
||||
title: r.title ?? null,
|
||||
location: r.location ?? null,
|
||||
time: r.reservation_time ?? null,
|
||||
end_time: r.reservation_end_time ?? null,
|
||||
status: r.status ?? null,
|
||||
notes: r.notes ?? null,
|
||||
}));
|
||||
return groupBy(rows, (r: ReservationRow) => r.day_id, toReservation);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,6 +212,84 @@ export class PublicApiService {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Places the traveller collected but has not scheduled: no row in
|
||||
* `day_assignments`, so they belong to the trip rather than to any day.
|
||||
*
|
||||
* These are not leftovers. On a real instance roughly half the places on a
|
||||
* trip sit here, and they are the ones a consumer can most usefully act on:
|
||||
* somewhere the traveller wants to go, with a coordinate, and no claim yet
|
||||
* about when.
|
||||
*
|
||||
* Ordered by creation, which is the only order they have: an unscheduled place
|
||||
* has no position relative to its siblings.
|
||||
*
|
||||
* Hotels are excluded. An accommodation's place has no day assignment either,
|
||||
* but it is not a shortlist entry and it is already reported in full under
|
||||
* `accommodations` — listing it twice would read as two different intentions.
|
||||
*/
|
||||
private buildUnplannedPlaces(tripId: number): PublicApiPlace[] {
|
||||
const rows = this.db.all<Omit<PlaceRow, 'day_id'>>(
|
||||
`SELECT p.name, p.address, p.lat, p.lng, p.place_time, p.end_time,
|
||||
p.duration_minutes, p.notes, p.transport_mode,
|
||||
c.name AS category
|
||||
FROM places p
|
||||
LEFT JOIN categories c ON c.id = p.category_id
|
||||
WHERE p.trip_id = ?
|
||||
AND NOT EXISTS (SELECT 1 FROM day_assignments da WHERE da.place_id = p.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM day_accommodations a WHERE a.place_id = p.id)
|
||||
ORDER BY p.created_at ASC, p.id ASC`,
|
||||
tripId,
|
||||
);
|
||||
return rows.map(toPlace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookings with no day. `reservations.day_id` is nullable and a deleted day
|
||||
* sets it null rather than cascading, so a flight can outlive the day it was
|
||||
* pinned to. Reporting only day-bound bookings would quietly lose those.
|
||||
*/
|
||||
private buildUnscheduledReservations(tripId: number): PublicApiReservation[] {
|
||||
const rows = this.db.all<Omit<ReservationRow, 'day_id'>>(
|
||||
`SELECT type, title, location, reservation_time, reservation_end_time,
|
||||
status, notes
|
||||
FROM reservations
|
||||
WHERE trip_id = ? AND day_id IS NULL
|
||||
ORDER BY reservation_time ASC, id ASC`,
|
||||
tripId,
|
||||
);
|
||||
return rows.map(toReservation);
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller's bucket list: places they want to reach, with no trip attached.
|
||||
*
|
||||
* Read here rather than through AtlasService, for the same reason every other
|
||||
* table in this file is: importing a domain module for one SELECT drags its
|
||||
* whole graph in, and this surface has to be able to boot on its own. The query
|
||||
* is scoped by `user_id` in SQL, which is the part that matters.
|
||||
*
|
||||
* Returns entries even when the Atlas addon is switched off: the addon governs
|
||||
* whether TREK shows the feature, not whether the rows exist, and a key whose
|
||||
* answers change when an unrelated toggle moves is a key nobody can build on.
|
||||
*/
|
||||
listBucketList(userId: number): PublicApiBucketListItem[] {
|
||||
const rows = this.db.all<BucketListRow>(
|
||||
`SELECT name, lat, lng, country_code, notes, target_date
|
||||
FROM bucket_list WHERE user_id = ?
|
||||
ORDER BY created_at DESC, id DESC`,
|
||||
userId,
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
name: r.name,
|
||||
lat: r.lat ?? null,
|
||||
lng: r.lng ?? null,
|
||||
country_code: r.country_code ?? null,
|
||||
notes: r.notes ?? null,
|
||||
target_date: r.target_date ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Who is on the trip: the owner first, then members in join order.
|
||||
*
|
||||
@@ -245,6 +314,44 @@ export class PublicApiService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sections that live on a day. Asking for any of them implies `days`, because
|
||||
* that is where they are reported.
|
||||
*/
|
||||
const DAY_SCOPED: PublicApiInclude[] = ['days', 'places', 'notes', 'reservations'];
|
||||
|
||||
/**
|
||||
* One definition of what a place looks like on the wire, used both for places on
|
||||
* a day and for unscheduled ones. Two copies would drift, and the second copy is
|
||||
* always the one that forgets a field.
|
||||
*/
|
||||
function toPlace(row: Omit<PlaceRow, 'day_id'>): PublicApiPlace {
|
||||
return {
|
||||
name: row.name,
|
||||
address: row.address ?? null,
|
||||
lat: row.lat ?? null,
|
||||
lng: row.lng ?? null,
|
||||
time: row.place_time ?? null,
|
||||
end_time: row.end_time ?? null,
|
||||
duration_minutes: row.duration_minutes ?? null,
|
||||
category: row.category ?? null,
|
||||
notes: row.notes ?? null,
|
||||
transport_mode: row.transport_mode ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function toReservation(row: Omit<ReservationRow, 'day_id'>): PublicApiReservation {
|
||||
return {
|
||||
type: row.type ?? null,
|
||||
title: row.title ?? null,
|
||||
location: row.location ?? null,
|
||||
time: row.reservation_time ?? null,
|
||||
end_time: row.reservation_end_time ?? null,
|
||||
status: row.status ?? null,
|
||||
notes: row.notes ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function toTripSummary(row: TripRow): PublicApiTripSummary {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -339,3 +446,12 @@ interface AccommodationRow {
|
||||
check_out: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
interface BucketListRow {
|
||||
name: string;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
country_code: string | null;
|
||||
notes: string | null;
|
||||
target_date: string | null;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@ const { db } = vi.hoisted(() => {
|
||||
day_number INTEGER NOT NULL, date TEXT NOT NULL, notes TEXT, title TEXT);
|
||||
CREATE TABLE places (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL, address TEXT, lat REAL, lng REAL, category_id INTEGER,
|
||||
place_time TEXT, end_time TEXT, duration_minutes INTEGER, notes TEXT, transport_mode TEXT);
|
||||
place_time TEXT, end_time TEXT, duration_minutes INTEGER, notes TEXT, transport_mode TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP);
|
||||
CREATE TABLE categories (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL);
|
||||
CREATE TABLE day_assignments (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL,
|
||||
place_id INTEGER NOT NULL, order_index INTEGER NOT NULL DEFAULT 0);
|
||||
@@ -47,6 +48,9 @@ const { db } = vi.hoisted(() => {
|
||||
day_id INTEGER, end_day_id INTEGER, place_id INTEGER, assignment_id INTEGER, title TEXT,
|
||||
accommodation_id TEXT, reservation_time TEXT, reservation_end_time TEXT, location TEXT,
|
||||
confirmation_number TEXT, notes TEXT, status TEXT, type TEXT);
|
||||
CREATE TABLE bucket_list (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL, lat REAL, lng REAL, country_code TEXT, notes TEXT,
|
||||
target_date TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);
|
||||
`);
|
||||
return { db: tmp };
|
||||
});
|
||||
@@ -128,6 +132,12 @@ describe('Public API v1 e2e (real guard + real SQL)', () => {
|
||||
db.prepare("INSERT INTO places (id, trip_id, name) VALUES (3, 1, 'Hotel Alba')").run();
|
||||
// Deliberately inserted out of order to prove order_index decides the sequence.
|
||||
db.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (1, 2, 1)').run();
|
||||
// Ein Ort auf der Shortlist: Koordinaten, aber noch kein Tag.
|
||||
db.prepare("INSERT INTO places (id, trip_id, name, lat, lng, notes) VALUES (4, 1, 'Boboli-Garten', 43.762, 11.248, 'vielleicht')").run();
|
||||
// Eine Buchung, die keinen Tag (mehr) hat.
|
||||
db.prepare("INSERT INTO reservations (trip_id, day_id, type, title, location, reservation_time, status) VALUES (1, NULL, 'flight', 'LH 1234', 'FRA', '2026-06-14T08:00', 'confirmed')").run();
|
||||
db.prepare("INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes, target_date) VALUES (1, 'Hokkaido', 43.06, 141.35, 'JP', 'im Winter', '2027-02-01')").run();
|
||||
db.prepare("INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (2, 'Bobs Traumziel', 1.0, 2.0)").run();
|
||||
db.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (1, 1, 0)').run();
|
||||
db.prepare("INSERT INTO day_notes (day_id, trip_id, text, time, sort_order) VALUES (1, 1, 'Tickets mitnehmen', '09:00', 0)").run();
|
||||
db.prepare(
|
||||
@@ -267,6 +277,39 @@ describe('Public API v1 e2e (real guard + real SQL)', () => {
|
||||
expect(JSON.stringify(res.body)).not.toContain('@example.com');
|
||||
});
|
||||
|
||||
it('returns shortlisted places, which are half of a real trip', async () => {
|
||||
const res = await get('/api/v1/trips/1?include=places', ADA_TOKEN);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.unplanned_places).toEqual([
|
||||
expect.objectContaining({ name: 'Boboli-Garten', lat: 43.762, lng: 11.248, notes: 'vielleicht' }),
|
||||
]);
|
||||
// Hotel Alba has no day either, but it is the accommodation and is
|
||||
// reported there — a shortlist that includes your hotel is not a shortlist.
|
||||
expect(res.body.unplanned_places.map((p: { name: string }) => p.name)).not.toContain('Hotel Alba');
|
||||
// And the scheduled ones still sit on their day, not in both places.
|
||||
expect(res.body.days[0].places.map((p: { name: string }) => p.name)).toEqual(['Uffizien', 'Ponte Vecchio']);
|
||||
});
|
||||
|
||||
it('returns bookings that lost their day instead of dropping them', async () => {
|
||||
const res = await get('/api/v1/trips/1?include=reservations', ADA_TOKEN);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.unscheduled_reservations).toEqual([
|
||||
expect.objectContaining({ type: 'flight', title: 'LH 1234', location: 'FRA' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('implies days when asked for something that lives on one', async () => {
|
||||
// The section Evgenii named first. Before days were implied this answered
|
||||
// the trip summary and nothing else, without saying so.
|
||||
const res = await get('/api/v1/trips/1?include=notes', ADA_TOKEN);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.days).toBeDefined();
|
||||
expect(res.body.days[0].day_notes.length).toBeGreaterThan(0);
|
||||
// Still only what was asked for: no places came along for the ride.
|
||||
expect(res.body.days[0].places).toEqual([]);
|
||||
expect(res.body.unplanned_places).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never exposes internal ids or foreign keys', async () => {
|
||||
const res = await get('/api/v1/trips/1', ADA_TOKEN);
|
||||
const serialised = JSON.stringify(res.body);
|
||||
@@ -293,4 +336,27 @@ describe('Public API v1 e2e (real guard + real SQL)', () => {
|
||||
expect((await get('/api/v1/trips/abc', ADA_TOKEN)).status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bucket list', () => {
|
||||
it("returns the caller's own wishlist, with coordinates", async () => {
|
||||
const res = await get('/api/v1/bucket-list', ADA_TOKEN);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.items).toEqual([
|
||||
{ name: 'Hokkaido', lat: 43.06, lng: 141.35, country_code: 'JP', notes: 'im Winter', target_date: '2027-02-01' },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not leak another user's wishlist", async () => {
|
||||
const res = await get('/api/v1/bucket-list', BOB_TOKEN);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.items).toEqual([
|
||||
{ name: 'Bobs Traumziel', lat: 1, lng: 2, country_code: null, notes: null, target_date: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('needs a token like everything else here', async () => {
|
||||
const res = await request(server).get('/api/v1/bucket-list');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,15 @@ const TRIP = {
|
||||
updated_at: '2026-06-01 10:00:00',
|
||||
};
|
||||
|
||||
const ITEM = {
|
||||
name: 'Hokkaido',
|
||||
lat: 43.06,
|
||||
lng: 141.35,
|
||||
country_code: 'JP',
|
||||
notes: null,
|
||||
target_date: null,
|
||||
};
|
||||
|
||||
/** The limiter always allows unless a test says otherwise. */
|
||||
function makeController(svc: Partial<PublicApiService>, allow = true) {
|
||||
const rl = { check: vi.fn().mockReturnValue(allow) } as unknown as RateLimitService;
|
||||
@@ -47,6 +56,29 @@ function thrown(fn: () => unknown): { status: number; body: unknown } {
|
||||
}
|
||||
|
||||
describe('PublicApiController', () => {
|
||||
describe('GET /api/v1/bucket-list', () => {
|
||||
it('returns the caller’s wishlist and passes the id from the guard, never the query', () => {
|
||||
const listBucketList = vi.fn().mockReturnValue([ITEM]);
|
||||
expect(makeController({ listBucketList }).listBucketList(req(7))).toEqual({ items: [ITEM] });
|
||||
expect(listBucketList).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it('401s when the guard left no user behind', () => {
|
||||
const listBucketList = vi.fn();
|
||||
expect(thrown(() => makeController({ listBucketList }).listBucketList(reqWithoutUser()))).toEqual({
|
||||
status: 401,
|
||||
body: { error: 'API token required', code: 'API_TOKEN_REQUIRED' },
|
||||
});
|
||||
expect(listBucketList).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('counts against the same rate budget as everything else', () => {
|
||||
const listBucketList = vi.fn();
|
||||
expect(thrown(() => makeController({ listBucketList }, false).listBucketList(req(7))).status).toBe(429);
|
||||
expect(listBucketList).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/trips', () => {
|
||||
it('returns the accessible trips for the token owner', () => {
|
||||
const listTrips = vi.fn().mockReturnValue([TRIP]);
|
||||
|
||||
@@ -23,7 +23,7 @@ function contextWith(headers: Record<string, string | undefined>) {
|
||||
}
|
||||
|
||||
function makeGuard(verify: (raw: string) => User | null) {
|
||||
return new ApiTokenGuard({ verifyMcpToken: verify } as unknown as TokenService);
|
||||
return new ApiTokenGuard({ verifyApiToken: verify } as unknown as TokenService);
|
||||
}
|
||||
|
||||
/** Run the guard, expecting a refusal; return its { status, body }. */
|
||||
@@ -69,6 +69,15 @@ describe('ApiTokenGuard', () => {
|
||||
expect(verify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never reaches for the MCP verifier, so the two credentials cannot swap', () => {
|
||||
const verifyApiToken = vi.fn().mockReturnValue(USER);
|
||||
const verifyMcpToken = vi.fn();
|
||||
const guard = new ApiTokenGuard({ verifyApiToken, verifyMcpToken } as unknown as TokenService);
|
||||
guard.canActivate(contextWith({ authorization: 'Bearer trek_abc123' }));
|
||||
expect(verifyApiToken).toHaveBeenCalledTimes(1);
|
||||
expect(verifyMcpToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('401s on a token the store does not know, without saying which part was wrong', () => {
|
||||
const verify = vi.fn().mockReturnValue(null);
|
||||
expect(
|
||||
|
||||
@@ -106,6 +106,32 @@ export const publicApiTravellerSchema = z.object({
|
||||
});
|
||||
export type PublicApiTraveller = z.infer<typeof publicApiTravellerSchema>;
|
||||
|
||||
/**
|
||||
* An entry on the traveller's bucket list: somewhere they want to go, with no
|
||||
* trip attached yet.
|
||||
*
|
||||
* Lives on the user rather than on a trip, which is why it has its own endpoint
|
||||
* rather than an `include`. It is the one part of TREK that is explicitly about
|
||||
* places not yet visited, so a consumer that knows where someone actually went
|
||||
* can tell them they got there.
|
||||
*/
|
||||
export const publicApiBucketListItemSchema = z.object({
|
||||
name: z.string(),
|
||||
lat: z.number().nullable(),
|
||||
lng: z.number().nullable(),
|
||||
/** ISO 3166-1 alpha-2, when the entry is pinned to a country. */
|
||||
country_code: z.string().nullable(),
|
||||
notes: z.string().nullable(),
|
||||
/** An optional date the traveller is aiming for. Not a booking. */
|
||||
target_date: z.string().nullable(),
|
||||
});
|
||||
export type PublicApiBucketListItem = z.infer<typeof publicApiBucketListItemSchema>;
|
||||
|
||||
export const publicApiBucketListSchema = z.object({
|
||||
items: z.array(publicApiBucketListItemSchema),
|
||||
});
|
||||
export type PublicApiBucketList = z.infer<typeof publicApiBucketListSchema>;
|
||||
|
||||
/** Trip without its itinerary — what the list endpoint returns. */
|
||||
export const publicApiTripSummarySchema = z.object({
|
||||
id: z.number(),
|
||||
@@ -136,6 +162,24 @@ export const publicApiTripSchema = publicApiTripSummarySchema.extend({
|
||||
days: z.array(publicApiDaySchema).optional(),
|
||||
accommodations: z.array(publicApiAccommodationSchema).optional(),
|
||||
travellers: z.array(publicApiTravellerSchema).optional(),
|
||||
/**
|
||||
* Places on the trip that sit on no day yet — the shortlist a traveller
|
||||
* collects before deciding when to go where.
|
||||
*
|
||||
* Comes with `include=places`, because a section called "places" that silently
|
||||
* omitted half of them would be worse than one that did not exist. On a real
|
||||
* instance these are routinely half of a trip's places, and they carry the
|
||||
* coordinates that make them worth matching against.
|
||||
*/
|
||||
unplanned_places: z.array(publicApiPlaceSchema).optional(),
|
||||
/**
|
||||
* Bookings not pinned to a day. A reservation keeps its `day_id` as a nullable
|
||||
* reference, and deleting a day sets it null rather than deleting the booking,
|
||||
* so these exist in the wild and are not an edge case.
|
||||
*
|
||||
* Comes with `include=reservations`, for the same reason as above.
|
||||
*/
|
||||
unscheduled_reservations: z.array(publicApiReservationSchema).optional(),
|
||||
});
|
||||
export type PublicApiTrip = z.infer<typeof publicApiTripSchema>;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ Read trips. That is deliberate and complete:
|
||||
|
||||
- list the trips you own or are a member of
|
||||
- fetch one trip with its days, places in planned order, day notes, reservations, accommodations and fellow travellers
|
||||
- read the bucket list: places the traveller wants to reach, not tied to any trip
|
||||
|
||||
It cannot write anything. An integration that reads your itinerary needs no ability to delete a place, and a key that can only read is a very different thing to hand to third-party software.
|
||||
|
||||
@@ -83,6 +84,15 @@ Pick what you need with `?include=`, comma-separated:
|
||||
| `accommodations` | where you sleep, with the date range resolved and check-in/check-out times |
|
||||
| `travellers` | who is on the trip, by display name |
|
||||
|
||||
Two sections come back at trip level rather than on a day, because that is where they live:
|
||||
|
||||
| Field | Comes with | What it is |
|
||||
|---|---|---|
|
||||
| `unplanned_places` | `places` | places collected but not scheduled yet. On a real instance these are routinely **half** of a trip's places, and they carry coordinates. A hotel is not listed here; it is under `accommodations`. |
|
||||
| `unscheduled_reservations` | `reservations` | bookings with no day. Deleting a day detaches its bookings rather than deleting them, so these exist in the wild. |
|
||||
|
||||
Asking for `places`, `notes` or `reservations` brings `days` along automatically, since that is where they are reported. `?include=notes` returns the day skeleton with its notes and empty place lists, not an empty trip.
|
||||
|
||||
Omitting `include` returns everything. A section you did not ask for is **absent** rather than empty, so your code can tell "not requested" apart from "nothing there". An unknown section name is a `400` rather than being silently ignored — a typo should not hand you a payload quietly missing what you wanted.
|
||||
|
||||
```bash
|
||||
@@ -131,12 +141,29 @@ curl -H "Authorization: Bearer trek_…" \
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/bucket-list`
|
||||
|
||||
Places the caller wants to reach, with no trip attached. Its own endpoint because it hangs off the user, not a trip.
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{ "name": "Hokkaido", "lat": 43.06, "lng": 141.35,
|
||||
"country_code": "JP", "notes": "in winter", "target_date": "2027-02-01" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`target_date` is an aspiration, not a booking. Entries are returned whether or not the Atlas addon is switched on: the addon governs whether TREK shows the feature, not whether the rows exist, and an endpoint whose answers change when an unrelated toggle moves is one nobody can build on.
|
||||
|
||||
## Notes for integrators
|
||||
|
||||
**Join on dates, not ids.** Every day carries an ISO `date`, and accommodations carry a resolved date range rather than internal day ids. If your software keeps its own notion of a trip, the date is the one thing both sides can agree on. TREK's internal ids are not in the payload at all, by design — they would tie you to storage details that are free to change.
|
||||
|
||||
**One request, not many.** Rather than separate endpoints per section, `?include=` lets you fetch exactly what you need in a single call. Fetching a trip four times for four sections would burn your rate budget for no benefit.
|
||||
|
||||
**There are no timezones.** Times are stored and returned exactly as the traveller typed them: `14:00` means two in the afternoon wherever they were. If you are matching against timestamps of your own, the trip's dates are the reliable join; the times are a hint, not a UTC instant.
|
||||
|
||||
**Rate limit: 120 requests per minute**, counted per user rather than per IP address. A self-hosted integration and its owner's browser often share an address, and an IP-based limit would let one starve the other. Exceeding it returns `429`; back off and retry.
|
||||
|
||||
**Errors** are JSON with an `error` field:
|
||||
|
||||
Reference in New Issue
Block a user