Derive client domain types from the shared schema contracts

Add entity/response Zod schemas to @trek/shared (place, trip, assignment, day, budget, packing, reservation), each matched against the producing server service, and re-export them from client types.ts instead of the hand-written duplicates that had drifted (name/title, amount/total_price, owner_id/user_id, cover_url/cover_image, ...). Updates the call sites and test fixtures the corrected types surfaced; type-only, no runtime behaviour change.
This commit is contained in:
Maurice
2026-05-31 15:42:39 +02:00
parent 239a68bb48
commit 3977a5ecba
52 changed files with 732 additions and 435 deletions
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { assignmentPlaceSchema } from '../place/place.schema';
/**
* Assignment API contract — single source of truth for the place↔day itinerary
@@ -11,6 +12,38 @@ import { z } from 'zod';
* request schemas + the bespoke 404/400 controller messages pin the rest.
*/
/**
* Assignment participant embedded on an assignment
* (server/src/services/queryHelpers.ts -> loadParticipantsByAssignmentIds).
*/
export const assignmentParticipantSchema = z.object({
user_id: z.number(),
username: z.string(),
avatar: z.string().nullable().optional(),
});
export type AssignmentParticipant = z.infer<typeof assignmentParticipantSchema>;
/**
* Assignment entity as returned by the day/assignment endpoints
* (server/src/services/queryHelpers.ts -> formatAssignmentWithPlace, and
* assignmentService.getAssignmentWithPlace). The embedded `place` is the trimmed
* assignment-place projection, NOT the full place pool entity. `assignment_time`
* /`assignment_end_time` carry the per-assignment override times.
*/
export const assignmentSchema = z.object({
id: z.number(),
day_id: z.number(),
place_id: z.number(),
order_index: z.number(),
notes: z.string().nullable().optional(),
assignment_time: z.string().nullable().optional(),
assignment_end_time: z.string().nullable().optional(),
participants: z.array(assignmentParticipantSchema).optional(),
created_at: z.string().optional(),
place: assignmentPlaceSchema,
});
export type Assignment = z.infer<typeof assignmentSchema>;
export const assignmentCreateRequestSchema = z.object({
place_id: z.union([z.number(), z.string()]),
notes: z.string().nullable().optional(),
+38
View File
@@ -12,6 +12,44 @@ import { z } from 'zod';
* linked reservation's metadata (and broadcasts reservation:updated).
*/
/**
* Budget item member as embedded on a budget item
* (server/src/services/budgetService.ts -> loadItemMembers). `paid` is the raw
* SQLite INTEGER (0/1); `avatar_url` is the resolved avatar (avatarUrl()).
*/
export const budgetItemMemberSchema = z.object({
user_id: z.number(),
paid: z.number(),
username: z.string(),
avatar_url: z.string().nullable().optional(),
avatar: z.string().nullable().optional(),
budget_item_id: z.number().optional(),
});
export type BudgetItemMember = z.infer<typeof budgetItemMemberSchema>;
/**
* Budget item entity as returned by the budget list/create/update endpoints
* (server/src/services/budgetService.ts). Columns of the `budget_items` table
* plus the embedded `members` array. total_price is SQLite REAL.
*/
export const budgetItemSchema = z.object({
id: z.number(),
trip_id: z.number(),
category: z.string(),
name: z.string(),
total_price: z.number(),
persons: z.number().nullable().optional(),
days: z.number().nullable().optional(),
note: z.string().nullable().optional(),
reservation_id: z.number().nullable().optional(),
paid_by_user_id: z.number().nullable().optional(),
expense_date: z.string().nullable().optional(),
sort_order: z.number().optional(),
created_at: z.string().optional(),
members: z.array(budgetItemMemberSchema).optional(),
});
export type BudgetItem = z.infer<typeof budgetItemSchema>;
export const budgetCreateItemRequestSchema = z.object({
name: z.string().min(1),
category: z.string().optional(),
+34
View File
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { assignmentSchema } from '../assignment/assignment.schema';
/**
* Day + day-note API contract — single source of truth for the
@@ -11,6 +12,39 @@ import { z } from 'zod';
* (the legacy validateStringLengths middleware) — reproduced in the controller.
*/
/**
* Day note entity (server day_notes table / dayNoteService). `sort_order` is
* SQLite REAL; `icon` defaults to a note emoji.
*/
export const dayNoteSchema = z.object({
id: z.number(),
day_id: z.number(),
trip_id: z.number().optional(),
text: z.string(),
time: z.string().nullable().optional(),
icon: z.string().nullable().optional(),
sort_order: z.number().optional(),
created_at: z.string().optional(),
});
export type DayNote = z.infer<typeof dayNoteSchema>;
/**
* Day entity as returned by the day list/get endpoints
* (server/src/services/dayService.ts -> listDays). Columns of the `days` table
* plus the embedded `assignments` and `notes_items` arrays.
*/
export const daySchema = z.object({
id: z.number(),
trip_id: z.number(),
day_number: z.number().optional(),
date: z.string().nullable().optional(),
title: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
assignments: z.array(assignmentSchema).optional(),
notes_items: z.array(dayNoteSchema).optional(),
});
export type Day = z.infer<typeof daySchema>;
export const dayCreateRequestSchema = z.object({
date: z.string().optional(),
notes: z.string().optional(),
+50
View File
@@ -13,6 +13,56 @@ import { z } from 'zod';
const open = z.record(z.string(), z.unknown());
/**
* Packing item entity as returned by the packing endpoints
* (server/src/services/packingService.ts -> SELECT * FROM packing_items).
* `checked` is the raw SQLite INTEGER (0/1). Columns match the packing_items
* table (see server DB): weight_grams/bag_id are nullable, quantity defaults 1.
*/
export const packingItemSchema = z.object({
id: z.number(),
trip_id: z.number(),
name: z.string(),
checked: z.number(),
category: z.string().nullable().optional(),
sort_order: z.number(),
weight_grams: z.number().nullable().optional(),
bag_id: z.number().nullable().optional(),
quantity: z.number().optional(),
created_at: z.string().optional(),
});
export type PackingItem = z.infer<typeof packingItemSchema>;
/**
* Packing bag member embedded on a bag (server packingService -> listBags).
* `avatar` is the resolved avatar URL.
*/
export const packingBagMemberSchema = z.object({
user_id: z.number(),
username: z.string(),
avatar: z.string().nullable().optional(),
});
export type PackingBagMember = z.infer<typeof packingBagMemberSchema>;
/**
* Packing bag entity (server packingService -> listBags). Columns of the
* packing_bags table plus the embedded `members` array (and the optional
* `assigned_username` join present on updateBag).
*/
export const packingBagSchema = z.object({
id: z.number(),
trip_id: z.number(),
name: z.string(),
color: z.string(),
weight_limit_grams: z.number().nullable().optional(),
sort_order: z.number(),
user_id: z.number().nullable().optional(),
assigned_username: z.string().nullable().optional(),
created_at: z.string().optional(),
members: z.array(packingBagMemberSchema).optional(),
});
export type PackingBag = z.infer<typeof packingBagSchema>;
export const packingCreateItemRequestSchema = z.object({
name: z.string().min(1),
category: z.string().optional(),
+86
View File
@@ -1,4 +1,6 @@
import { z } from 'zod';
import { categorySchema } from '../category/category.schema';
import { tagSchema } from '../tag/tag.schema';
/**
* Place API contract — single source of truth for the /api/trips/:tripId/places
@@ -14,6 +16,90 @@ import { z } from 'zod';
const open = z.record(z.string(), z.unknown());
/**
* Embedded category as returned on a place — a trimmed projection of the
* categories row (id/name/color/icon), built inline by placeService and
* getPlaceWithTags. `null` when the place has no category_id.
*/
export const placeCategorySchema = z
.object({
id: z.number(),
name: z.string().nullable(),
color: z.string().nullable(),
icon: z.string().nullable(),
})
.nullable();
export type PlaceCategory = z.infer<typeof placeCategorySchema>;
/**
* Full place entity as returned by the place list / get / create / update
* endpoints (server/src/services/placeService.ts -> getPlaceWithTags). All
* columns of the `places` table (see server/data DB) plus the joined `category`
* projection and `tags` array. Numbers (lat/lng/price) are SQLite REAL, ids are
* INTEGER; provider-derived columns are nullable.
*/
export const placeSchema = z.object({
id: z.number(),
trip_id: z.number(),
name: z.string(),
description: z.string().nullable().optional(),
lat: z.number().nullable().optional(),
lng: z.number().nullable().optional(),
address: z.string().nullable().optional(),
category_id: z.number().nullable().optional(),
price: z.number().nullable().optional(),
currency: z.string().nullable().optional(),
reservation_status: z.string().nullable().optional(),
reservation_notes: z.string().nullable().optional(),
reservation_datetime: z.string().nullable().optional(),
place_time: z.string().nullable().optional(),
end_time: z.string().nullable().optional(),
duration_minutes: z.number().nullable().optional(),
notes: z.string().nullable().optional(),
image_url: z.string().nullable().optional(),
google_place_id: z.string().nullable().optional(),
osm_id: z.string().nullable().optional(),
route_geometry: z.string().nullable().optional(),
website: z.string().nullable().optional(),
phone: z.string().nullable().optional(),
transport_mode: z.string().nullable().optional(),
created_at: z.string().optional(),
updated_at: z.string().optional(),
category: placeCategorySchema.optional(),
tags: z.array(tagSchema.partial()).optional(),
});
export type Place = z.infer<typeof placeSchema>;
/**
* Trimmed place projection embedded inside a day-assignment response
* (server/src/services/queryHelpers.ts -> formatAssignmentWithPlace). This is a
* SUBSET of the full place: no trip_id / osm_id / route_geometry / created_at /
* reservation_* — only the fields the planner needs to render the itinerary card.
*/
export const assignmentPlaceSchema = z.object({
id: z.number(),
name: z.string(),
description: z.string().nullable().optional(),
lat: z.number().nullable().optional(),
lng: z.number().nullable().optional(),
address: z.string().nullable().optional(),
category_id: z.number().nullable().optional(),
price: z.number().nullable().optional(),
currency: z.string().nullable().optional(),
place_time: z.string().nullable().optional(),
end_time: z.string().nullable().optional(),
duration_minutes: z.number().nullable().optional(),
notes: z.string().nullable().optional(),
image_url: z.string().nullable().optional(),
transport_mode: z.string().nullable().optional(),
google_place_id: z.string().nullable().optional(),
website: z.string().nullable().optional(),
phone: z.string().nullable().optional(),
category: placeCategorySchema.optional(),
tags: z.array(tagSchema.partial()).optional(),
});
export type AssignmentPlace = z.infer<typeof assignmentPlaceSchema>;
export const placeCreateRequestSchema = open.and(z.object({ name: z.string().min(1) }));
export type PlaceCreateRequest = z.infer<typeof placeCreateRequestSchema>;
@@ -15,6 +15,91 @@ import { z } from 'zod';
const open = z.record(z.string(), z.unknown());
/**
* A reservation endpoint (flight/train leg terminal) — row of the
* reservation_endpoints table (server/src/services/reservationService.ts).
*/
export const reservationEndpointSchema = z.object({
id: z.number().optional(),
reservation_id: z.number().optional(),
role: z.enum(['from', 'to', 'stop']),
sequence: z.number(),
name: z.string(),
code: z.string().nullable(),
lat: z.number(),
lng: z.number(),
timezone: z.string().nullable(),
local_time: z.string().nullable(),
local_date: z.string().nullable(),
});
export type ReservationEndpoint = z.infer<typeof reservationEndpointSchema>;
/**
* Reservation entity as returned by the reservation list endpoint
* (server/src/services/reservationService.ts -> listReservations). Columns of
* the `reservations` table plus the joined day_number / place_name / linked
* accommodation fields and the computed `day_positions` + `endpoints`.
* `accommodation_id` is stored as TEXT in the DB.
*/
export const reservationSchema = z.object({
id: z.number(),
trip_id: z.number(),
day_id: z.number().nullable().optional(),
end_day_id: z.number().nullable().optional(),
place_id: z.number().nullable().optional(),
assignment_id: z.number().nullable().optional(),
title: z.string(),
reservation_time: z.string().nullable().optional(),
reservation_end_time: z.string().nullable().optional(),
location: z.string().nullable().optional(),
confirmation_number: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
status: z.string(),
type: z.string(),
accommodation_id: z.union([z.number(), z.string()]).nullable().optional(),
metadata: z.string().nullable().optional(),
needs_review: z.number().optional(),
day_plan_position: z.number().nullable().optional(),
created_at: z.string().optional(),
// joined / computed in listReservations
day_number: z.number().nullable().optional(),
place_name: z.string().nullable().optional(),
accommodation_place_id: z.number().nullable().optional(),
accommodation_name: z.string().nullable().optional(),
accommodation_start_day_id: z.number().nullable().optional(),
accommodation_end_day_id: z.number().nullable().optional(),
day_positions: z.record(z.string(), z.number()).nullable().optional(),
endpoints: z.array(reservationEndpointSchema).optional(),
});
export type Reservation = z.infer<typeof reservationSchema>;
/**
* Accommodation entity as returned by listAccommodations / getAccommodationWithPlace
* (server/src/services/dayService.ts). Columns of the day_accommodations table
* plus the joined place fields and (on list) the linked reservation_title.
*/
export const accommodationSchema = z.object({
id: z.number(),
trip_id: z.number(),
place_id: z.number().nullable().optional(),
start_day_id: z.number(),
end_day_id: z.number(),
check_in: z.string().nullable().optional(),
check_in_end: z.string().nullable().optional(),
check_out: z.string().nullable().optional(),
confirmation: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
created_at: z.string().optional(),
// joined in listAccommodations / getAccommodationWithPlace
place_name: z.string().nullable().optional(),
place_address: z.string().nullable().optional(),
place_image: z.string().nullable().optional(),
place_lat: z.number().nullable().optional(),
place_lng: z.number().nullable().optional(),
reservation_title: z.string().nullable().optional(),
});
export type Accommodation = z.infer<typeof accommodationSchema>;
/** Reservation create: title is required; the many optional fields stay open. */
export const reservationCreateRequestSchema = open.and(z.object({ title: z.string().min(1) }));
export type ReservationCreateRequest = z.infer<typeof reservationCreateRequestSchema>;
+45
View File
@@ -13,6 +13,51 @@ import { z } from 'zod';
* permission checks + audit logging. Trip rows are wide, so responses stay open.
*/
/**
* Trip entity as returned by the trip list / get / create / update endpoints
* (server/src/services/tripService.ts -> TRIP_SELECT). Columns of the `trips`
* table plus the computed list fields (day_count, place_count, is_owner as 0/1,
* owner_username, shared_count). `is_archived` is the raw SQLite INTEGER.
*/
export const tripSchema = z.object({
id: z.number(),
user_id: z.number(),
title: z.string(),
description: z.string().nullable().optional(),
start_date: z.string().nullable().optional(),
end_date: z.string().nullable().optional(),
currency: z.string(),
cover_image: z.string().nullable().optional(),
is_archived: z.number(),
reminder_days: z.number(),
created_at: z.string().optional(),
updated_at: z.string().optional(),
// computed in TRIP_SELECT (list/get)
day_count: z.number().optional(),
place_count: z.number().optional(),
is_owner: z.number().optional(),
owner_username: z.string().optional(),
shared_count: z.number().optional(),
});
export type Trip = z.infer<typeof tripSchema>;
/**
* Trip member as returned by the members endpoint
* (server/src/services/tripService.ts -> listMembers). Owner + collaborators
* share this shape; `avatar_url` is resolved from the stored avatar.
*/
export const tripMemberSchema = z.object({
id: z.number(),
username: z.string(),
email: z.string().optional(),
avatar: z.string().nullable().optional(),
avatar_url: z.string().nullable().optional(),
role: z.string().optional(),
added_at: z.string().nullable().optional(),
invited_by_username: z.string().nullable().optional(),
});
export type TripMember = z.infer<typeof tripMemberSchema>;
export const tripCreateRequestSchema = z.object({
title: z.string().min(1),
description: z.string().nullable().optional(),