Files
TREK/shared/src/public-api/public-api.schema.ts
T
Maurice ba3dc65926 feat(public-api): next_trip in /api/v1/stats counts down to the next trip
The counterpart of last_trip for a homepage widget: the nearest trip that has not started yet, with its countries and the whole days until it starts, or null when nothing is planned. A trip under way stays last_trip, so the two never name the same trip. (#2542)
2026-10-01 09:22:37 +02:00

356 lines
14 KiB
TypeScript

import { z } from 'zod';
/**
* Public API v1 contract — the read-only surface third-party integrations bind to.
*
* Everything else in TREK's REST surface is internal: it answers a session cookie,
* carries no version, and changes whenever the client changes. This is the opposite
* promise. Once an integration ships against `/api/v1`, these shapes are a contract,
* so the rules are: fields may be added, never removed or retyped, and a breaking
* change gets a new version rather than an edit here.
*
* The shapes are deliberately NOT the internal row types. A row carries ids, foreign
* keys and storage details a consumer has no business depending on (`order_index`,
* `assignment_id`, the accommodation's `place_id`); those are resolved here instead,
* so the payload survives a refactor of the tables underneath.
*
* Every dated thing carries `date` in ISO `YYYY-MM-DD`. That is the join key for a
* consumer that keeps its own notion of a trip — location trackers bucket points by
* day, and matching on a date is the only thing both sides can agree on without
* sharing ids.
*/
/** A place as it sits on a day, with the day's ordering already applied. */
export const publicApiPlaceSchema = z.object({
name: z.string(),
address: z.string().nullable(),
/** Null for a place the user never geocoded — plenty of them exist. */
lat: z.number().nullable(),
lng: z.number().nullable(),
/** `HH:MM`, null when the stop is not pinned to a time. */
time: z.string().nullable(),
end_time: z.string().nullable(),
duration_minutes: z.number().nullable(),
category: z.string().nullable(),
notes: z.string().nullable(),
/** How the traveller reaches this stop from the previous one. */
transport_mode: z.string().nullable(),
});
export type PublicApiPlace = z.infer<typeof publicApiPlaceSchema>;
/** A free-text note pinned to a day, optionally to a time within it. */
export const publicApiDayNoteSchema = z.object({
text: z.string(),
time: z.string().nullable(),
});
export type PublicApiDayNote = z.infer<typeof publicApiDayNoteSchema>;
/**
* A booking. `type` is TREK's own vocabulary (flight, train, restaurant, …) and is
* passed through unmapped: inventing a canonical enum here would silently drop the
* types a consumer might actually care about.
*/
export const publicApiReservationSchema = z.object({
type: z.string().nullable(),
title: z.string().nullable(),
location: z.string().nullable(),
/** ISO timestamp or `HH:MM`, exactly as the user entered it. */
time: z.string().nullable(),
end_time: z.string().nullable(),
status: z.string().nullable(),
notes: z.string().nullable(),
});
export type PublicApiReservation = z.infer<typeof publicApiReservationSchema>;
/**
* Where the traveller sleeps. Spans days, so it is reported once at trip level with
* its own date range rather than duplicated onto every night it covers.
*/
export const publicApiAccommodationSchema = z.object({
name: z.string().nullable(),
address: z.string().nullable(),
lat: z.number().nullable(),
lng: z.number().nullable(),
/** First and last night as ISO dates, resolved from the start/end day rows. */
start_date: z.string().nullable(),
end_date: z.string().nullable(),
check_in: z.string().nullable(),
check_out: z.string().nullable(),
notes: z.string().nullable(),
});
export type PublicApiAccommodation = z.infer<typeof publicApiAccommodationSchema>;
/** One day of the itinerary. `date` is the join key for consumers. */
export const publicApiDaySchema = z.object({
date: z.string(),
day_number: z.number(),
title: z.string().nullable(),
notes: z.string().nullable(),
places: z.array(publicApiPlaceSchema),
day_notes: z.array(publicApiDayNoteSchema),
reservations: z.array(publicApiReservationSchema),
});
export type PublicApiDay = z.infer<typeof publicApiDaySchema>;
/**
* Someone who is on the trip.
*
* Display name only. Ids would let a consumer correlate the same person across
* instances, and email addresses have no business leaving TREK over an
* integration key — the name is what a traveller already sees on the trip.
*/
export const publicApiTravellerSchema = z.object({
name: z.string(),
/** True for the trip's owner; everyone else joined as a member. */
owner: z.boolean(),
});
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(),
title: z.string(),
description: z.string().nullable(),
start_date: z.string().nullable(),
end_date: z.string().nullable(),
currency: z.string().nullable(),
archived: z.boolean(),
/**
* When the trip's own fields last changed — title, dates, currency, cover.
*
* Explicitly NOT a content timestamp: editing a place, a day or a note does not
* touch it, because the child tables carry no `updated_at` of their own. A client
* that polls must compare the payload it received, not this value; using it to
* skip a fetch would silently miss every itinerary edit.
*/
updated_at: z.string().nullable(),
});
export type PublicApiTripSummary = z.infer<typeof publicApiTripSummarySchema>;
/**
* A whole trip. The `days`, `accommodations` and per-day sections are present only
* when the caller asked for them via `include`; an omitted section is absent rather
* than empty, so a consumer can tell "not requested" from "nothing there".
*/
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>;
/**
* The sections a caller may ask for. Defaults to everything, because the common
* case is a full sync; a consumer that only wants notes says so and gets a payload
* a fraction of the size.
*/
export const PUBLIC_API_INCLUDES = ['days', 'places', 'notes', 'reservations', 'accommodations', 'travellers'] as const;
export type PublicApiInclude = (typeof PUBLIC_API_INCLUDES)[number];
/**
* `?include=days,notes` — comma-separated, unknown values rejected rather than
* ignored, so a typo surfaces as a 400 instead of a silently missing section.
* Absent means all sections.
*/
export const publicApiIncludeQuerySchema = z
.string()
.transform((raw) => raw.split(',').map((part) => part.trim()).filter(Boolean))
.pipe(z.array(z.enum(PUBLIC_API_INCLUDES)).min(1));
export const publicApiTripListSchema = z.object({
trips: z.array(publicApiTripSummarySchema),
});
export type PublicApiTripList = z.infer<typeof publicApiTripListSchema>;
/**
* What an integration key is allowed to read.
*
* These are **not** `include`. `include` picks which sections a response
* carries and any caller may ask for any of them; these decide what the key may
* see at all, and the server refuses the rest. The two were confused often
* enough that it is worth saying plainly: handing an integration a key because
* it wanted day notes used to hand it every trip in full.
*
* The sections mirror the `include` vocabulary so an integrator does not have
* to learn two lists, plus the two endpoints that are not a trip section:
* `bucket-list` (hangs off the user) and `stats` (aggregates everything, which
* is exactly why a narrow key must be able to exclude it).
*/
export const PUBLIC_API_SCOPES = [
'trips',
'days',
'places',
'notes',
'reservations',
'accommodations',
'travellers',
'bucket-list',
'stats',
] as const;
export type PublicApiScope = (typeof PUBLIC_API_SCOPES)[number];
/**
* How a key's access is decided.
*
* `all` is an explicit value rather than an absent list, because "no list means
* everything" is the kind of sentinel where a bug that drops the column grants
* full access instead of none. Every key minted before scopes existed is `all`,
* which is exactly what it was and still is.
*/
export const PUBLIC_API_SCOPE_MODES = ['all', 'limited'] as const;
export const publicApiScopeModeSchema = z.enum(PUBLIC_API_SCOPE_MODES);
export type PublicApiScopeMode = z.infer<typeof publicApiScopeModeSchema>;
/**
* What a key may read, resolved.
*
* `mode: 'all'` carries the full list rather than an empty one, so a consumer
* never has to reimplement "empty means everything" — the rule this type exists
* to get rid of.
*/
export const publicApiGrantSchema = z.object({
mode: publicApiScopeModeSchema,
scopes: z.array(z.enum(PUBLIC_API_SCOPES)),
});
export type PublicApiGrant = z.infer<typeof publicApiGrantSchema>;
/**
* Minting an integration key. A key created without `scopes` reads everything,
* which is what every key did before this existed — narrowing is opt-in, so no
* integration breaks the day the column ships.
*/
export const apiTokenCreateRequestSchema = z.object({
name: z.string().optional(),
scopes: z.array(z.enum(PUBLIC_API_SCOPES)).min(1).optional(),
});
export type ApiTokenCreateRequest = z.infer<typeof apiTokenCreateRequestSchema>;
/** A key as the settings list shows it. The key itself is never in here. */
export const apiTokenSummarySchema = z.object({
id: z.number(),
name: z.string(),
token_prefix: z.string(),
created_at: z.string(),
last_used_at: z.string().nullable(),
scope_mode: publicApiScopeModeSchema,
/** The chosen sections; the full list when the mode is `all`. */
scopes: z.array(z.enum(PUBLIC_API_SCOPES)),
});
export type ApiTokenSummary = z.infer<typeof apiTokenSummarySchema>;
/**
* The trip the traveller most recently took, for a dashboard that wants to name it.
*
* "Most recently took" means started, not created: a trip booked for next year is
* not what anybody means by their last trip. A user whose trips are all in the
* future therefore gets `null` here rather than a trip they have not been on.
*/
export const publicApiLastTripSchema = z.object({
title: z.string(),
start_date: z.string().nullable(),
end_date: z.string().nullable(),
/**
* ISO-3166-1 alpha-2 of the country the trip mostly took place in — the one a
* widget shows when it has room for exactly one. Null when no place on the trip
* ever resolved to a country, which is normal for a trip jotted down without
* geocoded stops.
*/
country: z.string().nullable(),
/**
* Every country the trip touched, most-visited first. `country` is this list's
* head; a multi-country trip is not lossy here.
*/
countries: z.array(z.string()),
});
export type PublicApiLastTrip = z.infer<typeof publicApiLastTripSchema>;
/**
* The trip the traveller goes on next, for a widget that counts down to it (#2542).
*
* "Next" means not started yet, so a trip under way is still `last_trip` and the
* two never name the same trip. A user with nothing ahead gets `null`.
*/
export const publicApiNextTripSchema = publicApiLastTripSchema.extend({
start_date: z.string(),
/** Whole days from today until the start date; 1 means it starts tomorrow. */
days_until: z.number().int().positive(),
});
export type PublicApiNextTrip = z.infer<typeof publicApiNextTripSchema>;
/**
* Aggregate counts for a dashboard widget (#1367) — Homepage's `customapi` and
* anything else that renders a handful of numbers and cannot aggregate a list
* itself.
*
* Scalars rather than the arrays behind them. `GET /api/v1/trips` already hands
* out the rows for a consumer that wants to count its own way; this endpoint
* exists for the one that wants a number and a single HTTP request.
*
* The figures are the ones TREK's own dashboard shows, computed from the same
* source, so a widget cannot disagree with the passport card sitting next to it.
* That is the whole reason this is not a fresh count over `/trips`: "visited
* countries" carries years of accumulated rules — countries reached only by a
* flight, layovers that do not count as visited, countries the user hid by hand —
* and a second implementation would drift from the first the week after it shipped.
*/
export const publicApiStatsSchema = z.object({
/** Trips the caller owns or is a member of, archived ones included. */
total_trips: z.number(),
/** Distinct countries counted as visited. */
total_countries: z.number(),
/** Distinct cities, derived from place addresses. */
total_cities: z.number(),
total_places: z.number(),
/** Days across all trips, not days travelled. */
total_days: z.number(),
/** Flown distance, summed over non-cancelled flight bookings. Kilometres. */
total_distance_km: z.number(),
last_trip: publicApiLastTripSchema.nullable(),
next_trip: publicApiNextTripSchema.nullable(),
});
export type PublicApiStats = z.infer<typeof publicApiStatsSchema>;