feat(contracts): vacay and journey routes declare their response schemas

The vacay plan, calendars, years, entries, stats and shares, and the
journey list, create, tracks, stats, share link and every success answer
get response schemas in shared and a @ResponseContract on their handler.
The holiday proxies pass the provider's answer through and are marked
exempt. The journey e2e stubs return whole rows, as the service does.
This commit is contained in:
Maurice
2026-10-10 15:54:15 +02:00
parent 8e01dbb92f
commit e21f2d97b6
7 changed files with 360 additions and 14 deletions
@@ -28,7 +28,7 @@
"src/nest/integrations/airtrail.controller.ts": 6,
"src/nest/integrations/dawarich.controller.ts": 15,
"src/nest/journey/journey-public.controller.ts": 3,
"src/nest/journey/journey.controller.ts": 40,
"src/nest/journey/journey.controller.ts": 20,
"src/nest/llm-parse/llm-capabilities.controller.ts": 1,
"src/nest/llm-parse/llm-local.controller.ts": 2,
"src/nest/maps/maps.controller.ts": 11,
@@ -96,7 +96,6 @@
"src/nest/trip-invite/trip-invite.controller.ts": 5,
"src/nest/trip-members/trip-members.controller.ts": 7,
"src/nest/trips/trips.controller.ts": 12,
"src/nest/vacay/vacay.controller.ts": 33,
"src/nest/weather/weather.controller.ts": 2
}
}
@@ -43,6 +43,17 @@ import {
import { isVideoMime, isVideoExtension, MAX_VIDEO_SIZE } from '../files/files.constants';
import { AllowedFileTypesService } from '../files/allowed-file-types.service';
import { logError } from '../audit/audit-log.logger';
import { ResponseContract } from '../common/response-contract';
import {
emptyResponseSchema,
journeyListResponseSchema,
journeySchema,
journeyShareLinkResponseSchema,
journeyShareLinkSetResponseSchema,
journeyStatsSchema,
journeyTracksResponseSchema,
successResponseSchema,
} from '@trek/shared';
/**
* One filename hook for all four journey upload routes (consumed by
@@ -194,11 +205,13 @@ export class JourneyController {
// ── Static prefix routes (before /:id) ──────────────────────────────────
@Get()
@ResponseContract(journeyListResponseSchema)
async list(@CurrentUser() user: User) {
return { journeys: await this.journey.listJourneys(user.id) };
}
@Post()
@ResponseContract(journeySchema)
create(@CurrentUser() user: User, @Body() body: JourneyCreateDto) {
if (!body.title || typeof body.title !== 'string' || !body.title.trim()) {
throw new HttpException({ error: 'Title is required' }, 400);
@@ -233,6 +246,7 @@ export class JourneyController {
}
@Put('entries/:entryId/photos/reorder')
@ResponseContract(successResponseSchema)
async reorderEntryPhotos(@CurrentUser() user: User, @Param('entryId') entryId: string, @Body() body: JourneyReorderEntryPhotosDto, @Headers('x-socket-id') socketId?: string) {
if (!(await this.journey.reorderEntryPhotos(Number(entryId), user.id, body.orderedIds, socketId))) {
throw new HttpException({ error: 'Entry not found' }, 404);
@@ -241,6 +255,7 @@ export class JourneyController {
}
@Delete('entries/:entryId')
@ResponseContract(successResponseSchema)
async deleteEntry(@CurrentUser() user: User, @Param('entryId') entryId: string, @Headers('x-socket-id') socketId?: string) {
if (!(await this.journey.deleteEntry(Number(entryId), user.id, socketId))) {
throw new HttpException({ error: 'Entry not found' }, 404);
@@ -385,6 +400,7 @@ export class JourneyController {
@Delete('entries/:entryId/photos/:journeyPhotoId')
@HttpCode(204)
@ResponseContract(emptyResponseSchema)
async unlinkPhoto(@CurrentUser() user: User, @Param('entryId') entryId: string, @Param('journeyPhotoId') journeyPhotoId: string): Promise<void> {
if (!(await this.journey.unlinkPhotoFromEntry(Number(entryId), Number(journeyPhotoId), user.id))) {
throw new HttpException({ error: 'Not found or not allowed' }, 404);
@@ -401,6 +417,7 @@ export class JourneyController {
}
@Delete('photos/:photoId')
@ResponseContract(successResponseSchema)
async deletePhoto(@CurrentUser() user: User, @Param('photoId') photoId: string) {
const photo = await this.journey.deletePhoto(Number(photoId), user.id);
if (!photo) {
@@ -531,6 +548,7 @@ export class JourneyController {
@Delete(':id/gallery/:journeyPhotoId')
@HttpCode(204)
@ResponseContract(emptyResponseSchema)
async deleteGalleryPhoto(@CurrentUser() user: User, @Param('journeyPhotoId') journeyPhotoId: string): Promise<void> {
const photo = await this.journey.deleteGalleryPhoto(Number(journeyPhotoId), user.id);
if (!photo) {
@@ -576,6 +594,7 @@ export class JourneyController {
}
@Delete(':id')
@ResponseContract(successResponseSchema)
async remove(@CurrentUser() user: User, @Param('id') id: string) {
if (!(await this.journey.deleteJourney(Number(id), user.id))) {
throw new HttpException({ error: 'Journey not found' }, 404);
@@ -586,6 +605,7 @@ export class JourneyController {
// ── Journey trips ───────────────────────────────────────────────────────
@Post(':id/trips')
@HttpCode(200) // Express answers with res.json (200).
@ResponseContract(successResponseSchema)
async addTrip(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyAddTripDto) {
if (!body.trip_id) {
throw new HttpException({ error: 'trip_id required' }, 400);
@@ -597,6 +617,7 @@ export class JourneyController {
}
@Delete(':id/trips/:tripId')
@ResponseContract(successResponseSchema)
async removeTrip(@CurrentUser() user: User, @Param('id') id: string, @Param('tripId') tripId: string) {
if (!(await this.journey.removeTripFromJourney(Number(id), Number(tripId), user.id))) {
throw new HttpException({ error: 'Not allowed' }, 403);
@@ -620,6 +641,7 @@ export class JourneyController {
* planner is all it takes for it to show up here.
*/
@Get(':id/tracks')
@ResponseContract(journeyTracksResponseSchema)
async listTracks(@CurrentUser() user: User, @Param('id') id: string) {
const tracks = await this.journey.journeyTracks(Number(id), user.id);
if (!tracks) {
@@ -638,6 +660,7 @@ export class JourneyController {
* designing and never while printing.
*/
@Get(':id/stats')
@ResponseContract(journeyStatsSchema)
async stats(@CurrentUser() user: User, @Param('id') id: string) {
const stats = await this.journey.journeyStats(Number(id), user.id);
if (!stats) {
@@ -702,6 +725,7 @@ export class JourneyController {
@Delete(':id/book')
@HttpCode(204)
@ResponseContract(emptyResponseSchema)
async deleteBook(@CurrentUser() user: User, @Param('id') id: string) {
const removed = await this.books.deleteBook(Number(id), user.id);
if (removed === null) {
@@ -736,6 +760,7 @@ export class JourneyController {
}
@Put(':id/entries/reorder')
@ResponseContract(successResponseSchema)
async reorderEntries(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyReorderEntriesDto, @Headers('x-socket-id') socketId?: string) {
const orderedIds = body.orderedIds;
if (!Array.isArray(orderedIds) || !orderedIds.every((v) => Number.isFinite(Number(v)))) {
@@ -749,6 +774,7 @@ export class JourneyController {
// ── Contributors ────────────────────────────────────────────────────────
@Post(':id/contributors')
@ResponseContract(successResponseSchema)
async addContributor(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyContributorAddDto) {
if (!body.user_id) {
throw new HttpException({ error: 'user_id required' }, 400);
@@ -760,6 +786,7 @@ export class JourneyController {
}
@Patch(':id/contributors/:userId')
@ResponseContract(successResponseSchema)
async updateContributor(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string, @Body() body: JourneyContributorUpdateDto) {
if (!(await this.journey.updateContributorRole(Number(id), user.id, Number(userId), body.role as 'editor' | 'viewer'))) {
throw new HttpException({ error: 'Not allowed' }, 403);
@@ -768,6 +795,7 @@ export class JourneyController {
}
@Delete(':id/contributors/:userId')
@ResponseContract(successResponseSchema)
async removeContributor(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string) {
if (!(await this.journey.removeContributor(Number(id), user.id, Number(userId)))) {
throw new HttpException({ error: 'Not allowed' }, 403);
@@ -798,6 +826,7 @@ export class JourneyController {
// ── Share Link ──────────────────────────────────────────────────────────
@Get(':id/share-link')
@ResponseContract(journeyShareLinkResponseSchema)
async getShareLink(@CurrentUser() user: User, @Param('id') id: string) {
// Reading the token is owner-only. It refuses like its siblings rather than
// answering with a null link: "not published" and "not yours to see" are
@@ -812,6 +841,7 @@ export class JourneyController {
@Post(':id/share-link')
@HttpCode(200) // Express answers with res.json (200).
@ResponseContract(journeyShareLinkSetResponseSchema)
async setShareLink(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyShareLinkDto) {
const result = await this.journey.createOrUpdateJourneyShareLink(Number(id), user.id, {
share_timeline: body.share_timeline as boolean | undefined,
@@ -826,6 +856,7 @@ export class JourneyController {
}
@Delete(':id/share-link')
@ResponseContract(successResponseSchema)
async deleteShareLink(@CurrentUser() user: User, @Param('id') id: string) {
if (!(await this.journey.deleteJourneyShareLink(Number(id), user.id))) {
throw new HttpException({ error: 'Not allowed' }, 403);
+50
View File
@@ -31,6 +31,23 @@ import {
} from './vacay.dto';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { CurrentUser } from '../auth-core/current-user.decorator';
import { ResponseContract } from '../common/response-contract';
import {
successResponseSchema,
vacayAvailableUsersResponseSchema,
vacayCompanyHolidayResponseSchema,
vacayEntriesResponseSchema,
vacayHolidayCalendarResponseSchema,
vacayPlanResponseSchema,
vacayPlanUpdateResponseSchema,
vacayShareAvailableUsersResponseSchema,
vacaySharedCalendarsResponseSchema,
vacaySharesResponseSchema,
vacayStatsResponseSchema,
vacayToggleEntryResponseSchema,
vacayYearSettingsResponseSchema,
vacayYearsResponseSchema,
} from '@trek/shared';
/**
* /api/addons/vacay — shared vacation-day planner.
@@ -54,11 +71,13 @@ export class VacayController {
constructor(private readonly vacay: VacayService) {}
@Get('plan')
@ResponseContract(vacayPlanResponseSchema)
getPlan(@CurrentUser() user: User) {
return this.vacay.getPlanData(user.id);
}
@Put('plan')
@ResponseContract(vacayPlanUpdateResponseSchema)
async updatePlan(@CurrentUser() user: User, @Body() body: VacayUpdatePlanDto, @Headers('x-socket-id') socketId?: string) {
const planId = await this.vacay.getActivePlanId(user.id);
return this.vacay.updatePlan(planId, body, socketId);
@@ -66,6 +85,7 @@ export class VacayController {
@Post('plan/holiday-calendars')
@HttpCode(200)
@ResponseContract(vacayHolidayCalendarResponseSchema)
async addHolidayCalendar(
@CurrentUser() user: User,
@Body() body: VacayAddHolidayCalendarDto,
@@ -77,6 +97,7 @@ export class VacayController {
}
@Put('plan/holiday-calendars/:id')
@ResponseContract(vacayHolidayCalendarResponseSchema)
async updateHolidayCalendar(
@CurrentUser() user: User,
@Param('id') idParam: string,
@@ -93,6 +114,7 @@ export class VacayController {
}
@Delete('plan/holiday-calendars/:id')
@ResponseContract(successResponseSchema)
async deleteHolidayCalendar(@CurrentUser() user: User, @Param('id') idParam: string, @Headers('x-socket-id') socketId?: string) {
const id = Number.parseInt(idParam);
const planId = await this.vacay.getActivePlanId(user.id);
@@ -103,6 +125,7 @@ export class VacayController {
}
@Put('color')
@ResponseContract(successResponseSchema)
async setColor(
@CurrentUser() user: User,
@Body() body: VacaySetColorDto,
@@ -119,6 +142,7 @@ export class VacayController {
@Post('invite')
@HttpCode(200)
@ResponseContract(successResponseSchema)
async invite(@CurrentUser() user: User, @Body() body: VacayInviteDto) {
if (!body.user_id) {
throw new HttpException({ error: 'user_id required' }, 400);
@@ -130,6 +154,7 @@ export class VacayController {
@Post('invite/accept')
@HttpCode(200)
@ResponseContract(successResponseSchema)
async acceptInvite(@CurrentUser() user: User, @Body() body: VacayInviteActionDto, @Headers('x-socket-id') socketId?: string) {
await this.vacay.acceptInvite(user.id, body.plan_id as number, socketId);
return { success: true };
@@ -137,6 +162,7 @@ export class VacayController {
@Post('invite/decline')
@HttpCode(200)
@ResponseContract(successResponseSchema)
async declineInvite(@CurrentUser() user: User, @Body() body: VacayInviteActionDto, @Headers('x-socket-id') socketId?: string) {
await this.vacay.declineInvite(user.id, body.plan_id as number, socketId);
return { success: true };
@@ -144,6 +170,7 @@ export class VacayController {
@Post('invite/cancel')
@HttpCode(200)
@ResponseContract(successResponseSchema)
async cancelInvite(@CurrentUser() user: User, @Body() body: VacayInviteDto) {
const plan = await this.vacay.getActivePlan(user.id);
await this.vacay.cancelInvite(plan.id, body.user_id as number);
@@ -152,18 +179,21 @@ export class VacayController {
@Post('dissolve')
@HttpCode(200)
@ResponseContract(successResponseSchema)
async dissolve(@CurrentUser() user: User, @Headers('x-socket-id') socketId?: string) {
await this.vacay.dissolvePlan(user.id, socketId);
return { success: true };
}
@Get('available-users')
@ResponseContract(vacayAvailableUsersResponseSchema)
async availableUsers(@CurrentUser() user: User) {
const planId = await this.vacay.getActivePlanId(user.id);
return { users: await this.vacay.getAvailableUsers(user.id, planId) };
}
@Get('years')
@ResponseContract(vacayYearsResponseSchema)
async years(@CurrentUser() user: User) {
const planId = await this.vacay.getActivePlanId(user.id);
return { years: await this.vacay.listYears(planId) };
@@ -171,6 +201,7 @@ export class VacayController {
@Post('years')
@HttpCode(200)
@ResponseContract(vacayYearsResponseSchema)
async addYear(@CurrentUser() user: User, @Body() body: VacayAddYearDto, @Headers('x-socket-id') socketId?: string) {
if (!body.year) {
throw new HttpException({ error: 'Year required' }, 400);
@@ -180,6 +211,7 @@ export class VacayController {
}
@Delete('years/:year')
@ResponseContract(vacayYearsResponseSchema)
async deleteYear(@CurrentUser() user: User, @Param('year') yearParam: string, @Headers('x-socket-id') socketId?: string) {
const year = Number.parseInt(yearParam);
const planId = await this.vacay.getActivePlanId(user.id);
@@ -187,11 +219,13 @@ export class VacayController {
}
@Get('year-settings')
@ResponseContract(vacayYearSettingsResponseSchema)
async yearSettings(@CurrentUser() user: User) {
return { settings: await this.vacay.getYearSettings(user.id) };
}
@Put('year-settings')
@ResponseContract(vacayYearSettingsResponseSchema)
async updateYearSettings(
@CurrentUser() user: User,
@Body() body: VacayYearSettingsDto,
@@ -200,6 +234,7 @@ export class VacayController {
}
@Get('entries/:year')
@ResponseContract(vacayEntriesResponseSchema)
async entries(@CurrentUser() user: User, @Param('year') year: string) {
const planId = await this.vacay.getActivePlanId(user.id);
// Entries load over the caller's leave-year window (#737), so a shifted year
@@ -209,6 +244,7 @@ export class VacayController {
@Post('entries/toggle')
@HttpCode(200)
@ResponseContract(vacayToggleEntryResponseSchema)
async toggleEntry(
@CurrentUser() user: User,
@Body() body: VacayToggleEntryDto,
@@ -232,6 +268,7 @@ export class VacayController {
@Post('entries/company-holiday')
@HttpCode(200)
@ResponseContract(vacayCompanyHolidayResponseSchema)
async companyHoliday(
@CurrentUser() user: User,
@Body() body: VacayCompanyHolidayDto,
@@ -242,6 +279,7 @@ export class VacayController {
}
@Get('stats/:year')
@ResponseContract(vacayStatsResponseSchema)
async stats(@CurrentUser() user: User, @Param('year') yearParam: string) {
const year = Number.parseInt(yearParam);
const planId = await this.vacay.getActivePlanId(user.id);
@@ -249,6 +287,7 @@ export class VacayController {
}
@Put('stats/:year')
@ResponseContract(successResponseSchema)
async updateStats(
@CurrentUser() user: User,
@Param('year') yearParam: string,
@@ -266,12 +305,14 @@ export class VacayController {
}
@Get('shares')
@ResponseContract(vacaySharesResponseSchema)
shares(@CurrentUser() user: User) {
return this.vacay.listShares(user.id);
}
@Post('shares')
@HttpCode(200)
@ResponseContract(successResponseSchema)
async share(
@CurrentUser() user: User,
@Body() body: VacayShareDto,
@@ -285,16 +326,19 @@ export class VacayController {
}
@Get('shares/available-users')
@ResponseContract(vacayShareAvailableUsersResponseSchema)
async shareAvailableUsers(@CurrentUser() user: User) {
return { users: await this.vacay.getShareAvailableUsers(user.id) };
}
@Get('shares/calendars/:year')
@ResponseContract(vacaySharedCalendarsResponseSchema)
async sharedCalendars(@CurrentUser() user: User, @Param('year') year: string) {
return { calendars: await this.vacay.getSharedCalendars(user.id, year) };
}
@Put('shares/:id')
@ResponseContract(successResponseSchema)
async updateShare(
@CurrentUser() user: User,
@Param('id') idParam: string,
@@ -308,6 +352,7 @@ export class VacayController {
}
@Delete('shares/:id')
@ResponseContract(successResponseSchema)
async deleteShare(@CurrentUser() user: User, @Param('id') idParam: string, @Headers('x-socket-id') socketId?: string) {
if (!(await this.vacay.removeShare(Number.parseInt(idParam), user.id, socketId))) {
throw new HttpException({ error: 'Share not found' }, 404);
@@ -315,24 +360,28 @@ export class VacayController {
return { success: true };
}
// response-contract-exempt: passes the holiday provider's answer through as it came; its shape is the provider's.
@Get('holidays/countries')
async holidayCountries() {
const result = await this.vacay.getCountries();
return result.data;
}
// response-contract-exempt: passes the holiday provider's answer through as it came; its shape is the provider's.
@Get('holidays/:year/:country')
async holidays(@Param('year') year: string, @Param('country') country: string) {
const result = await this.vacay.getHolidays(year, country);
return result.data;
}
// response-contract-exempt: passes the holiday provider's answer through as it came; its shape is the provider's.
@Get('school-holidays/regions/:country')
async schoolHolidayRegions(@Param('country') country: string) {
const result = await this.vacay.getSchoolHolidayRegions(country, country.toUpperCase() === 'DE' ? 'DE' : 'EN');
return result.data;
}
// response-contract-exempt: passes the holiday provider's answer through as it came; its shape is the provider's.
@Get('school-holidays/:year/:country')
async schoolHolidaysForCountry(
@Param('year') year: string,
@@ -342,6 +391,7 @@ export class VacayController {
return this.schoolHolidays(year, country, undefined, group);
}
// response-contract-exempt: passes the holiday provider's answer through as it came; its shape is the provider's.
@Get('school-holidays/:year/:country/:subdivision')
async schoolHolidaysForSubdivision(
@Param('year') year: string,
+13 -4
View File
@@ -46,6 +46,15 @@ vi.mock('../../src/nest/memories/photo-resolver.service', async (importOriginal)
return actual;
});
// Whole rows, as the service returns them: the routes answer under their
// @ResponseContract, and a stub missing columns would fail it.
const journeyRow = (id: number) => ({
id, user_id: 1, title: 'J', subtitle: null, cover_gradient: null, status: 'draft',
created_at: 1_760_000_000_000, updated_at: 1_760_000_000_000, cover_image: null,
show_trip_tracks: 1, show_verdict: 1, show_mood: 1, show_weather: 1, status_override: null, photo_location: 1,
});
const journeyListRow = { ...journeyRow(1), entry_count: 0, photo_count: 0, place_count: 0, trip_date_min: null, trip_date_max: null };
const { jsvc } = vi.hoisted(() => ({
jsvc: {
listJourneys: vi.fn(), createJourney: vi.fn(), getJourneyFull: vi.fn(),
@@ -115,8 +124,8 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => {
seedUser(db as never, { id: 1 });
app = await build();
server = app.getHttpServer();
jsvc.listJourneys.mockReturnValue([{ id: 1, title: 'J' }]);
jsvc.createJourney.mockReturnValue({ id: 9, title: 'J' });
jsvc.listJourneys.mockReturnValue([journeyListRow]);
jsvc.createJourney.mockReturnValue(journeyRow(9));
sharesvc.getPublicJourney.mockReturnValue({ id: 9 });
});
@@ -147,13 +156,13 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => {
it('200 list with a session', async () => {
const res = await request(server).get('/api/journeys').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ journeys: [{ id: 1, title: 'J' }] });
expect(res.body).toEqual({ journeys: [journeyListRow] });
});
it('201 create, 400 without a title', async () => {
const ok = await request(server).post('/api/journeys').set('Cookie', sessionCookie(1)).send({ title: 'J' });
expect(ok.status).toBe(201);
expect(ok.body).toEqual({ id: 9, title: 'J' });
expect(ok.body).toEqual(journeyRow(9));
const bad = await request(server).post('/api/journeys').set('Cookie', sessionCookie(1)).send({});
expect(bad.status).toBe(400);
expect(bad.body).toEqual({ error: 'Title is required' });
+2
View File
@@ -35,6 +35,8 @@ One folder per domain exporting Zod schemas plus inferred types (`export type X
**Schemas mirror the exact wire behavior of existing routes** (`weather/weather.schema.ts` is the example): strings stay strings if the route never coerced them, optional fields reflect partial response subsets, and bespoke 4xx error strings are reproduced in the server controller, not derived from the schema. Don't "tidy up" a schema to be stricter than the contract it documents.
**Response schemas** (`<x>ResponseSchema`, with the row schemas they are built from) describe what a route answers, and the server checks them: a handler declares one with `@ResponseContract(schema)` (`server/src/nest/common/response-contract.ts`), and under `NODE_ENV=test` every response it sends is parsed against it, so the e2e and integration suites fail on drift. They follow the wire exactly as the routes have always sent it, flags as 0/1 and times as stored where that is what goes out, booleans where the service converts. Objects are not strict: an extra key passes, a declared one that is missing or of another type fails. `successResponseSchema` (`{ success: true }`) and `emptyResponseSchema` (a 204) live in `src/common/`. They are exported from the barrel for the client to type its calls with; the client's own copies in `types.ts` have not moved onto them yet.
## Rules for new contracts
The parity rule governs **existing** routes; it is not a license to mint new debt:
+72 -7
View File
@@ -115,7 +115,10 @@ export type JourneyPhotoUpdateRequest = z.infer<typeof journeyPhotoUpdateRequest
*/
export const journalPluginPhotoInputSchema = z.strictObject({
name: z.string().trim().min(1).max(255),
content_base64: z.string().min(1).max(14 * 1024 * 1024),
content_base64: z
.string()
.min(1)
.max(14 * 1024 * 1024),
caption: z.string().max(2000).optional(),
});
export type JournalPluginPhotoInput = z.infer<typeof journalPluginPhotoInputSchema>;
@@ -138,15 +141,19 @@ export type JourneyEntryUpdateRequest = z.infer<typeof journeyEntryUpdateRequest
* uploaded' for the empty case. Without this the pipe would answer
* 'body: Invalid input: expected object' first and the message would change.
*/
export const journeyEntryPhotoUploadRequestSchema = z.looseObject({
caption: z.unknown().optional(),
}).optional();
export const journeyEntryPhotoUploadRequestSchema = z
.looseObject({
caption: z.unknown().optional(),
})
.optional();
export type JourneyEntryPhotoUploadRequest = z.infer<typeof journeyEntryPhotoUploadRequestSchema>;
/** Multipart field alongside the uploaded video — absent body for the same reason. */
export const journeyGalleryVideoRequestSchema = z.looseObject({
duration_ms: z.unknown().optional(),
}).optional();
export const journeyGalleryVideoRequestSchema = z
.looseObject({
duration_ms: z.unknown().optional(),
})
.optional();
export type JourneyGalleryVideoRequest = z.infer<typeof journeyGalleryVideoRequestSchema>;
/** Free-form: per-user display preferences, forwarded whole. */
@@ -182,3 +189,61 @@ export const journeyTracksResponseSchema = z.object({
tracks: z.array(journeyTrackSchema),
});
export type JourneyTracksResponse = z.infer<typeof journeyTracksResponseSchema>;
// ── Responses ───────────────────────────────────────────────────────────────
/**
* A journeys row as the routes return it. Unlike most tables its times are epoch
* milliseconds, and its flags SQLite 0/1, as stored.
*/
export const journeySchema = z.object({
id: z.number(),
user_id: z.number(),
title: z.string(),
subtitle: z.string().nullable(),
cover_gradient: z.string().nullable(),
status: z.string().nullable(),
created_at: z.number(),
updated_at: z.number(),
cover_image: z.string().nullable(),
show_trip_tracks: z.number(),
show_verdict: z.number(),
show_mood: z.number(),
show_weather: z.number(),
status_override: z.string().nullable(),
photo_location: z.number(),
});
export type JourneyRecord = z.infer<typeof journeySchema>;
/** A journey on the dashboard, with its stat pills. */
export const journeyListItemSchema = journeySchema.extend({
entry_count: z.number(),
photo_count: z.number(),
place_count: z.number(),
trip_date_min: z.string().nullable(),
trip_date_max: z.string().nullable(),
});
export type JourneyListItem = z.infer<typeof journeyListItemSchema>;
/** GET /api/journeys */
export const journeyListResponseSchema = z.object({ journeys: z.array(journeyListItemSchema) });
export type JourneyListResponse = z.infer<typeof journeyListResponseSchema>;
/** A journey's public share link and what it shows. */
export const journeyShareLinkSchema = z.object({
token: z.string(),
created_at: z.string().nullable(),
share_timeline: z.boolean(),
share_gallery: z.boolean(),
share_map: z.boolean(),
newest_first: z.boolean(),
});
export type JourneyShareLink = z.infer<typeof journeyShareLinkSchema>;
/** GET :id/share-link: the link, or null while the journey is not published. */
export const journeyShareLinkResponseSchema = z.object({ link: journeyShareLinkSchema.nullable() });
export type JourneyShareLinkResponse = z.infer<typeof journeyShareLinkResponseSchema>;
/** POST :id/share-link: the token, and whether this call minted it. */
export const journeyShareLinkSetResponseSchema = z.object({ token: z.string(), created: z.boolean() });
export type JourneyShareLinkSetResponse = z.infer<typeof journeyShareLinkSetResponseSchema>;
+191 -1
View File
@@ -127,6 +127,196 @@ export const vacayShareUpdateRequestSchema = z.object({
});
export type VacayShareUpdateRequest = z.infer<typeof vacayShareUpdateRequestSchema>;
/** Plan / entries / stats payloads are wide and DB-derived; kept open. */
/**
* Plan / entries / stats payloads, kept open.
* @deprecated Use the typed response schemas below (vacayPlanResponseSchema, vacayEntriesResponseSchema, ...).
*/
export const vacayPlanDataSchema = open;
export type VacayPlanData = z.infer<typeof vacayPlanDataSchema>;
// ── Responses ───────────────────────────────────────────────────────────────
// The plan's own flags come back as booleans (the service converts them); the
// other rows keep SQLite's storage spelling, as the routes have always answered.
/** A holiday calendar on a plan (public or school holidays of one region). */
export const vacayHolidayCalendarSchema = z.object({
id: z.number(),
plan_id: z.number(),
type: z.enum(['public_holiday', 'school_holiday']),
region: z.string(),
label: z.string().nullable(),
color: z.string(),
sort_order: z.number(),
});
export type VacayHolidayCalendar = z.infer<typeof vacayHolidayCalendarSchema>;
/** A vacation plan with its settings and holiday calendars. */
export const vacayPlanSchema = z.object({
id: z.number(),
owner_id: z.number(),
block_weekends: z.boolean(),
holidays_enabled: z.boolean(),
holidays_region: z.string().nullable(),
school_holidays_enabled: z.boolean(),
company_holidays_enabled: z.boolean(),
carry_over_enabled: z.boolean(),
weekend_days: z.string().nullable(),
week_start: z.number(),
holiday_calendars: z.array(vacayHolidayCalendarSchema),
});
export type VacayPlan = z.infer<typeof vacayPlanSchema>;
/** A person on a plan, or one who could be invited to it. */
export const vacayUserSchema = z.object({ id: z.number(), username: z.string(), email: z.string() });
export type VacayUser = z.infer<typeof vacayUserSchema>;
/** GET /plan: the caller's active plan, its people and its open invitations. */
export const vacayPlanResponseSchema = z.object({
plan: vacayPlanSchema,
users: z.array(vacayUserSchema.extend({ color: z.string() })),
pendingInvites: z.array(
z.object({
id: z.number(),
user_id: z.number(),
username: z.string(),
email: z.string(),
created_at: z.string().nullable(),
}),
),
incomingInvites: z.array(
z.object({
id: z.number(),
plan_id: z.number(),
username: z.string(),
email: z.string(),
created_at: z.string().nullable(),
}),
),
isOwner: z.boolean(),
isFused: z.boolean(),
});
export type VacayPlanResponse = z.infer<typeof vacayPlanResponseSchema>;
/** PUT /plan: the plan after the write. */
export const vacayPlanUpdateResponseSchema = z.object({ plan: vacayPlanSchema });
export type VacayPlanUpdateResponse = z.infer<typeof vacayPlanUpdateResponseSchema>;
/** POST/PUT plan/holiday-calendars */
export const vacayHolidayCalendarResponseSchema = z.object({ calendar: vacayHolidayCalendarSchema });
export type VacayHolidayCalendarResponse = z.infer<typeof vacayHolidayCalendarResponseSchema>;
/** GET available-users: who could be invited to fuse with the plan. */
export const vacayAvailableUsersResponseSchema = z.object({ users: z.array(vacayUserSchema) });
export type VacayAvailableUsersResponse = z.infer<typeof vacayAvailableUsersResponseSchema>;
/** GET/POST /years, DELETE /years/:year: the plan's years after the call. */
export const vacayYearsResponseSchema = z.object({ years: z.array(z.number()) });
export type VacayYearsResponse = z.infer<typeof vacayYearsResponseSchema>;
/** A user's leave-year settings (#737). */
export const vacayYearSettingsSchema = z.object({
user_id: z.number(),
year_type: z.string(),
year_start_month: z.number(),
year_start_day: z.number(),
hire_date: z.string().nullable(),
});
export type VacayYearSettings = z.infer<typeof vacayYearSettingsSchema>;
/** GET/PUT year-settings */
export const vacayYearSettingsResponseSchema = z.object({ settings: vacayYearSettingsSchema });
export type VacayYearSettingsResponse = z.infer<typeof vacayYearSettingsResponseSchema>;
/** GET entries/:year: every entry in the viewer's leave-year window, and the company holidays in it. */
export const vacayEntriesResponseSchema = z.object({
entries: z.array(
z.object({
id: z.number(),
plan_id: z.number(),
user_id: z.number(),
date: z.string(),
note: z.string().nullable(),
fraction: z.number(),
kind: z.string().nullable(),
person_name: z.string(),
person_color: z.string(),
}),
),
companyHolidays: z.array(
z.object({
id: z.number(),
plan_id: z.number(),
date: z.string(),
note: z.string().nullable(),
fraction: z.number(),
}),
),
});
export type VacayEntriesResponse = z.infer<typeof vacayEntriesResponseSchema>;
/** POST entries/toggle: what the click did to the day. */
export const vacayToggleEntryResponseSchema = z.object({
action: z.enum(['added', 'updated', 'removed']),
fraction: z.number().optional(),
kind: z.string().optional(),
});
export type VacayToggleEntryResponse = z.infer<typeof vacayToggleEntryResponseSchema>;
/** POST entries/company-holiday: what the click did to the company holiday. */
export const vacayCompanyHolidayResponseSchema = z.object({
action: z.enum(['added', 'updated', 'removed']),
fraction: z.number().optional(),
});
export type VacayCompanyHolidayResponse = z.infer<typeof vacayCompanyHolidayResponseSchema>;
/** GET stats/:year: one row per person on the plan. */
export const vacayStatsResponseSchema = z.object({
stats: z.array(
z.object({
user_id: z.number(),
person_name: z.string(),
person_color: z.string(),
year: z.number(),
vacation_days: z.number().nullable(),
carried_over: z.number().nullable(),
total_available: z.number(),
used: z.number(),
remaining: z.number(),
comp_used: z.number(),
window_start: z.string(),
window_end: z.string(),
}),
),
});
export type VacayStatsResponse = z.infer<typeof vacayStatsResponseSchema>;
/** GET /shares: the caller's outgoing shares and the calendars shared with them. */
export const vacaySharesResponseSchema = z.object({
outgoing: z.array(z.object({ id: z.number(), user_id: z.number(), username: z.string() })),
incoming: z.array(
z.object({ id: z.number(), owner_id: z.number(), username: z.string(), color: z.string(), hidden: z.boolean() }),
),
});
export type VacaySharesResponse = z.infer<typeof vacaySharesResponseSchema>;
/** GET shares/available-users: who the caller could share with (usernames only). */
export const vacayShareAvailableUsersResponseSchema = z.object({
users: z.array(z.object({ id: z.number(), username: z.string() })),
});
export type VacayShareAvailableUsersResponse = z.infer<typeof vacayShareAvailableUsersResponseSchema>;
/** GET shares/calendars/:year: the calendars shared with the caller, over the caller's window. */
export const vacaySharedCalendarsResponseSchema = z.object({
calendars: z.array(
z.object({
share_id: z.number(),
owner_id: z.number(),
owner_name: z.string(),
color: z.string(),
hidden: z.boolean(),
entries: z.array(z.object({ date: z.string(), fraction: z.number(), kind: z.string().nullable() })),
companyHolidays: z.array(z.object({ date: z.string() })),
}),
),
});
export type VacaySharedCalendarsResponse = z.infer<typeof vacaySharedCalendarsResponseSchema>;