mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user