mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
7bd9e08016
* 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>
348 lines
12 KiB
TypeScript
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;
|
|
}
|
|
}
|