diff --git a/server/src/nest/common/body-contract-allow-list.ts b/server/src/nest/common/body-contract-allow-list.ts index ec745e914..188c15fac 100644 --- a/server/src/nest/common/body-contract-allow-list.ts +++ b/server/src/nest/common/body-contract-allow-list.ts @@ -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', ]; diff --git a/server/src/nest/vacay/vacay.controller.ts b/server/src/nest/vacay/vacay.controller.ts index 70d901d61..a5103f118 100644 --- a/server/src/nest/vacay/vacay.controller.ts +++ b/server/src/nest/vacay/vacay.controller.ts @@ -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, @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, + @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!); } diff --git a/server/src/nest/vacay/vacay.dto.ts b/server/src/nest/vacay/vacay.dto.ts index 3d0a6cc13..64e4d43ae 100644 --- a/server/src/nest/vacay/vacay.dto.ts +++ b/server/src/nest/vacay/vacay.dto.ts @@ -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) {} diff --git a/server/src/nest/vacay/vacay.service.ts b/server/src/nest/vacay/vacay.service.ts index cf3a37d4b..e9ad32a4f 100644 --- a/server/src/nest/vacay/vacay.service.ts +++ b/server/src/nest/vacay/vacay.service.ts @@ -2,6 +2,10 @@ import { Injectable } from '@nestjs/common'; import * as svc from '../../services/vacayService'; type UpdatePlanBody = Parameters[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 & { 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') { diff --git a/server/tests/e2e/vacay.e2e.test.ts b/server/tests/e2e/vacay.e2e.test.ts index 0414a1d40..02426cb8c 100644 --- a/server/tests/e2e/vacay.e2e.test.ts +++ b/server/tests/e2e/vacay.e2e.test.ts @@ -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 () => { diff --git a/server/tests/unit/nest/vacay.controller.test.ts b/server/tests/unit/nest/vacay.controller.test.ts index 7336923ac..e366ed205 100644 --- a/server/tests/unit/nest/vacay.controller.test.ts +++ b/server/tests/unit/nest/vacay.controller.test.ts @@ -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'); }); diff --git a/shared/src/vacay/vacay.schema.spec.ts b/shared/src/vacay/vacay.schema.spec.ts index b3c1de229..420396357 100644 --- a/shared/src/vacay/vacay.schema.spec.ts +++ b/shared/src/vacay/vacay.schema.spec.ts @@ -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); + }); +}); diff --git a/shared/src/vacay/vacay.schema.ts b/shared/src/vacay/vacay.schema.ts index f9c30fd02..7111065a0 100644 --- a/shared/src/vacay/vacay.schema.ts +++ b/shared/src/vacay/vacay.schema.ts @@ -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; + 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; +// 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; + export const vacaySetColorRequestSchema = z.object({ color: z.string().optional(), target_user_id: z.union([z.number(), z.string()]).optional(),