feat(server): adopt zod DTOs for the vacay body contracts

This commit is contained in:
jubnl
2026-07-27 18:39:47 +02:00
parent a8d6663dc7
commit 29e89a5774
8 changed files with 219 additions and 76 deletions
@@ -138,17 +138,4 @@ export const BODY_CONTRACT_ALLOW_LIST: string[] = [
'UnifiedMemoriesController.addPhotos',
'UnifiedMemoriesController.createAlbumLink',
'UnifiedMemoriesController.setSharing',
'VacayController.acceptInvite',
'VacayController.addHolidayCalendar',
'VacayController.addYear',
'VacayController.cancelInvite',
'VacayController.companyHoliday',
'VacayController.declineInvite',
'VacayController.invite',
'VacayController.setColor',
'VacayController.share',
'VacayController.toggleEntry',
'VacayController.updateHolidayCalendar',
'VacayController.updatePlan',
'VacayController.updateStats',
];
+47 -32
View File
@@ -14,7 +14,21 @@ import {
} from '@nestjs/common';
import type { User } from '../../types';
import { VacayService } from './vacay.service';
import { VacayShareUpdateDto, VacayYearSettingsDto } from './vacay.dto';
import {
VacayAddHolidayCalendarDto,
VacayAddYearDto,
VacayCompanyHolidayDto,
VacayInviteActionDto,
VacayInviteDto,
VacaySetColorDto,
VacayShareDto,
VacayShareUpdateDto,
VacayToggleEntryDto,
VacayUpdateHolidayCalendarDto,
VacayUpdatePlanDto,
VacayUpdateStatsDto,
VacayYearSettingsDto,
} from './vacay.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
@@ -24,8 +38,15 @@ import { CurrentUser } from '../auth/current-user.decorator';
* Byte-identical to the legacy Express route (server/src/routes/vacay.ts): all
* endpoints require auth; the X-Socket-Id header is forwarded to the services so
* the originating client is excluded from the broadcast; POSTs answer 200 (the
* legacy route uses res.json, not 201); and the bespoke 400/403/404/502 bodies
* are reproduced exactly. No addon gate — the legacy mount has none.
* legacy route uses res.json, not 201); and the bespoke 403/404/502 bodies are
* reproduced exactly. No addon gate — the legacy mount has none.
*
* Bodies validate against the @trek/shared vacay schemas via the DTO classes in
* vacay.dto.ts + the global ZodValidationPipe (400 with the standard `{ error }`
* envelope on mismatch — this replaced the bespoke 'region required' and
* 'date required' checks). The 'user_id required' / 'Year required' guards stay:
* the schemas accept falsy-but-present values (0, ''), which the legacy route
* rejected with those exact bodies.
*/
@Controller('api/addons/vacay')
@UseGuards(JwtAuthGuard)
@@ -38,7 +59,7 @@ export class VacayController {
}
@Put('plan')
async updatePlan(@CurrentUser() user: User, @Body() body: Record<string, unknown>, @Headers('x-socket-id') socketId?: string) {
async updatePlan(@CurrentUser() user: User, @Body() body: VacayUpdatePlanDto, @Headers('x-socket-id') socketId?: string) {
const planId = this.vacay.getActivePlanId(user.id);
return this.vacay.updatePlan(planId, body, socketId);
}
@@ -47,12 +68,9 @@ export class VacayController {
@HttpCode(200)
addHolidayCalendar(
@CurrentUser() user: User,
@Body() body: { region?: string; label?: string | null; color?: string; sort_order?: number; type?: 'public_holiday' | 'school_holiday' },
@Body() body: VacayAddHolidayCalendarDto,
@Headers('x-socket-id') socketId?: string,
) {
if (!body.region) {
throw new HttpException({ error: 'region required' }, 400);
}
const planId = this.vacay.getActivePlanId(user.id);
const calendar = this.vacay.addHolidayCalendar(planId, body.region, body.label ?? null, body.color, body.sort_order, socketId, body.type);
return { calendar };
@@ -62,7 +80,7 @@ export class VacayController {
updateHolidayCalendar(
@CurrentUser() user: User,
@Param('id') idParam: string,
@Body() body: Record<string, unknown>,
@Body() body: VacayUpdateHolidayCalendarDto,
@Headers('x-socket-id') socketId?: string,
) {
const id = parseInt(idParam);
@@ -87,7 +105,7 @@ export class VacayController {
@Put('color')
setColor(
@CurrentUser() user: User,
@Body() body: { color?: string; target_user_id?: number | string },
@Body() body: VacaySetColorDto,
@Headers('x-socket-id') socketId?: string,
) {
const planId = this.vacay.getActivePlanId(user.id);
@@ -101,12 +119,12 @@ export class VacayController {
@Post('invite')
@HttpCode(200)
invite(@CurrentUser() user: User, @Body('user_id') userIdInput?: number | string) {
if (!userIdInput) {
invite(@CurrentUser() user: User, @Body() body: VacayInviteDto) {
if (!body.user_id) {
throw new HttpException({ error: 'user_id required' }, 400);
}
const plan = this.vacay.getActivePlan(user.id);
const result = this.vacay.sendInvite(plan.id, user.id, user.username, user.email, userIdInput as number);
const result = this.vacay.sendInvite(plan.id, user.id, user.username, user.email, body.user_id as number);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
@@ -115,8 +133,8 @@ export class VacayController {
@Post('invite/accept')
@HttpCode(200)
acceptInvite(@CurrentUser() user: User, @Body('plan_id') planId: number, @Headers('x-socket-id') socketId?: string) {
const result = this.vacay.acceptInvite(user.id, planId, socketId);
acceptInvite(@CurrentUser() user: User, @Body() body: VacayInviteActionDto, @Headers('x-socket-id') socketId?: string) {
const result = this.vacay.acceptInvite(user.id, body.plan_id as number, socketId);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
@@ -125,16 +143,16 @@ export class VacayController {
@Post('invite/decline')
@HttpCode(200)
declineInvite(@CurrentUser() user: User, @Body('plan_id') planId: number, @Headers('x-socket-id') socketId?: string) {
this.vacay.declineInvite(user.id, planId, socketId);
declineInvite(@CurrentUser() user: User, @Body() body: VacayInviteActionDto, @Headers('x-socket-id') socketId?: string) {
this.vacay.declineInvite(user.id, body.plan_id as number, socketId);
return { success: true };
}
@Post('invite/cancel')
@HttpCode(200)
cancelInvite(@CurrentUser() user: User, @Body('user_id') targetUserId: number) {
cancelInvite(@CurrentUser() user: User, @Body() body: VacayInviteDto) {
const plan = this.vacay.getActivePlan(user.id);
this.vacay.cancelInvite(plan.id, targetUserId);
this.vacay.cancelInvite(plan.id, body.user_id as number);
return { success: true };
}
@@ -159,12 +177,12 @@ export class VacayController {
@Post('years')
@HttpCode(200)
addYear(@CurrentUser() user: User, @Body('year') year?: number, @Headers('x-socket-id') socketId?: string) {
if (!year) {
addYear(@CurrentUser() user: User, @Body() body: VacayAddYearDto, @Headers('x-socket-id') socketId?: string) {
if (!body.year) {
throw new HttpException({ error: 'Year required' }, 400);
}
const planId = this.vacay.getActivePlanId(user.id);
return { years: this.vacay.addYear(planId, year, socketId) };
return { years: this.vacay.addYear(planId, body.year as number, socketId) };
}
@Delete('years/:year')
@@ -199,12 +217,9 @@ export class VacayController {
@HttpCode(200)
toggleEntry(
@CurrentUser() user: User,
@Body() body: { date?: string; target_user_id?: number | string; fraction?: number; kind?: string },
@Body() body: VacayToggleEntryDto,
@Headers('x-socket-id') socketId?: string,
) {
if (!body.date) {
throw new HttpException({ error: 'date required' }, 400);
}
const planId = this.vacay.getActivePlanId(user.id);
let userId = user.id;
if (body.target_user_id && parseInt(String(body.target_user_id)) !== user.id) {
@@ -221,11 +236,11 @@ export class VacayController {
@HttpCode(200)
companyHoliday(
@CurrentUser() user: User,
@Body() body: { date?: string; note?: string },
@Body() body: VacayCompanyHolidayDto,
@Headers('x-socket-id') socketId?: string,
) {
const planId = this.vacay.getActivePlanId(user.id);
return this.vacay.toggleCompanyHoliday(planId, body.date as string, body.note, socketId);
return this.vacay.toggleCompanyHoliday(planId, body.date, body.note, socketId);
}
@Get('stats/:year')
@@ -239,7 +254,7 @@ export class VacayController {
updateStats(
@CurrentUser() user: User,
@Param('year') yearParam: string,
@Body() body: { vacation_days?: number; target_user_id?: number | string },
@Body() body: VacayUpdateStatsDto,
@Headers('x-socket-id') socketId?: string,
) {
const year = parseInt(yearParam);
@@ -261,13 +276,13 @@ export class VacayController {
@HttpCode(200)
share(
@CurrentUser() user: User,
@Body('user_id') userIdInput?: number | string,
@Body() body: VacayShareDto,
@Headers('x-socket-id') socketId?: string,
) {
if (!userIdInput) {
if (!body.user_id) {
throw new HttpException({ error: 'user_id required' }, 400);
}
const result = this.vacay.shareCalendar(user.id, user.email, parseInt(String(userIdInput)), socketId);
const result = this.vacay.shareCalendar(user.id, user.email, parseInt(String(body.user_id)), socketId);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
+26 -1
View File
@@ -4,7 +4,32 @@
* shared Zod schemas stay the single source of truth.
*/
import { createZodDto } from 'nestjs-zod';
import { vacayShareUpdateRequestSchema, vacayYearSettingsRequestSchema } from '@trek/shared';
import {
vacayAddHolidayCalendarRequestSchema,
vacayAddYearRequestSchema,
vacayCompanyHolidayRequestSchema,
vacayInviteActionRequestSchema,
vacayInviteRequestSchema,
vacaySetColorRequestSchema,
vacayShareRequestSchema,
vacayShareUpdateRequestSchema,
vacayToggleEntryRequestSchema,
vacayUpdateHolidayCalendarRequestSchema,
vacayUpdatePlanRequestSchema,
vacayUpdateStatsRequestSchema,
vacayYearSettingsRequestSchema,
} from '@trek/shared';
export class VacayUpdatePlanDto extends createZodDto(vacayUpdatePlanRequestSchema) {}
export class VacayAddHolidayCalendarDto extends createZodDto(vacayAddHolidayCalendarRequestSchema) {}
export class VacayUpdateHolidayCalendarDto extends createZodDto(vacayUpdateHolidayCalendarRequestSchema) {}
export class VacaySetColorDto extends createZodDto(vacaySetColorRequestSchema) {}
export class VacayInviteDto extends createZodDto(vacayInviteRequestSchema) {}
export class VacayInviteActionDto extends createZodDto(vacayInviteActionRequestSchema) {}
export class VacayAddYearDto extends createZodDto(vacayAddYearRequestSchema) {}
export class VacayToggleEntryDto extends createZodDto(vacayToggleEntryRequestSchema) {}
export class VacayCompanyHolidayDto extends createZodDto(vacayCompanyHolidayRequestSchema) {}
export class VacayUpdateStatsDto extends createZodDto(vacayUpdateStatsRequestSchema) {}
export class VacayShareDto extends createZodDto(vacayShareRequestSchema) {}
export class VacayYearSettingsDto extends createZodDto(vacayYearSettingsRequestSchema) {}
export class VacayShareUpdateDto extends createZodDto(vacayShareUpdateRequestSchema) {}
+6 -2
View File
@@ -2,6 +2,10 @@ import { Injectable } from '@nestjs/common';
import * as svc from '../../services/vacayService';
type UpdatePlanBody = Parameters<typeof svc.updatePlan>[1];
// The wire contract admits holidays_region: null (the MCP tool already passes
// null through and SQLite stores NULL); the legacy UpdatePlanBody type is
// narrower than the runtime. Widened here at the wrapper seam.
type UpdatePlanInput = Omit<UpdatePlanBody, 'holidays_region'> & { holidays_region?: string | null };
/**
* Thin Nest wrapper around the existing vacay service. All plan logic, the
@@ -22,8 +26,8 @@ export class VacayService {
return svc.getActivePlan(userId);
}
updatePlan(planId: number, body: UpdatePlanBody, socketId: string | undefined) {
return svc.updatePlan(planId, body, socketId);
updatePlan(planId: number, body: UpdatePlanInput, socketId: string | undefined) {
return svc.updatePlan(planId, body as UpdatePlanBody, socketId);
}
addHolidayCalendar(planId: number, region: string, label: string | null, color: string | undefined, sortOrder: number | undefined, socketId: string | undefined, type?: 'public_holiday' | 'school_holiday') {
+2 -2
View File
@@ -90,10 +90,10 @@ describe('Vacay e2e (real auth guard + temp SQLite)', () => {
expect(svc.toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', undefined, undefined, 'sock-7');
});
it('400 on entries/toggle without a date', async () => {
it('400 from the Zod pipe on entries/toggle without a date', async () => {
const res = await request(server).post('/api/addons/vacay/entries/toggle').set('Cookie', sessionCookie(1)).send({});
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'date required' });
expect(res.body.error).toContain('date');
});
it('403 on color for a user not in the plan', async () => {
+24 -26
View File
@@ -32,16 +32,13 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', ()
it('PUT /plan forwards the socket id', async () => {
const updatePlan = vi.fn().mockResolvedValue({ ok: true });
await makeController({ ...planBase, updatePlan }).updatePlan(user, { foo: 1 }, 'sock-1');
expect(updatePlan).toHaveBeenCalledWith(10, { foo: 1 }, 'sock-1');
await makeController({ ...planBase, updatePlan }).updatePlan(user, { block_weekends: true }, 'sock-1');
expect(updatePlan).toHaveBeenCalledWith(10, { block_weekends: true }, 'sock-1');
});
describe('holiday calendars', () => {
it('400 when region missing', () => {
return thrown(() => makeController({ ...planBase }).addHolidayCalendar(user, {})).then((r) =>
expect(r).toEqual({ status: 400, body: { error: 'region required' } }));
});
// A missing region now 400s in the global ZodValidationPipe (the schema
// requires it), so the bespoke 'region required' guard is gone.
it('creates a calendar', () => {
const addHolidayCalendar = vi.fn().mockReturnValue({ id: 1, region: 'DE-BY' });
const res = makeController({ ...planBase, addHolidayCalendar }).addHolidayCalendar(user, { region: 'DE-BY', label: 'Bayern' }, 'sock');
@@ -78,56 +75,57 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', ()
});
describe('invites', () => {
it('400 when user_id missing', () => {
return thrown(() => makeController({ ...planBase }).invite(user, undefined)).then((r) =>
// The schema requires user_id but still admits falsy values (0, '') — the
// bespoke guard stays for those, byte-identical to the legacy route.
it('400 when user_id falsy', () => {
return thrown(() => makeController({ ...planBase }).invite(user, { user_id: 0 })).then((r) =>
expect(r).toEqual({ status: 400, body: { error: 'user_id required' } }));
});
it('maps a sendInvite error to its status', () => {
const sendInvite = vi.fn().mockReturnValue({ error: 'Already in a plan', status: 409 });
return thrown(() => makeController({ ...planBase, sendInvite }).invite(user, 2)).then((r) =>
return thrown(() => makeController({ ...planBase, sendInvite }).invite(user, { user_id: 2 })).then((r) =>
expect(r).toEqual({ status: 409, body: { error: 'Already in a plan' } }));
});
it('sends an invite', () => {
const sendInvite = vi.fn().mockReturnValue({});
expect(makeController({ ...planBase, sendInvite }).invite(user, 2)).toEqual({ success: true });
expect(makeController({ ...planBase, sendInvite }).invite(user, { user_id: 2 })).toEqual({ success: true });
expect(sendInvite).toHaveBeenCalledWith(10, 1, 'u', 'u@example.test', 2);
});
it('maps an acceptInvite error', () => {
const acceptInvite = vi.fn().mockReturnValue({ error: 'Invite not found', status: 404 });
return thrown(() => makeController({ acceptInvite }).acceptInvite(user, 5)).then((r) =>
return thrown(() => makeController({ acceptInvite }).acceptInvite(user, { plan_id: 5 })).then((r) =>
expect(r).toEqual({ status: 404, body: { error: 'Invite not found' } }));
});
it('decline / cancel / dissolve return success', () => {
const declineInvite = vi.fn(); const cancelInvite = vi.fn(); const dissolvePlan = vi.fn();
expect(makeController({ declineInvite }).declineInvite(user, 5)).toEqual({ success: true });
expect(makeController({ ...planBase, cancelInvite }).cancelInvite(user, 2)).toEqual({ success: true });
expect(makeController({ declineInvite }).declineInvite(user, { plan_id: 5 })).toEqual({ success: true });
expect(makeController({ ...planBase, cancelInvite }).cancelInvite(user, { user_id: 2 })).toEqual({ success: true });
expect(makeController({ dissolvePlan }).dissolve(user)).toEqual({ success: true });
});
});
describe('years', () => {
it('400 when year missing on add', () => {
return thrown(() => makeController({ ...planBase }).addYear(user, undefined)).then((r) =>
// Same falsy-but-present rule as user_id: schema admits 0/'', the guard
// keeps the legacy 'Year required' body for them.
it('400 when year falsy on add', () => {
return thrown(() => makeController({ ...planBase }).addYear(user, { year: 0 })).then((r) =>
expect(r).toEqual({ status: 400, body: { error: 'Year required' } }));
});
it('adds and deletes years', () => {
const addYear = vi.fn().mockReturnValue([2026]); const deleteYear = vi.fn().mockReturnValue([]);
expect(makeController({ ...planBase, addYear }).addYear(user, 2026, 'sock')).toEqual({ years: [2026] });
expect(makeController({ ...planBase, addYear }).addYear(user, { year: 2026 }, 'sock')).toEqual({ years: [2026] });
expect(makeController({ ...planBase, deleteYear }).deleteYear(user, '2026', 'sock')).toEqual({ years: [] });
});
});
describe('entries', () => {
it('400 when date missing on toggle', () => {
return thrown(() => makeController({ ...planBase }).toggleEntry(user, {})).then((r) =>
expect(r).toEqual({ status: 400, body: { error: 'date required' } }));
});
// A missing date now 400s in the global ZodValidationPipe (schema-required),
// so the bespoke 'date required' guard is gone.
it('403 when toggling for a user not in the plan', () => {
const getPlanUsers = vi.fn().mockReturnValue([{ id: 1 }]);
return thrown(() => makeController({ ...planBase, getPlanUsers }).toggleEntry(user, { date: '2026-07-01', target_user_id: 99 })).then((r) =>
@@ -215,20 +213,20 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', ()
expect(listShares).toHaveBeenCalledWith(1);
});
it('400 when user_id missing on share', () => {
return thrown(() => makeController({}).share(user, undefined)).then((r) =>
it('400 when user_id falsy on share', () => {
return thrown(() => makeController({}).share(user, { user_id: 0 })).then((r) =>
expect(r).toEqual({ status: 400, body: { error: 'user_id required' } }));
});
it('maps a shareCalendar error to its status', () => {
const shareCalendar = vi.fn().mockReturnValue({ error: 'Already shared', status: 400 });
return thrown(() => makeController({ shareCalendar }).share(user, 2)).then((r) =>
return thrown(() => makeController({ shareCalendar }).share(user, { user_id: 2 })).then((r) =>
expect(r).toEqual({ status: 400, body: { error: 'Already shared' } }));
});
it('shares a calendar (user_id coerced to a number, socket id forwarded)', () => {
const shareCalendar = vi.fn().mockReturnValue({});
expect(makeController({ shareCalendar }).share(user, '2', 'sock-1')).toEqual({ success: true });
expect(makeController({ shareCalendar }).share(user, { user_id: '2' }, 'sock-1')).toEqual({ success: true });
expect(shareCalendar).toHaveBeenCalledWith(1, 'u@example.test', 2, 'sock-1');
});
+86
View File
@@ -1,8 +1,16 @@
import {
vacayAddHolidayCalendarRequestSchema,
vacayCompanyHolidayRequestSchema,
vacayInviteActionRequestSchema,
vacayInviteRequestSchema,
vacaySetColorRequestSchema,
vacayShareRequestSchema,
vacayShareUpdateRequestSchema,
vacayToggleEntryRequestSchema,
vacayAddYearRequestSchema,
vacayUpdateHolidayCalendarRequestSchema,
vacayUpdatePlanRequestSchema,
vacayUpdateStatsRequestSchema,
vacayYearSettingsRequestSchema,
} from './vacay.schema';
@@ -76,3 +84,81 @@ describe('vacayAddYearRequestSchema', () => {
expect(vacayAddYearRequestSchema.safeParse({}).success).toBe(false);
});
});
describe('vacayUpdatePlanRequestSchema', () => {
it('accepts any partial subset of the plan settings', () => {
expect(vacayUpdatePlanRequestSchema.safeParse({}).success).toBe(true);
expect(vacayUpdatePlanRequestSchema.safeParse({ block_weekends: true }).success).toBe(true);
expect(
vacayUpdatePlanRequestSchema.safeParse({ weekend_days: '0,6', week_start: 0, carry_over_enabled: false }).success,
).toBe(true);
});
it('takes null to clear holidays_region but rejects wrong types', () => {
expect(vacayUpdatePlanRequestSchema.safeParse({ holidays_region: null }).success).toBe(true);
expect(vacayUpdatePlanRequestSchema.safeParse({ holidays_region: 'CH' }).success).toBe(true);
expect(vacayUpdatePlanRequestSchema.safeParse({ block_weekends: 'yes' }).success).toBe(false);
expect(vacayUpdatePlanRequestSchema.safeParse({ week_start: 'monday' }).success).toBe(false);
});
});
describe('vacayUpdateHolidayCalendarRequestSchema', () => {
it('accepts any partial subset including a null label', () => {
expect(vacayUpdateHolidayCalendarRequestSchema.safeParse({}).success).toBe(true);
expect(vacayUpdateHolidayCalendarRequestSchema.safeParse({ region: 'DE-BY', label: null }).success).toBe(true);
expect(
vacayUpdateHolidayCalendarRequestSchema.safeParse({ type: 'school_holiday', color: '#a5f3fc', sort_order: 2 }).success,
).toBe(true);
expect(vacayUpdateHolidayCalendarRequestSchema.safeParse({ type: 'bank_holiday' }).success).toBe(false);
});
});
describe('vacaySetColorRequestSchema', () => {
it('color and target_user_id are both optional', () => {
expect(vacaySetColorRequestSchema.safeParse({}).success).toBe(true);
expect(vacaySetColorRequestSchema.safeParse({ color: '#6366f1' }).success).toBe(true);
expect(vacaySetColorRequestSchema.safeParse({ color: '#6366f1', target_user_id: '3' }).success).toBe(true);
expect(vacaySetColorRequestSchema.safeParse({ color: 42 }).success).toBe(false);
});
});
describe('vacayInviteActionRequestSchema', () => {
it('plan_id is optional (a missing id falls through to the 404 path)', () => {
expect(vacayInviteActionRequestSchema.safeParse({}).success).toBe(true);
expect(vacayInviteActionRequestSchema.safeParse({ plan_id: 7 }).success).toBe(true);
expect(vacayInviteActionRequestSchema.safeParse({ plan_id: '7' }).success).toBe(false);
});
});
describe('vacayCompanyHolidayRequestSchema', () => {
it('requires a date; note optional', () => {
expect(vacayCompanyHolidayRequestSchema.safeParse({ date: '2026-12-24' }).success).toBe(true);
expect(vacayCompanyHolidayRequestSchema.safeParse({ date: '2026-12-24', note: 'Xmas eve' }).success).toBe(true);
expect(vacayCompanyHolidayRequestSchema.safeParse({}).success).toBe(false);
});
});
describe('vacayUpdateStatsRequestSchema', () => {
it('vacation_days and target_user_id are both optional', () => {
expect(vacayUpdateStatsRequestSchema.safeParse({}).success).toBe(true);
expect(vacayUpdateStatsRequestSchema.safeParse({ vacation_days: 25 }).success).toBe(true);
expect(vacayUpdateStatsRequestSchema.safeParse({ vacation_days: 25, target_user_id: 3 }).success).toBe(true);
expect(vacayUpdateStatsRequestSchema.safeParse({ vacation_days: 'many' }).success).toBe(false);
});
});
describe('vacayShareRequestSchema', () => {
it('accepts a numeric or string user_id', () => {
expect(vacayShareRequestSchema.safeParse({ user_id: 2 }).success).toBe(true);
expect(vacayShareRequestSchema.safeParse({ user_id: '2' }).success).toBe(true);
expect(vacayShareRequestSchema.safeParse({}).success).toBe(false);
});
});
describe('vacayShareUpdateRequestSchema', () => {
it('requires a boolean hidden flag', () => {
expect(vacayShareUpdateRequestSchema.safeParse({ hidden: true }).success).toBe(true);
expect(vacayShareUpdateRequestSchema.safeParse({ hidden: 1 }).success).toBe(false);
expect(vacayShareUpdateRequestSchema.safeParse({}).success).toBe(false);
});
});
+28
View File
@@ -16,6 +16,23 @@ import { z } from 'zod';
const open = z.record(z.string(), z.unknown());
// Plan settings update (PUT /plan): every field optional — only provided keys
// are written (dynamic SET list server-side). weekend_days is the
// comma-separated weekday list stored as TEXT (e.g. '0,6'); week_start is
// coerced to 0/1 server-side; holidays_region takes null to clear the legacy
// single-region field (superseded by holiday calendars, still on the wire).
export const vacayUpdatePlanRequestSchema = z.object({
block_weekends: z.boolean().optional(),
holidays_enabled: z.boolean().optional(),
holidays_region: z.string().nullable().optional(),
school_holidays_enabled: z.boolean().optional(),
company_holidays_enabled: z.boolean().optional(),
carry_over_enabled: z.boolean().optional(),
weekend_days: z.string().optional(),
week_start: z.number().optional(),
});
export type VacayUpdatePlanRequest = z.infer<typeof vacayUpdatePlanRequestSchema>;
export const vacayAddHolidayCalendarRequestSchema = z.object({
region: z.string().min(1),
type: z.enum(['public_holiday', 'school_holiday']).optional(),
@@ -25,6 +42,17 @@ export const vacayAddHolidayCalendarRequestSchema = z.object({
});
export type VacayAddHolidayCalendarRequest = z.infer<typeof vacayAddHolidayCalendarRequestSchema>;
// Partial calendar update (PUT /plan/holiday-calendars/:id): every field
// optional — only provided keys are written (dynamic SET list server-side).
export const vacayUpdateHolidayCalendarRequestSchema = z.object({
region: z.string().optional(),
type: z.enum(['public_holiday', 'school_holiday']).optional(),
label: z.string().nullable().optional(),
color: z.string().optional(),
sort_order: z.number().optional(),
});
export type VacayUpdateHolidayCalendarRequest = z.infer<typeof vacayUpdateHolidayCalendarRequestSchema>;
export const vacaySetColorRequestSchema = z.object({
color: z.string().optional(),
target_user_id: z.union([z.number(), z.string()]).optional(),