Files
TREK/server/src/nest/vacay/vacay.controller.ts
T
Elmer 7bd9e08016 feat(vacay): add school holiday overlays (#1633)
* feat: add vacay school holiday overlays

* fix(vacay): remove SOH control character corruption in locale files

13 of 22 vacay locale files contained a 0x01 (SOH) control character
that corrupted the closing }; token. This broke the shared package
build (tsdown/rolldown PARSE_ERROR: Invalid Character) and cascaded
to all client tests failing to resolve @trek/shared.

Affected locales: ar, br, ca, cs, es, fr, gr, hu, id, it, ja, ko,
nl, pl, ru, sv, tr, uk, vi, zh, zh-TW

* fix(vacay): school-holiday migration order, i18n dupes, settings layout

- Move the school-holiday columns migration to the end of the migrations
  array. It sat before the #1435/#1281 migrations, which every existing DB
  had already run, so the runner skipped it and school_holidays_enabled /
  vacay_holiday_calendars.type were never created — PUT /addons/vacay/plan
  then 500'd with "no such column".
- Remove duplicate i18n keys in 13 locale vacay files (TS1117) that failed
  the shared + client typechecks.
- Move the school-holidays block into the right column, directly under
  public holidays, instead of full-width below everything.
- Add the school-holidays section to the mobile Vacay settings sheet.

* feat(vacay): modern school-holiday day styling + combined hover tooltip

- Replace the stacked 2px underlines with a single rounded accent band and a
  soft background wash on plain school-break days (desktop + mobile — mobile
  did not render school holidays at all before).
- Fold school holidays into the custom day tooltip under a divider instead of a
  separate native title, and show the tooltip on every set day (whole vacation
  days too), not only half days / shared calendars.

---------

Co-authored-by: Otto <uif86164@automotive-wan.com>
Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-07-23 22:48:42 +02:00

348 lines
12 KiB
TypeScript

import {
Body,
Controller,
Delete,
Get,
Headers,
HttpCode,
HttpException,
Param,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
import { vacayShareUpdateRequestSchema, type VacayShareUpdateRequest } from '@trek/shared';
import type { User } from '../../types';
import { VacayService } from './vacay.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
/**
* /api/addons/vacay — shared vacation-day planner.
*
* 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.
*/
@Controller('api/addons/vacay')
@UseGuards(JwtAuthGuard)
export class VacayController {
constructor(private readonly vacay: VacayService) {}
@Get('plan')
getPlan(@CurrentUser() user: User) {
return this.vacay.getPlanData(user.id);
}
@Put('plan')
async updatePlan(@CurrentUser() user: User, @Body() body: Record<string, unknown>, @Headers('x-socket-id') socketId?: string) {
const planId = this.vacay.getActivePlanId(user.id);
return this.vacay.updatePlan(planId, body, socketId);
}
@Post('plan/holiday-calendars')
@HttpCode(200)
addHolidayCalendar(
@CurrentUser() user: User,
@Body() body: { region?: string; label?: string | null; color?: string; sort_order?: number; type?: 'public_holiday' | 'school_holiday' },
@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 };
}
@Put('plan/holiday-calendars/:id')
updateHolidayCalendar(
@CurrentUser() user: User,
@Param('id') idParam: string,
@Body() body: Record<string, unknown>,
@Headers('x-socket-id') socketId?: string,
) {
const id = parseInt(idParam);
const planId = this.vacay.getActivePlanId(user.id);
const calendar = this.vacay.updateHolidayCalendar(id, planId, body, socketId);
if (!calendar) {
throw new HttpException({ error: 'Calendar not found' }, 404);
}
return { calendar };
}
@Delete('plan/holiday-calendars/:id')
deleteHolidayCalendar(@CurrentUser() user: User, @Param('id') idParam: string, @Headers('x-socket-id') socketId?: string) {
const id = parseInt(idParam);
const planId = this.vacay.getActivePlanId(user.id);
if (!this.vacay.deleteHolidayCalendar(id, planId, socketId)) {
throw new HttpException({ error: 'Calendar not found' }, 404);
}
return { success: true };
}
@Put('color')
setColor(
@CurrentUser() user: User,
@Body() body: { color?: string; target_user_id?: number | string },
@Headers('x-socket-id') socketId?: string,
) {
const planId = this.vacay.getActivePlanId(user.id);
const userId = body.target_user_id ? parseInt(String(body.target_user_id)) : user.id;
if (!this.vacay.getPlanUsers(planId).find((u) => u.id === userId)) {
throw new HttpException({ error: 'User not in plan' }, 403);
}
this.vacay.setUserColor(userId, planId, body.color, socketId);
return { success: true };
}
@Post('invite')
@HttpCode(200)
invite(@CurrentUser() user: User, @Body('user_id') userIdInput?: number | string) {
if (!userIdInput) {
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);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
return { success: true };
}
@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);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
return { success: true };
}
@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);
return { success: true };
}
@Post('invite/cancel')
@HttpCode(200)
cancelInvite(@CurrentUser() user: User, @Body('user_id') targetUserId: number) {
const plan = this.vacay.getActivePlan(user.id);
this.vacay.cancelInvite(plan.id, targetUserId);
return { success: true };
}
@Post('dissolve')
@HttpCode(200)
dissolve(@CurrentUser() user: User, @Headers('x-socket-id') socketId?: string) {
this.vacay.dissolvePlan(user.id, socketId);
return { success: true };
}
@Get('available-users')
availableUsers(@CurrentUser() user: User) {
const planId = this.vacay.getActivePlanId(user.id);
return { users: this.vacay.getAvailableUsers(user.id, planId) };
}
@Get('years')
years(@CurrentUser() user: User) {
const planId = this.vacay.getActivePlanId(user.id);
return { years: this.vacay.listYears(planId) };
}
@Post('years')
@HttpCode(200)
addYear(@CurrentUser() user: User, @Body('year') year?: number, @Headers('x-socket-id') socketId?: string) {
if (!year) {
throw new HttpException({ error: 'Year required' }, 400);
}
const planId = this.vacay.getActivePlanId(user.id);
return { years: this.vacay.addYear(planId, year, socketId) };
}
@Delete('years/:year')
deleteYear(@CurrentUser() user: User, @Param('year') yearParam: string, @Headers('x-socket-id') socketId?: string) {
const year = parseInt(yearParam);
const planId = this.vacay.getActivePlanId(user.id);
return { years: this.vacay.deleteYear(planId, year, socketId) };
}
@Get('entries/:year')
entries(@CurrentUser() user: User, @Param('year') year: string) {
const planId = this.vacay.getActivePlanId(user.id);
return this.vacay.getEntries(planId, year);
}
@Post('entries/toggle')
@HttpCode(200)
toggleEntry(
@CurrentUser() user: User,
@Body() body: { date?: string; target_user_id?: number | string; fraction?: number },
@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) {
const tid = parseInt(String(body.target_user_id));
if (!this.vacay.getPlanUsers(planId).find((u) => u.id === tid)) {
throw new HttpException({ error: 'User not in plan' }, 403);
}
userId = tid;
}
return this.vacay.toggleEntry(userId, planId, body.date, body.fraction, socketId);
}
@Post('entries/company-holiday')
@HttpCode(200)
companyHoliday(
@CurrentUser() user: User,
@Body() body: { date?: string; note?: string },
@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);
}
@Get('stats/:year')
stats(@CurrentUser() user: User, @Param('year') yearParam: string) {
const year = parseInt(yearParam);
const planId = this.vacay.getActivePlanId(user.id);
return { stats: this.vacay.getStats(planId, year) };
}
@Put('stats/:year')
updateStats(
@CurrentUser() user: User,
@Param('year') yearParam: string,
@Body() body: { vacation_days?: number; target_user_id?: number | string },
@Headers('x-socket-id') socketId?: string,
) {
const year = parseInt(yearParam);
const planId = this.vacay.getActivePlanId(user.id);
const userId = body.target_user_id ? parseInt(String(body.target_user_id)) : user.id;
if (!this.vacay.getPlanUsers(planId).find((u) => u.id === userId)) {
throw new HttpException({ error: 'User not in plan' }, 403);
}
this.vacay.updateStats(userId, planId, year, body.vacation_days as number, socketId);
return { success: true };
}
@Get('shares')
shares(@CurrentUser() user: User) {
return this.vacay.listShares(user.id);
}
@Post('shares')
@HttpCode(200)
share(
@CurrentUser() user: User,
@Body('user_id') userIdInput?: number | string,
@Headers('x-socket-id') socketId?: string,
) {
if (!userIdInput) {
throw new HttpException({ error: 'user_id required' }, 400);
}
const result = this.vacay.shareCalendar(user.id, user.email, parseInt(String(userIdInput)), socketId);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
return { success: true };
}
@Get('shares/available-users')
shareAvailableUsers(@CurrentUser() user: User) {
return { users: this.vacay.getShareAvailableUsers(user.id) };
}
@Get('shares/calendars/:year')
sharedCalendars(@CurrentUser() user: User, @Param('year') year: string) {
return { calendars: this.vacay.getSharedCalendars(user.id, year) };
}
@Put('shares/:id')
updateShare(
@CurrentUser() user: User,
@Param('id') idParam: string,
@Body(new ZodValidationPipe(vacayShareUpdateRequestSchema)) body: VacayShareUpdateRequest,
@Headers('x-socket-id') socketId?: string,
) {
if (!this.vacay.setShareHidden(parseInt(idParam), user.id, body.hidden, socketId)) {
throw new HttpException({ error: 'Share not found' }, 404);
}
return { success: true };
}
@Delete('shares/:id')
deleteShare(@CurrentUser() user: User, @Param('id') idParam: string, @Headers('x-socket-id') socketId?: string) {
if (!this.vacay.removeShare(parseInt(idParam), user.id, socketId)) {
throw new HttpException({ error: 'Share not found' }, 404);
}
return { success: true };
}
@Get('holidays/countries')
async holidayCountries() {
const result = await this.vacay.getCountries();
if (result.error) {
throw new HttpException({ error: result.error }, 502);
}
return result.data;
}
@Get('holidays/:year/:country')
async holidays(@Param('year') year: string, @Param('country') country: string) {
const result = await this.vacay.getHolidays(year, country);
if (result.error) {
throw new HttpException({ error: result.error }, 502);
}
return result.data;
}
@Get('school-holidays/regions/:country')
async schoolHolidayRegions(@Param('country') country: string) {
const result = await this.vacay.getSchoolHolidayRegions(country, country.toUpperCase() === 'DE' ? 'DE' : 'EN');
if (result.error) {
throw new HttpException({ error: result.error }, 502);
}
return result.data;
}
@Get('school-holidays/:year/:country')
async schoolHolidaysForCountry(
@Param('year') year: string,
@Param('country') country: string,
@Query('group') group?: string,
) {
return this.schoolHolidays(year, country, undefined, group);
}
@Get('school-holidays/:year/:country/:subdivision')
async schoolHolidaysForSubdivision(
@Param('year') year: string,
@Param('country') country: string,
@Param('subdivision') subdivision: string,
@Query('group') group?: string,
) {
return this.schoolHolidays(year, country, subdivision, group);
}
private async schoolHolidays(year: string, country: string, subdivision?: string, group?: string) {
const result = await this.vacay.getSchoolHolidays(year, country, subdivision, country.toUpperCase() === 'DE' ? 'DE' : 'EN', group);
if (result.error) {
throw new HttpException({ error: result.error }, 502);
}
return result.data;
}
}