mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
361 lines
14 KiB
TypeScript
361 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>;
|