mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(vacay): add manual school holiday calendars
This commit is contained in:
@@ -4534,6 +4534,26 @@ function runMigrations(db: Database.Database): void {
|
||||
db.exec('ALTER TABLE places ADD COLUMN fill_percent INTEGER');
|
||||
}
|
||||
},
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS school_holiday_countries (
|
||||
code TEXT PRIMARY KEY, name TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS school_holiday_regions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
country TEXT NOT NULL REFERENCES school_holiday_countries(code),
|
||||
name TEXT NOT NULL COLLATE NOCASE, revision INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE(country, name)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS school_holiday_periods (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
region_id INTEGER NOT NULL REFERENCES school_holiday_regions(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL, start_date TEXT NOT NULL, end_date TEXT NOT NULL,
|
||||
CHECK (end_date >= start_date)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_school_holiday_periods_region ON school_holiday_periods(region_id);
|
||||
`);
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -27,6 +27,7 @@ import { CategoriesModule } from './categories/categories.module';
|
||||
import { TagsModule } from './tags/tags.module';
|
||||
import { NotificationsModule } from './notifications/notifications.module';
|
||||
import { AtlasModule } from './atlas/atlas.module';
|
||||
import { SchoolHolidaysModule } from './school-holidays/school-holidays.module';
|
||||
import { VacayModule } from './vacay/vacay.module';
|
||||
import { PackingModule } from './packing/packing.module';
|
||||
import { BudgetModule } from './budget/budget.module';
|
||||
@@ -79,7 +80,7 @@ import { RealtimeGatewayModule } from './realtime/realtime-gateway.module';
|
||||
* migrated.
|
||||
*/
|
||||
@Module({
|
||||
imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, RouteUsageModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule],
|
||||
imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, RouteUsageModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, SchoolHolidaysModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule],
|
||||
providers: [
|
||||
// Default-deny: a route is authenticated unless it carries @Public() or
|
||||
// @OptionalAuth(), or declares its own @UseGuards chain. Protection used to
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, Query, UseGuards, BadRequestException } from '@nestjs/common';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { schoolHolidayCountryRequestSchema, schoolHolidayRegionRequestSchema } from '@trek/shared';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { AdminGuard } from '../auth/admin.guard';
|
||||
import { SchoolHolidaysService } from './school-holidays.service';
|
||||
|
||||
export class SchoolHolidayCountryDto extends createZodDto(schoolHolidayCountryRequestSchema) {}
|
||||
export class SchoolHolidayRegionDto extends createZodDto(schoolHolidayRegionRequestSchema) {}
|
||||
|
||||
@Controller('api/school-holiday-catalog')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SchoolHolidaysController {
|
||||
constructor(private readonly holidays: SchoolHolidaysService) {}
|
||||
|
||||
@Get()
|
||||
catalog() { return this.holidays.catalog(); }
|
||||
|
||||
@Get('regions/:id')
|
||||
region(@Param('id', ParseIntPipe) id: number) { return this.holidays.region(id); }
|
||||
|
||||
@Get('regions/:id/holidays/:year')
|
||||
forYear(@Param('id', ParseIntPipe) id: number, @Param('year') year: string) {
|
||||
if (!/^\d{4}$/.test(year)) throw new BadRequestException('Invalid year');
|
||||
return this.holidays.holidays(id, year);
|
||||
}
|
||||
|
||||
@Post('countries')
|
||||
@UseGuards(AdminGuard)
|
||||
createCountry(@Body() body: SchoolHolidayCountryDto) { return this.holidays.createCountry(body); }
|
||||
|
||||
@Delete('countries/:code')
|
||||
@UseGuards(AdminGuard)
|
||||
deleteCountry(@Param('code') code: string) { return this.holidays.deleteCountry(code); }
|
||||
|
||||
@Post('countries/:code/regions')
|
||||
@UseGuards(AdminGuard)
|
||||
createRegion(@Param('code') code: string, @Body() body: SchoolHolidayRegionDto) { return this.holidays.createRegion(code, body); }
|
||||
|
||||
@Put('regions/:id')
|
||||
@UseGuards(AdminGuard)
|
||||
updateRegion(@Param('id', ParseIntPipe) id: number, @Body() body: SchoolHolidayRegionDto) { return this.holidays.updateRegion(id, body); }
|
||||
|
||||
@Delete('regions/:id')
|
||||
@UseGuards(AdminGuard)
|
||||
deleteRegion(@Param('id', ParseIntPipe) id: number, @Query('revision', ParseIntPipe) revision: number) { return this.holidays.deleteRegion(id, revision); }
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
import { idSchema, schoolHolidayCountryRequestSchema, schoolHolidayRegionRequestSchema, type SchoolHolidayCountryRequest, type SchoolHolidayRegionRequest } from '@trek/shared';
|
||||
import { McpController, Tool, type McpContext, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_NON_IDEMPOTENT, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, errorResult, ok } from '../../nest-mcp';
|
||||
import { adminRequired } from '../../mcp/tools/_shared';
|
||||
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
|
||||
import { SchoolHolidaysService } from './school-holidays.service';
|
||||
|
||||
@McpController()
|
||||
export class SchoolHolidaysMcp {
|
||||
constructor(private readonly holidays: SchoolHolidaysService, private readonly guards: McpToolGuardsService) {}
|
||||
|
||||
private adminWrite(ctx: McpContext, write: () => unknown) {
|
||||
if (!this.guards.isAdminUser(ctx.userId)) return adminRequired();
|
||||
try { return ok(write()); }
|
||||
catch (error) {
|
||||
if (error instanceof HttpException || error instanceof z.ZodError) return errorResult(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'list_manual_school_holiday_regions',
|
||||
description: 'List the global manual school holiday catalog. Use regions[].code verbatim as the region with add_holiday_calendar and type school_holiday. These calendars require no external API.',
|
||||
inputSchema: {}, annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'vacay', mode: 'read' },
|
||||
})
|
||||
catalog() { return ok(this.holidays.catalog()); }
|
||||
|
||||
@Tool({
|
||||
name: 'get_manual_school_holiday_region',
|
||||
description: 'Read a manual school region, its current revision and all named holiday periods. Read before updating: updates replace the complete list of periods, and the current revision is required.',
|
||||
inputSchema: { regionId: idSchema }, annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'vacay', mode: 'read' },
|
||||
})
|
||||
region({ regionId }: { regionId: number }) { return ok(this.holidays.region(regionId)); }
|
||||
|
||||
@Tool({
|
||||
name: 'list_manual_school_holidays',
|
||||
description: 'Read named school breaks for a manual region and year, including breaks spanning a year boundary. Both boundary dates are included.',
|
||||
inputSchema: { regionId: idSchema, year: z.number().int().min(1000).max(9999) },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'vacay', mode: 'read' },
|
||||
})
|
||||
forYear({ regionId, year }: { regionId: number; year: number }) { return ok({ holidays: this.holidays.holidays(regionId, String(year)) }); }
|
||||
|
||||
@Tool({
|
||||
name: 'create_manual_school_holiday_country',
|
||||
description: 'Admin only. Create a country in the instance-wide manual school holiday catalog with its two-letter uppercase country code and display name. Check the catalog first to avoid duplicates.',
|
||||
inputSchema: schoolHolidayCountryRequestSchema.shape, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
access: { group: 'vacay', mode: 'write' },
|
||||
})
|
||||
createCountry(body: SchoolHolidayCountryRequest, ctx: McpContext) {
|
||||
return this.adminWrite(ctx, () => this.holidays.createCountry(schoolHolidayCountryRequestSchema.parse(body)));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'create_manual_school_holiday_region',
|
||||
description: 'Admin only. Create a region or school district in an existing manual country, with named holiday periods. Use revision 0 for creation. Dates are inclusive YYYY-MM-DD. All users can select this region.',
|
||||
inputSchema: { country: schoolHolidayCountryRequestSchema.shape.code, ...schoolHolidayRegionRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, access: { group: 'vacay', mode: 'write' },
|
||||
})
|
||||
createRegion({ country, ...body }: SchoolHolidayRegionRequest & { country: string }, ctx: McpContext) {
|
||||
return this.adminWrite(ctx, () => this.holidays.createRegion(country, schoolHolidayRegionRequestSchema.parse(body)));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'update_manual_school_holiday_region',
|
||||
description: 'Admin only. Rename a global school region and replace its complete list of holiday periods. First read get_manual_school_holiday_region and preserve periods that should remain. Supply its current revision; stale updates are rejected. Changes affect every calendar using this region.',
|
||||
inputSchema: { regionId: idSchema, ...schoolHolidayRegionRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE, access: { group: 'vacay', mode: 'write' },
|
||||
})
|
||||
updateRegion({ regionId, ...body }: SchoolHolidayRegionRequest & { regionId: number }, ctx: McpContext) {
|
||||
return this.adminWrite(ctx, () => this.holidays.updateRegion(regionId, schoolHolidayRegionRequestSchema.parse(body)));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'delete_manual_school_holiday_region',
|
||||
description: 'Admin only. Delete a global manual school region and its periods using the current revision. Regions selected by a vacation calendar cannot be deleted.',
|
||||
inputSchema: { regionId: idSchema, revision: z.number().int().positive() },
|
||||
annotations: TOOL_ANNOTATIONS_DELETE, access: { group: 'vacay', mode: 'write' },
|
||||
})
|
||||
deleteRegion({ regionId, revision }: { regionId: number; revision: number }, ctx: McpContext) {
|
||||
return this.adminWrite(ctx, () => this.holidays.deleteRegion(regionId, revision));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'delete_manual_school_holiday_country',
|
||||
description: 'Admin only. Delete an empty country from the global manual school holiday catalog. Remove its unused regions first.',
|
||||
inputSchema: { code: schoolHolidayCountryRequestSchema.shape.code },
|
||||
annotations: TOOL_ANNOTATIONS_DELETE, access: { group: 'vacay', mode: 'write' },
|
||||
})
|
||||
deleteCountry({ code }: { code: string }, ctx: McpContext) {
|
||||
return this.adminWrite(ctx, () => this.holidays.deleteCountry(code));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SchoolHolidaysController } from './school-holidays.controller';
|
||||
import { SchoolHolidaysService } from './school-holidays.service';
|
||||
import { SchoolHolidaysMcp } from './school-holidays.mcp';
|
||||
|
||||
@Module({ imports: [McpSharedModule], controllers: [SchoolHolidaysController], providers: [SchoolHolidaysService, SchoolHolidaysMcp] })
|
||||
export class SchoolHolidaysModule {}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type { SchoolHolidayCatalog, SchoolHolidayCountryRequest, SchoolHolidayPeriod, SchoolHolidayRegion, SchoolHolidayRegionDetail, SchoolHolidayRegionRequest } from '@trek/shared';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
|
||||
@Injectable()
|
||||
export class SchoolHolidaysService {
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
|
||||
catalog(): SchoolHolidayCatalog {
|
||||
return {
|
||||
countries: this.db.all<SchoolHolidayCountryRequest>('SELECT code, name FROM school_holiday_countries ORDER BY name, code'),
|
||||
regions: this.db.all<SchoolHolidayRegion>("SELECT *, country || '-MANUAL-' || id AS code FROM school_holiday_regions ORDER BY name, id"),
|
||||
};
|
||||
}
|
||||
|
||||
country(code: string): SchoolHolidayCountryRequest {
|
||||
const country = this.db.get<SchoolHolidayCountryRequest>('SELECT code, name FROM school_holiday_countries WHERE code = ?', code);
|
||||
if (!country) throw new NotFoundException('Country not found');
|
||||
return country;
|
||||
}
|
||||
|
||||
createCountry(country: SchoolHolidayCountryRequest) {
|
||||
const inserted = this.db.run('INSERT OR IGNORE INTO school_holiday_countries (code, name) VALUES (?, ?)', country.code, country.name);
|
||||
if (!inserted.changes) throw new ConflictException('Country already exists');
|
||||
return country;
|
||||
}
|
||||
|
||||
deleteCountry(code: string) {
|
||||
return this.db.transaction(() => {
|
||||
this.country(code);
|
||||
if (this.db.get('SELECT id FROM school_holiday_regions WHERE country = ? LIMIT 1', code)) {
|
||||
throw new ConflictException('Remove the regions before deleting this country');
|
||||
}
|
||||
this.db.run('DELETE FROM school_holiday_countries WHERE code = ?', code);
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
|
||||
region(id: number): SchoolHolidayRegionDetail {
|
||||
const region = this.db.get<SchoolHolidayRegion>("SELECT *, country || '-MANUAL-' || id AS code FROM school_holiday_regions WHERE id = ?", id);
|
||||
if (!region) throw new NotFoundException('Region not found');
|
||||
return { ...region, holidays: this.db.all<SchoolHolidayPeriod>('SELECT name, start_date AS startDate, end_date AS endDate FROM school_holiday_periods WHERE region_id = ? ORDER BY start_date, end_date, name', id) };
|
||||
}
|
||||
|
||||
private checkName(country: string, name: string, id: number) {
|
||||
if (this.db.get('SELECT id FROM school_holiday_regions WHERE country = ? AND name = ? COLLATE NOCASE AND id != ?', country, name, id)) {
|
||||
throw new ConflictException('A region with this name already exists');
|
||||
}
|
||||
}
|
||||
|
||||
private writePeriods(id: number, holidays: SchoolHolidayPeriod[]) {
|
||||
this.db.run('DELETE FROM school_holiday_periods WHERE region_id = ?', id);
|
||||
const insert = this.db.prepare('INSERT INTO school_holiday_periods (region_id, name, start_date, end_date) VALUES (?, ?, ?, ?)');
|
||||
for (const holiday of holidays) insert.run(id, holiday.name, holiday.startDate, holiday.endDate);
|
||||
}
|
||||
|
||||
createRegion(country: string, body: SchoolHolidayRegionRequest) {
|
||||
return this.db.transaction(() => {
|
||||
this.country(country);
|
||||
this.checkName(country, body.name, 0);
|
||||
if (body.revision !== 0) throw new ConflictException('New regions must have revision zero');
|
||||
const inserted = this.db.run('INSERT INTO school_holiday_regions (country, name) VALUES (?, ?)', country, body.name);
|
||||
const id = Number(inserted.lastInsertRowid);
|
||||
this.writePeriods(id, body.holidays);
|
||||
return this.region(id);
|
||||
});
|
||||
}
|
||||
|
||||
updateRegion(id: number, body: SchoolHolidayRegionRequest) {
|
||||
return this.db.transaction(() => {
|
||||
const region = this.region(id);
|
||||
this.checkName(region.country, body.name, id);
|
||||
const updated = this.db.run('UPDATE school_holiday_regions SET name = ?, revision = revision + 1 WHERE id = ? AND revision = ?', body.name, id, body.revision);
|
||||
if (!updated.changes) throw new ConflictException('This region changed. Reopen it before saving again.');
|
||||
this.writePeriods(id, body.holidays);
|
||||
return this.region(id);
|
||||
});
|
||||
}
|
||||
|
||||
deleteRegion(id: number, revision: number) {
|
||||
return this.db.transaction(() => {
|
||||
const region = this.region(id);
|
||||
if (region.revision !== revision) throw new ConflictException('This region changed. Reload before deleting it.');
|
||||
if (this.db.get("SELECT id FROM vacay_holiday_calendars WHERE type = 'school_holiday' AND region = ? LIMIT 1", region.code)) {
|
||||
throw new ConflictException('This region is used by vacation calendars and cannot be deleted');
|
||||
}
|
||||
this.db.run('DELETE FROM school_holiday_periods WHERE region_id = ?', id);
|
||||
this.db.run('DELETE FROM school_holiday_regions WHERE id = ?', id);
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
|
||||
holidays(id: number, year: string): SchoolHolidayPeriod[] {
|
||||
return this.region(id).holidays.filter(holiday => holiday.startDate <= `${year}-12-31` && holiday.endDate >= `${year}-01-01`);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { manualSchoolRegionId } from '@trek/shared';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
@@ -578,6 +579,7 @@ export class VacayService {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
addHolidayCalendar(planId: number, region: string, label: string | null, color: string | undefined, sortOrder: number | undefined, socketId: string | undefined, type: 'public_holiday' | 'school_holiday' = 'public_holiday') {
|
||||
this.validateManualRegion(region, type);
|
||||
const result = this.db.run(
|
||||
'INSERT INTO vacay_holiday_calendars (plan_id, type, region, label, color, sort_order) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
planId, type, region, label || null, color || (type === 'school_holiday' ? '#a5f3fc' : '#fecaca'), sortOrder ?? 0
|
||||
@@ -595,6 +597,7 @@ export class VacayService {
|
||||
): VacayHolidayCalendar | null {
|
||||
const cal = this.db.get<VacayHolidayCalendar>('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?', calId, planId);
|
||||
if (!cal) return null;
|
||||
this.validateManualRegion(body.region ?? cal.region, body.type ?? cal.type);
|
||||
const { region, label, color, sort_order, type } = body;
|
||||
const updates: string[] = [];
|
||||
const params: (string | number | null)[] = [];
|
||||
@@ -620,6 +623,14 @@ export class VacayService {
|
||||
return true;
|
||||
}
|
||||
|
||||
private validateManualRegion(code: string, type: string) {
|
||||
if (!code.includes('-MANUAL-')) return;
|
||||
const id = manualSchoolRegionId(code);
|
||||
if (type !== 'school_holiday' || !id || !this.db.get('SELECT id FROM school_holiday_regions WHERE id = ? AND country = ?', id, code.slice(0, 2))) {
|
||||
throw new BadRequestException('Unknown manual school holiday region');
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// User colors
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { beforeAll, afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
||||
import { sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
return { db: new Database(':memory:') };
|
||||
});
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {} }));
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { DatabaseModule } from '../../src/nest/database/database.module';
|
||||
import { SchoolHolidaysModule } from '../../src/nest/school-holidays/school-holidays.module';
|
||||
import { SchoolHolidaysService } from '../../src/nest/school-holidays/school-holidays.service';
|
||||
import { SchoolHolidaysMcp } from '../../src/nest/school-holidays/school-holidays.mcp';
|
||||
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { validateBodyContracts } from '../../src/nest/common/validate-body-contracts';
|
||||
import { createTestRegistry } from '../../src/nest-mcp';
|
||||
import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../src/mcp/nest-mcp-policy';
|
||||
|
||||
const base = '/api/school-holiday-catalog';
|
||||
const winter = { name: 'Winter break', startDate: '2026-12-20', endDate: '2027-01-06' };
|
||||
let app: INestApplication;
|
||||
let service: SchoolHolidaysService;
|
||||
beforeAll(async () => {
|
||||
db.pragma('foreign_keys = ON');
|
||||
createTables(db);
|
||||
runMigrations(db);
|
||||
db.prepare("INSERT INTO users (id, username, email, password_hash, role) VALUES (1, 'admin', 'admin@test.local', '', 'admin'), (2, 'member', 'member@test.local', '', 'user')").run();
|
||||
const module = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, SchoolHolidaysModule] }).compile();
|
||||
app = module.createNestApplication();
|
||||
app.use(cookieParser());
|
||||
app.useGlobalPipes(new ZodValidationPipe());
|
||||
app.useGlobalFilters(new TrekExceptionFilter());
|
||||
await app.init();
|
||||
service = app.get(SchoolHolidaysService);
|
||||
});
|
||||
afterAll(async () => { await app.close(); db.close(); });
|
||||
beforeEach(() => {
|
||||
db.exec('DELETE FROM vacay_holiday_calendars; DELETE FROM school_holiday_periods; DELETE FROM school_holiday_regions; DELETE FROM school_holiday_countries;');
|
||||
});
|
||||
|
||||
function seed() {
|
||||
service.createCountry({ code: 'US', name: 'USA' });
|
||||
return service.createRegion('US', { name: 'Seattle schools', revision: 0, holidays: [winter] });
|
||||
}
|
||||
|
||||
describe('global manual school holidays', () => {
|
||||
it('requires authentication and admin rights for every write', async () => {
|
||||
await request(app.getHttpServer()).get(base).expect(401);
|
||||
await request(app.getHttpServer()).post(`${base}/countries`).send({ code: 'US', name: 'USA' }).expect(401);
|
||||
for (const [method, path, body] of [
|
||||
['post', '/countries', { code: 'US', name: 'USA' }],
|
||||
['post', '/countries/US/regions', { name: 'District', revision: 0, holidays: [] }],
|
||||
['put', '/regions/1', { name: 'District', revision: 1, holidays: [] }],
|
||||
['delete', '/countries/US', {}], ['delete', '/regions/1?revision=1', {}],
|
||||
] as const) {
|
||||
await request(app.getHttpServer())[method](`${base}${path}`).set('Cookie', sessionCookie(2)).send(body).expect(403);
|
||||
}
|
||||
expect(() => validateBodyContracts(app)).not.toThrow();
|
||||
});
|
||||
|
||||
it('lets any member discover regions and read dates without external requests', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
const country = await request(app.getHttpServer()).post(`${base}/countries`).set('Cookie', sessionCookie(1)).send({ code: 'US', name: 'USA' }).expect(201);
|
||||
expect(country.body.code).toBe('US');
|
||||
const created = await request(app.getHttpServer()).post(`${base}/countries/US/regions`).set('Cookie', sessionCookie(1)).send({ name: 'Seattle', revision: 0, holidays: [winter] }).expect(201);
|
||||
const catalog = await request(app.getHttpServer()).get(base).set('Cookie', sessionCookie(2)).expect(200);
|
||||
expect(catalog.body.regions[0].code).toBe(`US-MANUAL-${created.body.id}`);
|
||||
for (const year of [2026, 2027]) {
|
||||
const holidays = await request(app.getHttpServer()).get(`${base}/regions/${created.body.id}/holidays/${year}`).set('Cookie', sessionCookie(2)).expect(200);
|
||||
expect(holidays.body).toEqual([winter]);
|
||||
}
|
||||
expect(service.holidays(created.body.id, '2025')).toEqual([]);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('validates dates and body shape before writing', async () => {
|
||||
service.createCountry({ code: 'US', name: 'USA' });
|
||||
for (const holidays of [[{ ...winter, endDate: '2026-01-01' }], [{ ...winter, startDate: '2026-02-30' }], [{ ...winter, name: ' ' }]]) {
|
||||
await request(app.getHttpServer()).post(`${base}/countries/US/regions`).set('Cookie', sessionCookie(1)).send({ name: 'Seattle', revision: 0, holidays }).expect(400);
|
||||
}
|
||||
expect(service.catalog().regions).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves ids on rename and rejects stale saves atomically', async () => {
|
||||
const region = seed();
|
||||
const updated = await request(app.getHttpServer()).put(`${base}/regions/${region.id}`).set('Cookie', sessionCookie(1)).send({ name: 'Renamed', revision: 1, holidays: [] }).expect(200);
|
||||
expect(updated.body).toMatchObject({ code: region.code, revision: 2, holidays: [] });
|
||||
await request(app.getHttpServer()).put(`${base}/regions/${region.id}`).set('Cookie', sessionCookie(1)).send({ name: 'Stale', revision: 1, holidays: [winter] }).expect(409);
|
||||
expect(service.region(region.id).name).toBe('Renamed');
|
||||
expect(service.region(region.id).holidays).toEqual([]);
|
||||
await request(app.getHttpServer()).delete(`${base}/regions/${region.id}?revision=1`).set('Cookie', sessionCookie(1)).expect(409);
|
||||
});
|
||||
|
||||
it('rejects duplicates, missing countries and nonzero initial revisions', () => {
|
||||
seed();
|
||||
expect(() => service.createCountry({ code: 'US', name: 'America' })).toThrow('already exists');
|
||||
expect(() => service.createRegion('US', { name: 'SEATTLE SCHOOLS', revision: 0, holidays: [] })).toThrow('already exists');
|
||||
expect(() => service.createRegion('CA', { name: 'School', revision: 0, holidays: [] })).toThrow('not found');
|
||||
expect(() => service.createRegion('US', { name: 'School', revision: 1, holidays: [] })).toThrow('revision zero');
|
||||
const other = service.createRegion('US', { name: 'Other', revision: 0, holidays: [] });
|
||||
expect(() => service.updateRegion(other.id, { name: 'Seattle schools', revision: 1, holidays: [] })).toThrow('already exists');
|
||||
});
|
||||
|
||||
it('protects used regions and deletes unused regions with their periods', async () => {
|
||||
const region = seed();
|
||||
db.prepare('INSERT OR IGNORE INTO vacay_plans (id, owner_id) VALUES (1, 1)').run();
|
||||
db.prepare("INSERT INTO vacay_holiday_calendars (plan_id, type, region) VALUES (1, 'school_holiday', ?)").run(region.code);
|
||||
await request(app.getHttpServer()).delete(`${base}/regions/${region.id}?revision=1`).set('Cookie', sessionCookie(1)).expect(409);
|
||||
await request(app.getHttpServer()).delete(`${base}/countries/US`).set('Cookie', sessionCookie(1)).expect(409);
|
||||
db.exec('DELETE FROM vacay_holiday_calendars');
|
||||
await request(app.getHttpServer()).delete(`${base}/regions/${region.id}?revision=1`).set('Cookie', sessionCookie(1)).expect(200);
|
||||
expect(db.prepare('SELECT * FROM school_holiday_periods').all()).toEqual([]);
|
||||
await request(app.getHttpServer()).delete(`${base}/countries/US`).set('Cookie', sessionCookie(1)).expect(200);
|
||||
expect(service.catalog()).toEqual({ countries: [], regions: [] });
|
||||
});
|
||||
|
||||
it('reports missing regions and malformed year and id parameters', async () => {
|
||||
const region = seed();
|
||||
await request(app.getHttpServer()).get(`${base}/regions/${region.id}`).set('Cookie', sessionCookie(2)).expect(200);
|
||||
await request(app.getHttpServer()).get(`${base}/regions/999999`).set('Cookie', sessionCookie(2)).expect(404);
|
||||
await request(app.getHttpServer()).get(`${base}/regions/invalid`).set('Cookie', sessionCookie(2)).expect(400);
|
||||
await request(app.getHttpServer()).get(`${base}/regions/${region.id}/holidays/xx`).set('Cookie', sessionCookie(2)).expect(400);
|
||||
await request(app.getHttpServer()).delete(`${base}/countries/CA`).set('Cookie', sessionCookie(1)).expect(404);
|
||||
});
|
||||
|
||||
it('exposes the same manual catalog and periods through MCP', () => {
|
||||
const region = seed();
|
||||
const mcp = app.get(SchoolHolidaysMcp);
|
||||
expect(() => createTestRegistry([mcp], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess })).not.toThrow();
|
||||
expect(JSON.stringify(mcp.catalog())).toContain(region.code);
|
||||
expect(JSON.stringify(mcp.forYear({ regionId: region.id, year: 2027 }))).toContain('Winter break');
|
||||
});
|
||||
|
||||
it('denies every MCP mutation to normal users without changing the catalog', () => {
|
||||
const region = seed();
|
||||
const mcp = app.get(SchoolHolidaysMcp);
|
||||
const member = { userId: 2, scopes: null, isStaticToken: false };
|
||||
const before = service.region(region.id);
|
||||
const writes = [
|
||||
() => mcp.createCountry({ code: 'CA', name: 'Canada' }, member),
|
||||
() => mcp.createRegion({ country: 'US', name: 'Other', revision: 0, holidays: [] }, member),
|
||||
() => mcp.updateRegion({ regionId: region.id, name: 'Changed', revision: 1, holidays: [] }, member),
|
||||
() => mcp.deleteRegion({ regionId: region.id, revision: 1 }, member),
|
||||
() => mcp.deleteCountry({ code: 'US' }, member),
|
||||
];
|
||||
for (const write of writes) expect(write()).toHaveProperty('isError', true);
|
||||
expect(service.region(region.id)).toEqual(before);
|
||||
expect(service.catalog().countries).toHaveLength(1);
|
||||
expect(service.catalog().regions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('supports the complete admin MCP workflow with validation and revision protection', () => {
|
||||
const mcp = app.get(SchoolHolidaysMcp);
|
||||
const admin = { userId: 1, scopes: null, isStaticToken: false };
|
||||
expect(mcp.createCountry({ code: 'US', name: 'USA' }, admin)).not.toHaveProperty('isError', true);
|
||||
expect(mcp.createRegion({ country: 'US', name: 'Seattle', revision: 0, holidays: [winter] }, admin)).not.toHaveProperty('isError', true);
|
||||
const region = service.catalog().regions[0];
|
||||
expect(JSON.stringify(mcp.region({ regionId: region.id }))).toContain('Winter break');
|
||||
const edit = { regionId: region.id, name: 'Seattle Public Schools', revision: 1, holidays: [{ ...winter, name: 'Winter holiday' }] };
|
||||
expect(mcp.updateRegion(edit, admin)).not.toHaveProperty('isError', true);
|
||||
expect(mcp.updateRegion(edit, admin)).toHaveProperty('isError', true);
|
||||
expect(mcp.updateRegion({ ...edit, revision: 2, holidays: [{ ...winter, endDate: '2025-01-01' }] }, admin)).toHaveProperty('isError', true);
|
||||
expect(service.region(region.id)).toMatchObject({ revision: 2, holidays: [{ ...winter, name: 'Winter holiday' }] });
|
||||
expect(mcp.deleteCountry({ code: 'US' }, admin)).toHaveProperty('isError', true);
|
||||
expect(mcp.deleteRegion({ regionId: region.id, revision: 1 }, admin)).toHaveProperty('isError', true);
|
||||
expect(mcp.deleteRegion({ regionId: region.id, revision: 2 }, admin)).not.toHaveProperty('isError', true);
|
||||
expect(mcp.deleteCountry({ code: 'US' }, admin)).not.toHaveProperty('isError', true);
|
||||
expect(service.catalog()).toEqual({ countries: [], regions: [] });
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createTestRegistry, type McpRegistry } from '../../src/nest-mcp';
|
||||
import { SchoolHolidaysMcp } from '../../src/nest/school-holidays/school-holidays.mcp';
|
||||
import { SchoolHolidaysService } from '../../src/nest/school-holidays/school-holidays.service';
|
||||
import { db } from '../../src/db/database';
|
||||
import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../src/mcp/nest-mcp-policy';
|
||||
import { AssignmentsMcp } from '../../src/nest/assignments/assignments.mcp';
|
||||
@@ -221,6 +223,7 @@ export function createMcpTestRegistry(): McpRegistry {
|
||||
new AssignmentsMcp(assignmentsService, daysService, authService, guards),
|
||||
new CollabMcp(collabService, authService, addonsService, guards),
|
||||
new VacayMcp(new VacayService(dbService, realtimeService, notificationsStub()), authService, addonsService),
|
||||
new SchoolHolidaysMcp(new SchoolHolidaysService(dbService), guards),
|
||||
new TripsMcp(tripsService, todoService, collabService, authService, calendarService, membersService, readModelService, addonsService, guards),
|
||||
new TripPromptsMcp(tripsService, readModelService, packingService, addonsService),
|
||||
new ShareMcp(new ShareService(dbService, new SettingsService(dbService), permissionsService, queryHelpersService, placePhotoCache), authService, guards),
|
||||
|
||||
@@ -27,6 +27,9 @@ import type { RateLimitService } from '../../src/nest/common/rate-limit.service'
|
||||
// Keep in sync with schema.ts + migrations.ts. Intentionally excluded: categories, addons,
|
||||
// photo_providers, photo_provider_fields, schema_version (seed/config data, not user data).
|
||||
const RESET_TABLES = [
|
||||
'school_holiday_periods',
|
||||
'school_holiday_regions',
|
||||
'school_holiday_countries',
|
||||
// Collab
|
||||
'file_links',
|
||||
'collab_message_reactions',
|
||||
|
||||
@@ -309,6 +309,19 @@ describe('updatePlan', () => {
|
||||
// ── addHolidayCalendar ────────────────────────────────────────────────────────
|
||||
|
||||
describe('addHolidayCalendar', () => {
|
||||
it('validates manual region references for creates and updates', () => {
|
||||
const { plan } = setupUserWithPlan();
|
||||
testDb.prepare("INSERT INTO school_holiday_countries (code, name) VALUES ('US', 'USA')").run();
|
||||
const inserted = testDb.prepare("INSERT INTO school_holiday_regions (country, name) VALUES ('US', 'Seattle')").run();
|
||||
const code = `US-MANUAL-${inserted.lastInsertRowid}`;
|
||||
const calendar = svc.addHolidayCalendar(plan.id, code, null, undefined, 0, undefined, 'school_holiday');
|
||||
expect(calendar.region).toBe(code);
|
||||
expect(svc.updateHolidayCalendar(calendar.id, plan.id, { label: 'School' }, undefined)?.region).toBe(code);
|
||||
expect(() => svc.updateHolidayCalendar(calendar.id, plan.id, { type: 'public_holiday' }, undefined)).toThrow('Unknown manual');
|
||||
for (const region of ['US-MANUAL-0', 'US-MANUAL-999999', `CA-MANUAL-${inserted.lastInsertRowid}`]) {
|
||||
expect(() => svc.addHolidayCalendar(plan.id, region, null, undefined, 0, undefined, 'school_holiday')).toThrow('Unknown manual');
|
||||
}
|
||||
});
|
||||
it('VACAY-SVC-019: inserts a new calendar row and returns the calendar object', () => {
|
||||
const { plan } = setupUserWithPlan();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user