chore: move vacay backend to proper DI

This commit is contained in:
jubnl
2026-07-27 18:39:52 +02:00
parent 29e89a5774
commit 588457710c
20 changed files with 2127 additions and 2119 deletions
+2 -37
View File
@@ -7,7 +7,6 @@ import { listBudgetItems, getPerPersonSummary, calculateSettlement } from '../se
import { listReservations } from '../services/reservationService';
import { listBucketList, listVisitedCountries, getStats as getAtlasStats, listManuallyVisitedRegions } from '../services/atlasService';
import { getNotifications } from '../services/inAppNotifications';
import { getActivePlanId, getActivePlan, getPlanData, getEntries as getVacayEntries, getHolidays } from '../services/vacayService';
import { isAddonEnabled } from '../services/adminService';
import { ADDON_IDS } from '../addons';
import { canAccessJourney, getJourneyFull, listEntries, listJourneys } from '../services/journeyService';
@@ -258,42 +257,8 @@ export function registerResources(server: McpServer, userId: number, scopes: str
);
}
// Vacay resources (addon-gated)
if (isAddonEnabled(ADDON_IDS.VACAY) && canRead(scopes, 'vacay')) {
server.registerResource(
'vacay-plan',
'trek://vacay/plan',
{ description: "Full snapshot of the user's active vacation plan (members, years, settings)", mimeType: 'application/json' },
async (uri) => {
const plan = getPlanData(userId);
return jsonContent(uri.href, plan);
}
);
server.registerResource(
'vacay-entries',
new ResourceTemplate('trek://vacay/entries/{year}', { list: undefined }),
{ description: 'All vacation entries for the active plan and a specific year', mimeType: 'application/json' },
async (uri, { year }) => {
const planId = getActivePlanId(userId);
const entries = getVacayEntries(planId, Array.isArray(year) ? year[0] : year, userId);
return jsonContent(uri.href, entries);
}
);
server.registerResource(
'vacay-holidays',
new ResourceTemplate('trek://vacay/holidays/{year}', { list: undefined }),
{ description: "Cached public holidays for the plan's configured region and year", mimeType: 'application/json' },
async (uri, { year }) => {
const plan = getActivePlan(userId);
if (!plan.holidays_enabled || !plan.holidays_region) return jsonContent(uri.href, []);
const yearStr = Array.isArray(year) ? year[0] : year;
const result = await getHolidays(yearStr, plan.holidays_region);
return jsonContent(uri.href, result.data ?? []);
}
);
}
// The vacay resources moved to the DI-discovered src/nest/vacay/vacay.mcp.ts
// (@McpController, attached via the nest-mcp registry in tools.ts).
// Journey resources (Journey addon)
if (isAddonEnabled(ADDON_IDS.JOURNEY) && canRead(scopes, 'journey')) {
+2 -2
View File
@@ -11,7 +11,6 @@ import { registerBudgetTools } from './tools/budget';
import { registerTripTools } from './tools/trips';
import { registerTransportTools } from './tools/transports';
import { registerTransitTools } from './tools/transit';
import { registerVacayTools } from './tools/vacay';
import { registerMcpPrompts } from './tools/prompts';
import { getMcpRegistry } from './registry-handoff';
@@ -49,7 +48,8 @@ export function registerTools(server: McpServer, userId: number, scopes: string[
registerJourneyTools(server, userId, scopes);
registerVacayTools(server, userId, scopes);
// The vacay tools moved to the DI-discovered src/nest/vacay/vacay.mcp.ts
// (@McpController, attached via the nest-mcp registry below).
// The todo tools moved to the DI-discovered src/nest/todo/todo.mcp.ts
// (@McpController, attached via the nest-mcp registry below).
-465
View File
@@ -1,465 +0,0 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { isDemoUser, getCurrentUser } from '../../services/authService';
import {
getOwnPlan, getActivePlan, getActivePlanId, getPlanData,
updatePlan, setUserColor,
sendInvite as sendVacayInvite, acceptInvite, declineInvite, cancelInvite, dissolvePlan,
getAvailableUsers,
listYears, addYear, deleteYear,
getEntries as getVacayEntries, toggleEntry, toggleCompanyHoliday,
getStats as getVacayStats, updateStats as updateVacayStats,
addHolidayCalendar, updateHolidayCalendar, deleteHolidayCalendar,
getCountries as getHolidayCountries, getHolidays,
listShares, shareCalendar, removeShare, getSharedCalendars,
} from '../../services/vacayService';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import {
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
export function registerVacayTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'vacay');
const W = canWrite(scopes, 'vacay');
if (isAddonEnabled(ADDON_IDS.VACAY)) {
if (R) server.registerTool(
'get_vacay_plan',
{
description: "Get the current user's active vacation plan (own or joined).",
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const plan = getPlanData(userId);
return ok({ plan });
}
);
if (W) server.registerTool(
'update_vacay_plan',
{
description: 'Update vacation plan settings (weekends blocking, holidays, carry-over).',
inputSchema: {
block_weekends: z.boolean().optional(),
holidays_enabled: z.boolean().optional(),
holidays_region: z.string().nullable().optional(),
company_holidays_enabled: z.boolean().optional(),
carry_over_enabled: z.boolean().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
// updatePlan already returns the fully-hydrated { plan }; surface it so the
// AI consumer sees the updated plan, matching get_vacay_plan.
const result = await updatePlan(planId, { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }, undefined);
return ok(result);
}
);
if (W) server.registerTool(
'set_vacay_color',
{
description: "Set the current user's color in the vacation plan calendar.",
inputSchema: {
color: z.string().describe('Hex color e.g. #6366f1'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ color }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
setUserColor(userId, planId, color, undefined);
// Echo the persisted color (mirrors the service default) so the AI consumer sees what was set.
return ok({ success: true, color: color || '#6366f1' });
}
);
if (R) server.registerTool(
'get_available_vacay_users',
{
description: 'List users who can be invited to the current vacation plan.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const planId = getActivePlanId(userId);
const users = getAvailableUsers(userId, planId);
return ok({ users });
}
);
if (W) server.registerTool(
'send_vacay_invite',
{
description: 'Invite a user to join the vacation plan by their user ID.',
inputSchema: {
targetUserId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ targetUserId }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
const me = getCurrentUser(userId);
if (!me) return { content: [{ type: 'text' as const, text: 'User not found.' }], isError: true };
const result = sendVacayInvite(planId, userId, me.username, me.email, targetUserId);
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
return ok({ success: true });
}
);
if (W) server.registerTool(
'accept_vacay_invite',
{
description: 'Accept a pending invitation to join another user\'s vacation plan.',
inputSchema: {
planId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ planId }) => {
if (isDemoUser(userId)) return demoDenied();
const result = acceptInvite(userId, planId, undefined);
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
return ok({ success: true });
}
);
if (W) server.registerTool(
'decline_vacay_invite',
{
description: 'Decline a pending vacation plan invitation.',
inputSchema: {
planId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ planId }) => {
declineInvite(userId, planId, undefined);
return ok({ success: true });
}
);
if (W) server.registerTool(
'cancel_vacay_invite',
{
description: 'Cancel an outgoing invitation (owner cancels invite they sent).',
inputSchema: {
targetUserId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ targetUserId }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
cancelInvite(planId, targetUserId);
return ok({ success: true });
}
);
if (W) server.registerTool(
'dissolve_vacay_plan',
{
description: 'Dissolve the shared plan — all members are removed and everyone returns to their own individual plan.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async () => {
if (isDemoUser(userId)) return demoDenied();
dissolvePlan(userId, undefined);
return ok({ success: true });
}
);
if (R) server.registerTool(
'list_vacay_years',
{
description: 'List calendar years tracked in the current vacation plan.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const planId = getActivePlanId(userId);
const years = listYears(planId);
return ok({ years });
}
);
if (W) server.registerTool(
'add_vacay_year',
{
description: 'Add a calendar year to the vacation plan.',
inputSchema: {
year: z.number().int().min(2000).max(2100),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ year }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
const years = addYear(planId, year, undefined);
return ok({ years });
}
);
if (W) server.registerTool(
'delete_vacay_year',
{
description: 'Remove a calendar year from the vacation plan.',
inputSchema: {
year: z.number().int(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ year }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
const years = deleteYear(planId, year, undefined);
return ok({ years });
}
);
if (R) server.registerTool(
'get_vacay_entries',
{
description: 'Get all vacation day entries for a plan and year.',
inputSchema: {
year: z.number().int(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ year }) => {
const planId = getActivePlanId(userId);
const entries = getVacayEntries(planId, String(year), userId);
return ok({ entries });
}
);
if (W) server.registerTool(
'toggle_vacay_entry',
{
description: 'Toggle a day on or off as a vacation day for the current user.',
inputSchema: {
date: z.string().describe('ISO date YYYY-MM-DD'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ date }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
const result = toggleEntry(userId, planId, date, 1, 'vacation', undefined);
return ok(result);
}
);
if (W) server.registerTool(
'toggle_company_holiday',
{
description: 'Toggle a date as a company holiday for the whole plan.',
inputSchema: {
date: z.string(),
note: z.string().optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ date, note }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
const result = toggleCompanyHoliday(planId, date, note, undefined);
return ok(result);
}
);
if (R) server.registerTool(
'get_vacay_stats',
{
description: 'Get vacation statistics for a specific year (days used, remaining, carried over).',
inputSchema: {
year: z.number().int(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ year }) => {
const planId = getActivePlanId(userId);
const stats = getVacayStats(planId, year);
return ok({ stats });
}
);
if (W) server.registerTool(
'update_vacay_stats',
{
description: 'Update the vacation day allowance for a specific user and year.',
inputSchema: {
year: z.number().int(),
vacationDays: z.number().int().min(0),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ year, vacationDays }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
updateVacayStats(userId, planId, year, vacationDays, undefined);
return ok({ success: true });
}
);
if (W) server.registerTool(
'add_holiday_calendar',
{
description: 'Add a public holiday calendar (by region code) to the vacation plan.',
inputSchema: {
region: z.string().describe('Country/region code e.g. US, GB, DE'),
label: z.string().nullable().optional(),
color: z.string().optional(),
sortOrder: z.number().int().optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ region, label, color, sortOrder }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
const calendar = addHolidayCalendar(planId, region, label ?? null, color, sortOrder, undefined);
return ok({ calendar });
}
);
if (W) server.registerTool(
'update_holiday_calendar',
{
description: 'Update label or color for a holiday calendar.',
inputSchema: {
calendarId: z.number().int().positive(),
label: z.string().nullable().optional(),
color: z.string().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ calendarId, label, color }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
const cal = updateHolidayCalendar(calendarId, planId, { label, color }, undefined);
if (!cal) return { content: [{ type: 'text' as const, text: 'Holiday calendar not found.' }], isError: true };
return ok({ calendar: cal });
}
);
if (W) server.registerTool(
'delete_holiday_calendar',
{
description: 'Remove a holiday calendar from the vacation plan.',
inputSchema: {
calendarId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ calendarId }) => {
if (isDemoUser(userId)) return demoDenied();
const planId = getActivePlanId(userId);
deleteHolidayCalendar(calendarId, planId, undefined);
return ok({ success: true });
}
);
if (R) server.registerTool(
'list_holiday_countries',
{
description: 'List countries available for public holiday calendars.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const result = await getHolidayCountries();
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
return ok({ countries: result.data });
}
);
if (R) server.registerTool(
'list_holidays',
{
description: 'List public holidays for a country and year.',
inputSchema: {
country: z.string().describe('ISO 3166-1 alpha-2 code'),
year: z.number().int(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ country, year }) => {
const result = await getHolidays(String(year), country);
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
return ok({ holidays: result.data });
}
);
if (R) server.registerTool(
'list_vacay_shares',
{
description: 'List read-only calendar shares: who the current user shares their vacation calendar with, and which calendars are shared with them.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
return ok(listShares(userId));
}
);
if (W) server.registerTool(
'share_vacay_calendar',
{
description: "Share the current user's vacation calendar with another user (view only, no merge).",
inputSchema: {
targetUserId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ targetUserId }) => {
if (isDemoUser(userId)) return demoDenied();
const me = getCurrentUser(userId);
if (!me) return { content: [{ type: 'text' as const, text: 'User not found.' }], isError: true };
const result = shareCalendar(userId, me.email, targetUserId);
if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true };
return ok({ success: true });
}
);
if (W) server.registerTool(
'unshare_vacay_calendar',
{
description: 'Remove a read-only calendar share the current user is part of (revoke as owner, or remove a calendar shared with them).',
inputSchema: {
shareId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ shareId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!removeShare(shareId, userId, undefined)) {
return { content: [{ type: 'text' as const, text: 'Share not found.' }], isError: true };
}
return ok({ success: true });
}
);
if (R) server.registerTool(
'get_shared_vacay_calendars',
{
description: 'Get the read-only vacation calendars shared with the current user for a year (entries and company holidays per sharer).',
inputSchema: {
year: z.number().int(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ year }) => {
return ok({ calendars: getSharedCalendars(userId, String(year)) });
}
);
}
}
+9 -3
View File
@@ -28,7 +28,7 @@ mount to Nest and leaves the sibling trip routes (days, places, ...) on Express.
- **Phase 2 (trip sub-domains):** vacay (addon), packing, todo.
- **DI-native services (legacy `src/services/*` deleted):** tags, categories,
todo, packing, day-notes, trip-invite, assignments, share, settings, files,
collab — see the migration recipe below.
collab, vacay — see the migration recipe below.
## Cross-cutting Foundation pieces
@@ -129,9 +129,15 @@ outside DI — while every function consumer, including the plugin host and
addon AND the per-sub-feature `getCollabFeatures()` flags (notes/polls/chat)
that the legacy registrar and resources checked at registration time — plus
the plugin-host swap and a 3-export `collab.bridge.ts` for the two remaining
legacy consumers, `tripService`'s trip summary and `mcp/tools/trips.ts`).
legacy consumers, `tripService`'s trip summary and `mcp/tools/trips.ts`);
vacay followed (the largest MCP port yet — a 26-tool + 3-resource
`vacay.mcp.ts`, including the first fixed-URI `@Resource` behind a `when`
addon gate — plus the plugin-host swap and a 1-export `vacay.bridge.ts` for
`tripService`'s trip-window entry shift; the DTO ratchet for its 13
grandfathered body contracts landed as a sibling commit).
Repeat these steps per
service (next up: vacayService). This is a **pure relocation** — byte-identical
service (next up: reservationService, the residue fold). This is a **pure
relocation** — byte-identical
SQL, statuses, bodies, and error strings. The plugin RPC host is **no longer a
bridge consumer**: since Option A of `src/nest/plugins/DI-MIGRATION.md` it
injects domain services via `PluginHostDepsFactory`, so a migrated domain adds
+5 -3
View File
@@ -108,8 +108,10 @@ but its `resolveLlmConfig` import became the injected `LlmConfigResolver`;
fileService swapped in 2026-07 — its ten imported symbols became the injected
`FilesService` plus the load-time constants from `files.constants.ts`;
collabService swapped in 2026-07 — its seven imported symbols became the
injected `CollabService`, completing Wave 3; the remaining factory imports are
all Wave-4+ domains: trips, places, days, journeys, atlas, vacay, collections).
injected `CollabService`, completing Wave 3; vacayService swapped in 2026-07 —
its four imported symbols became the injected `VacayService`; the remaining
factory imports are all Wave-4+ domains: trips, places, days, journeys, atlas,
collections).
### Test impact (as landed)
@@ -117,7 +119,7 @@ all Wave-4+ domains: trips, places, days, journeys, atlas, vacay, collections).
`PluginRpcHost` directly with hand-built `HostDeps`).
- `tests/unit/plugins/plugin-host-deps.factory.test.ts` (was
`create-rpc-host.test.ts`) — the six DI-domain path mocks became constructor
stubs (nine stubs as of the 2026-07 assignments migration); the ~25
stubs (eleven stubs as of the 2026-07 vacay migration); the ~24
legacy-service path mocks remain; a file-local shim keeps the
historical `createRealRpcHost(id, granted)` call sites and supplies a default
no-op router.
@@ -23,7 +23,6 @@ import { isDemoEmail } from '../../../services/demo';
import { ADDON_IDS } from '../../../addons';
import { listJourneys, listEntries as listJournalEntriesSvc, createEntry as createJournalEntrySvc, updateEntry as updateJournalEntrySvc, deleteEntry as deleteJournalEntrySvc, createJourney as createJourneySvc, deleteJourney as deleteJourneySvc } from '../../../services/journeyService';
import { listVisitedCountries, listManuallyVisitedRegions, listBucketList, markCountryVisited, unmarkCountryVisited, markRegionVisited, unmarkRegionVisited, createBucketItem as createBucketItemSvc, deleteBucketItem as deleteBucketItemSvc } from '../../../services/atlasService';
import { getPlanData, getActivePlanId, toggleEntry as vacayToggleEntrySvc, toggleCompanyHoliday as vacayToggleCompanyHolidaySvc } from '../../../services/vacayService';
import { listCollections, getCollection, createCollection, updateCollection, savePlace as saveCollectionPlaceSvc, copyToTrip as copyCollectionToTripSvc, deletePlace as deleteCollectionPlaceSvc } from '../../../services/collectionsService';
import { BudgetService } from '../../budget/budget.service';
import { ReservationsService } from '../../reservations/reservations.service';
@@ -37,6 +36,7 @@ import { LlmConfigResolver } from '../../llm-parse/llm-config.resolver';
import { DatabaseService } from '../../database/database.service';
import { FilesService } from '../../files/files.service';
import { CollabService } from '../../collab/collab.service';
import { VacayService } from '../../vacay/vacay.service';
import { notifyBookingChange } from '../../../services/reservationService';
import { PluginRpcHost, ForbiddenResource, BadParams } from './rpc-host';
import { appendAudit } from './plugin-audit';
@@ -137,8 +137,8 @@ export interface PluginCallRouter {
* `plugin:{id}:{event}` so a plugin can't forge a core event.
*
* DI-native domain services (budget, reservations, tags, categories, todo,
* packing, day-notes, assignments, oauth, files, collab, the LLM config
* resolver) and
* packing, day-notes, assignments, oauth, files, collab, vacay, the LLM
* config resolver) and
* the DatabaseService (all inline SQL + the trip-access helper) are
* constructor-injected; legacy `services/*` domains stay plain function
* imports until their own migration lands (DI-MIGRATION.md), at which point
@@ -160,6 +160,7 @@ export class PluginHostDepsFactory {
private readonly db: DatabaseService,
private readonly files: FilesService,
private readonly collab: CollabService,
private readonly vacay: VacayService,
) {}
/**
@@ -625,7 +626,7 @@ export class PluginHostDepsFactory {
return { countries: listVisitedCountries(userId), regions: listManuallyVisitedRegions(userId) };
},
atlasBucketForUser: (userId) => { requireAddon(ADDON_IDS.ATLAS, 'atlas'); return listBucketList(userId) as unknown[]; },
vacayForUser: (userId) => { requireAddon(ADDON_IDS.VACAY, 'vacay'); return getPlanData(userId); },
vacayForUser: (userId) => { requireAddon(ADDON_IDS.VACAY, 'vacay'); return this.vacay.getPlanData(userId); },
listCollectionsForUser: (userId) => { requireAddon(ADDON_IDS.COLLECTIONS, 'collections'); return listCollections(userId); },
getCollectionForUser: (userId, id) => { requireAddon(ADDON_IDS.COLLECTIONS, 'collections'); return getCollection(userId, id); },
// --- Collections write. The service self-gates per-collection role (assertAccess/
@@ -668,10 +669,10 @@ export class PluginHostDepsFactory {
},
// --- Vacay write: the plan is the ACTING USER's active plan (resolved host-side);
// the service broadcasts to plan users itself. ---
vacayToggleEntry: (userId, date) => { requireAddon(ADDON_IDS.VACAY, 'vacay'); return vacayToggleEntrySvc(userId, getActivePlanId(userId), date, 1, 'vacation', undefined); },
vacayToggleEntry: (userId, date) => { requireAddon(ADDON_IDS.VACAY, 'vacay'); return this.vacay.toggleEntry(userId, this.vacay.getActivePlanId(userId), date, 1, 'vacation', undefined); },
vacayToggleCompanyHoliday: (userId, date, note) => {
requireAddon(ADDON_IDS.VACAY, 'vacay');
return vacayToggleCompanyHolidaySvc(getActivePlanId(userId), date, note, undefined);
return this.vacay.toggleCompanyHoliday(this.vacay.getActivePlanId(userId), date, note, undefined);
},
// --- Journal write: journeyService.canEdit self-gates each call (owner/contributor). ---
createJournalEntry: (userId, journeyId, input) => {
+2 -1
View File
@@ -35,6 +35,7 @@ import { AssignmentsModule } from '../assignments/assignments.module';
import { LlmParseModule } from '../llm-parse/llm-parse.module';
import { FilesModule } from '../files/files.module';
import { CollabModule } from '../collab/collab.module';
import { VacayModule } from '../vacay/vacay.module';
/**
* Plugin system (#plugins). M0 read side + M2 isolated runtime + M3 frontend:
@@ -46,7 +47,7 @@ import { CollabModule } from '../collab/collab.module';
@Module({
// The DI-native domain services the plugin host wiring injects
// (PluginHostDepsFactory); DatabaseModule is @Global, so not listed.
imports: [TagsModule, CategoriesModule, BudgetModule, ReservationsModule, TodoModule, PackingModule, DaysModule, AssignmentsModule, LlmParseModule, FilesModule, CollabModule],
imports: [TagsModule, CategoriesModule, BudgetModule, ReservationsModule, TodoModule, PackingModule, DaysModule, AssignmentsModule, LlmParseModule, FilesModule, CollabModule, VacayModule],
controllers: [PluginsController, PluginsFeedController, PluginsProxyController, PluginFrameController, PlaceDetailsController, TripWarningsController, ViewContributionsController, TripCardContributionsController, PluginPhotosController, PluginCalendarController, MapMarkersController, MapLayersController, PluginRoutesController, DayScheduleController, PdfSectionsController, AtlasLayersController, JournalEntryRowsController, PluginUserSettingsController, PluginOAuthController, PluginActivityController],
providers: [PluginsService, PluginRuntimeService, PluginRegistryService, PluginOAuthService, PluginHostDepsFactory],
// Exported so the admin addon-toggle handler can cascade-disable plugins whose
+27
View File
@@ -0,0 +1,27 @@
import { db } from '../../db/database';
import { DatabaseService } from '../database/database.service';
import { VacayService } from './vacay.service';
/**
* Non-Nest entry point for the vacay domain — for code running OUTSIDE the
* Nest container (the legacy services/tripService.ts, which shifts a trip
* owner's vacation entries when the trip window moves; the vacay MCP tools and
* resources moved to the DI-discovered vacay.mcp.ts, and the plugin RPC host
* injects VacayService via PluginHostDepsFactory). Exports only the legacy
* services/vacayService names still consumed outside the container, 1:1, so
* repointing a consumer is an import-path-only diff. Inside the container,
* inject VacayService instead. Delete this file when tripService migrates.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton.
*/
const vacay = new VacayService(new DatabaseService(db));
export function shiftOwnerEntriesForTripWindow(
ownerId: number,
oldStart: string,
oldEnd: string,
newStart: string
): void {
return vacay.shiftOwnerEntriesForTripWindow(ownerId, oldStart, oldEnd, newStart);
}
+544
View File
@@ -0,0 +1,544 @@
import {
McpController, Tool, Resource, ResourceTemplate, type McpContext,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, errorResult, ok,
} from '@trek/nest-mcp';
import { z } from 'zod';
import { isDemoUser, getCurrentUser } from '../../services/authService';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import { VacayService } from './vacay.service';
/** Legacy registrar gate: the whole vacay surface rides the vacay addon. */
const vacayAddonOn = () => isAddonEnabled(ADDON_IDS.VACAY);
/**
* Vacay MCP surface — ported 1:1 from the legacy registrars: the 26 tools from
* src/mcp/tools/vacay.ts and the trek://vacay/{plan,entries,holidays} resources
* from src/mcp/resources.ts (identical names, descriptions, schemas,
* annotations and error/payload shapes; broadcasts happen inside VacayService,
* driven by the socketId argument every tool passes as undefined). The
* registration-time gates map to `when` (the whole-registrar vacay-addon check)
* plus the declarative vacay read/write access markers (the legacy `if (R)` /
* `if (W)` checks, resolved by trekMcpAccessPolicy). Vacay is plan-scoped, not
* trip-scoped, so there is no trip-permission layer; the only per-call guard is
* the demo-user check — which decline_vacay_invite deliberately lacks, exactly
* like the legacy registrar (a quirk preserved for the fix commit).
*/
@McpController()
export class VacayMcp {
constructor(private readonly vacay: VacayService) {}
@Tool({
name: 'get_vacay_plan',
description: "Get the current user's active vacation plan (own or joined).",
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
when: vacayAddonOn,
access: { group: 'vacay', mode: 'read' },
})
async getVacayPlan(_args: Record<string, never>, ctx: McpContext) {
const plan = this.vacay.getPlanData(ctx.userId);
return ok({ plan });
}
@Tool({
name: 'update_vacay_plan',
description: 'Update vacation plan settings (weekends blocking, holidays, carry-over).',
inputSchema: {
block_weekends: z.boolean().optional(),
holidays_enabled: z.boolean().optional(),
holidays_region: z.string().nullable().optional(),
company_holidays_enabled: z.boolean().optional(),
carry_over_enabled: z.boolean().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
when: vacayAddonOn,
access: { group: 'vacay', mode: 'write' },
})
async updateVacayPlan(
{ block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }: {
block_weekends?: boolean; holidays_enabled?: boolean; holidays_region?: string | null; company_holidays_enabled?: boolean; carry_over_enabled?: boolean;
},
ctx: McpContext,
) {
if (isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
// updatePlan already returns the fully-hydrated { plan }; surface it so the
// AI consumer sees the updated plan, matching get_vacay_plan.
const result = await this.vacay.updatePlan(planId, { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }, undefined);
return ok(result);
}
@Tool({
name: 'set_vacay_color',
description: "Set the current user's color in the vacation plan calendar.",
inputSchema: {
color: z.string().describe('Hex color e.g. #6366f1'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
when: vacayAddonOn,
access: { group: 'vacay', mode: 'write' },
})
async setVacayColor({ color }: { color: string }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
this.vacay.setUserColor(ctx.userId, planId, color, undefined);
// Echo the persisted color (mirrors the service default) so the AI consumer sees what was set.
return ok({ success: true, color: color || '#6366f1' });
}
@Tool({
name: 'get_available_vacay_users',
description: 'List users who can be invited to the current vacation plan.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
when: vacayAddonOn,
access: { group: 'vacay', mode: 'read' },
})
async getAvailableVacayUsers(_args: Record<string, never>, ctx: McpContext) {
const planId = this.vacay.getActivePlanId(ctx.userId);
const users = this.vacay.getAvailableUsers(ctx.userId, planId);
return ok({ users });
}
@Tool({
name: 'send_vacay_invite',
description: 'Invite a user to join the vacation plan by their user ID.',
inputSchema: {
targetUserId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: vacayAddonOn,
access: { group: 'vacay', mode: 'write' },
})
async sendVacayInvite({ targetUserId }: { targetUserId: number }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
const me = getCurrentUser(ctx.userId);
if (!me) return errorResult('User not found.');
const result = this.vacay.sendInvite(planId, ctx.userId, me.username, me.email, targetUserId);
if (result.error) return errorResult(result.error);
return ok({ success: true });
}
@Tool({
name: 'accept_vacay_invite',
description: "Accept a pending invitation to join another user's vacation plan.",
inputSchema: {
planId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: vacayAddonOn,
access: { group: 'vacay', mode: 'write' },
})
async acceptVacayInvite({ planId }: { planId: number }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
const result = this.vacay.acceptInvite(ctx.userId, planId, undefined);
if (result.error) return errorResult(result.error);
return ok({ success: true });
}
@Tool({
name: 'decline_vacay_invite',
description: 'Decline a pending vacation plan invitation.',
inputSchema: {
planId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
when: vacayAddonOn,
access: { group: 'vacay', mode: 'write' },
})
async declineVacayInvite({ planId }: { planId: number }, ctx: McpContext) {
this.vacay.declineInvite(ctx.userId, planId, undefined);
return ok({ success: true });
}
@Tool({
name: 'cancel_vacay_invite',
description: 'Cancel an outgoing invitation (owner cancels invite they sent).',
inputSchema: {
targetUserId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
when: vacayAddonOn,
access: { group: 'vacay', mode: 'write' },
})
async cancelVacayInvite({ targetUserId }: { targetUserId: number }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
this.vacay.cancelInvite(planId, targetUserId);
return ok({ success: true });
}
@Tool({
name: 'dissolve_vacay_plan',
description: 'Dissolve the shared plan — all members are removed and everyone returns to their own individual plan.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_DELETE,
when: vacayAddonOn,
access: { group: 'vacay', mode: 'write' },
})
async dissolveVacayPlan(_args: Record<string, never>, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
this.vacay.dissolvePlan(ctx.userId, undefined);
return ok({ success: true });
}
@Tool({
name: 'list_vacay_years',
description: 'List calendar years tracked in the current vacation plan.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
when: vacayAddonOn,
access: { group: 'vacay', mode: 'read' },
})
async listVacayYears(_args: Record<string, never>, ctx: McpContext) {
const planId = this.vacay.getActivePlanId(ctx.userId);
const years = this.vacay.listYears(planId);
return ok({ years });
}
@Tool({
name: 'add_vacay_year',
description: 'Add a calendar year to the vacation plan.',
inputSchema: {
year: z.number().int().min(2000).max(2100),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: vacayAddonOn,
access: { group: 'vacay', mode: 'write' },
})
async addVacayYear({ year }: { year: number }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
const years = this.vacay.addYear(planId, year, undefined);
return ok({ years });
}
@Tool({
name: 'delete_vacay_year',
description: 'Remove a calendar year from the vacation plan.',
inputSchema: {
year: z.number().int(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
when: vacayAddonOn,
access: { group: 'vacay', mode: 'write' },
})
async deleteVacayYear({ year }: { year: number }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
const years = this.vacay.deleteYear(planId, year, undefined);
return ok({ years });
}
@Tool({
name: 'get_vacay_entries',
description: 'Get all vacation day entries for a plan and year.',
inputSchema: {
year: z.number().int(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
when: vacayAddonOn,
access: { group: 'vacay', mode: 'read' },
})
async getVacayEntries({ year }: { year: number }, ctx: McpContext) {
const planId = this.vacay.getActivePlanId(ctx.userId);
const entries = this.vacay.getEntries(planId, String(year), ctx.userId);
return ok({ entries });
}
@Tool({
name: 'toggle_vacay_entry',
description: 'Toggle a day on or off as a vacation day for the current user.',
inputSchema: {
date: z.string().describe('ISO date YYYY-MM-DD'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: vacayAddonOn,
access: { group: 'vacay', mode: 'write' },
})
async toggleVacayEntry({ date }: { date: string }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
const result = this.vacay.toggleEntry(ctx.userId, planId, date, 1, 'vacation', undefined);
return ok(result);
}
@Tool({
name: 'toggle_company_holiday',
description: 'Toggle a date as a company holiday for the whole plan.',
inputSchema: {
date: z.string(),
note: z.string().optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: vacayAddonOn,
access: { group: 'vacay', mode: 'write' },
})
async toggleCompanyHoliday({ date, note }: { date: string; note?: string }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
const result = this.vacay.toggleCompanyHoliday(planId, date, note, undefined);
return ok(result);
}
@Tool({
name: 'get_vacay_stats',
description: 'Get vacation statistics for a specific year (days used, remaining, carried over).',
inputSchema: {
year: z.number().int(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
when: vacayAddonOn,
access: { group: 'vacay', mode: 'read' },
})
async getVacayStats({ year }: { year: number }, ctx: McpContext) {
const planId = this.vacay.getActivePlanId(ctx.userId);
const stats = this.vacay.getStats(planId, year);
return ok({ stats });
}
@Tool({
name: 'update_vacay_stats',
description: 'Update the vacation day allowance for a specific user and year.',
inputSchema: {
year: z.number().int(),
vacationDays: z.number().int().min(0),
},
annotations: TOOL_ANNOTATIONS_WRITE,
when: vacayAddonOn,
access: { group: 'vacay', mode: 'write' },
})
async updateVacayStats({ year, vacationDays }: { year: number; vacationDays: number }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
this.vacay.updateStats(ctx.userId, planId, year, vacationDays, undefined);
return ok({ success: true });
}
@Tool({
name: 'add_holiday_calendar',
description: 'Add a public holiday calendar (by region code) to the vacation plan.',
inputSchema: {
region: z.string().describe('Country/region code e.g. US, GB, DE'),
label: z.string().nullable().optional(),
color: z.string().optional(),
sortOrder: z.number().int().optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: vacayAddonOn,
access: { group: 'vacay', mode: 'write' },
})
async addHolidayCalendar(
{ region, label, color, sortOrder }: { region: string; label?: string | null; color?: string; sortOrder?: number },
ctx: McpContext,
) {
if (isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
const calendar = this.vacay.addHolidayCalendar(planId, region, label ?? null, color, sortOrder, undefined);
return ok({ calendar });
}
@Tool({
name: 'update_holiday_calendar',
description: 'Update label or color for a holiday calendar.',
inputSchema: {
calendarId: z.number().int().positive(),
label: z.string().nullable().optional(),
color: z.string().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
when: vacayAddonOn,
access: { group: 'vacay', mode: 'write' },
})
async updateHolidayCalendar(
{ calendarId, label, color }: { calendarId: number; label?: string | null; color?: string },
ctx: McpContext,
) {
if (isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
const cal = this.vacay.updateHolidayCalendar(calendarId, planId, { label, color }, undefined);
if (!cal) return errorResult('Holiday calendar not found.');
return ok({ calendar: cal });
}
@Tool({
name: 'delete_holiday_calendar',
description: 'Remove a holiday calendar from the vacation plan.',
inputSchema: {
calendarId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
when: vacayAddonOn,
access: { group: 'vacay', mode: 'write' },
})
async deleteHolidayCalendar({ calendarId }: { calendarId: number }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
this.vacay.deleteHolidayCalendar(calendarId, planId, undefined);
return ok({ success: true });
}
@Tool({
name: 'list_holiday_countries',
description: 'List countries available for public holiday calendars.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
when: vacayAddonOn,
access: { group: 'vacay', mode: 'read' },
})
async listHolidayCountries(_args: Record<string, never>, _ctx: McpContext) {
const result = await this.vacay.getCountries();
if (result.error) return errorResult(result.error);
return ok({ countries: result.data });
}
@Tool({
name: 'list_holidays',
description: 'List public holidays for a country and year.',
inputSchema: {
country: z.string().describe('ISO 3166-1 alpha-2 code'),
year: z.number().int(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
when: vacayAddonOn,
access: { group: 'vacay', mode: 'read' },
})
async listHolidays({ country, year }: { country: string; year: number }, _ctx: McpContext) {
const result = await this.vacay.getHolidays(String(year), country);
if (result.error) return errorResult(result.error);
return ok({ holidays: result.data });
}
@Tool({
name: 'list_vacay_shares',
description: 'List read-only calendar shares: who the current user shares their vacation calendar with, and which calendars are shared with them.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
when: vacayAddonOn,
access: { group: 'vacay', mode: 'read' },
})
async listVacayShares(_args: Record<string, never>, ctx: McpContext) {
return ok(this.vacay.listShares(ctx.userId));
}
@Tool({
name: 'share_vacay_calendar',
description: "Share the current user's vacation calendar with another user (view only, no merge).",
inputSchema: {
targetUserId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: vacayAddonOn,
access: { group: 'vacay', mode: 'write' },
})
async shareVacayCalendar({ targetUserId }: { targetUserId: number }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
const me = getCurrentUser(ctx.userId);
if (!me) return errorResult('User not found.');
const result = this.vacay.shareCalendar(ctx.userId, me.email, targetUserId);
if (result.error) return errorResult(result.error);
return ok({ success: true });
}
@Tool({
name: 'unshare_vacay_calendar',
description: 'Remove a read-only calendar share the current user is part of (revoke as owner, or remove a calendar shared with them).',
inputSchema: {
shareId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
when: vacayAddonOn,
access: { group: 'vacay', mode: 'write' },
})
async unshareVacayCalendar({ shareId }: { shareId: number }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.vacay.removeShare(shareId, ctx.userId, undefined)) {
return errorResult('Share not found.');
}
return ok({ success: true });
}
@Tool({
name: 'get_shared_vacay_calendars',
description: 'Get the read-only vacation calendars shared with the current user for a year (entries and company holidays per sharer).',
inputSchema: {
year: z.number().int(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
when: vacayAddonOn,
access: { group: 'vacay', mode: 'read' },
})
async getSharedVacayCalendars({ year }: { year: number }, ctx: McpContext) {
return ok({ calendars: this.vacay.getSharedCalendars(ctx.userId, String(year)) });
}
// -------------------------------------------------------------------------
// Resources (from src/mcp/resources.ts — payloads reproduce jsonContent verbatim)
// -------------------------------------------------------------------------
@Resource({
name: 'vacay-plan',
uri: 'trek://vacay/plan',
description: "Full snapshot of the user's active vacation plan (members, years, settings)",
mimeType: 'application/json',
when: vacayAddonOn,
access: { group: 'vacay', mode: 'read' },
})
async vacayPlanResource(uri: URL, ctx: McpContext) {
const plan = this.vacay.getPlanData(ctx.userId);
return {
contents: [{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify(plan, null, 2),
}],
};
}
@ResourceTemplate({
name: 'vacay-entries',
uriTemplate: 'trek://vacay/entries/{year}',
description: 'All vacation entries for the active plan and a specific year',
mimeType: 'application/json',
when: vacayAddonOn,
access: { group: 'vacay', mode: 'read' },
})
async vacayEntriesResource(uri: URL, { year }: { year: string | string[] }, ctx: McpContext) {
const planId = this.vacay.getActivePlanId(ctx.userId);
const entries = this.vacay.getEntries(planId, Array.isArray(year) ? year[0] : year, ctx.userId);
return {
contents: [{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify(entries, null, 2),
}],
};
}
@ResourceTemplate({
name: 'vacay-holidays',
uriTemplate: 'trek://vacay/holidays/{year}',
description: "Cached public holidays for the plan's configured region and year",
mimeType: 'application/json',
when: vacayAddonOn,
access: { group: 'vacay', mode: 'read' },
})
async vacayHolidaysResource(uri: URL, { year }: { year: string | string[] }, ctx: McpContext) {
const plan = this.vacay.getActivePlan(ctx.userId);
const json = (data: unknown) => ({
contents: [{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify(data, null, 2),
}],
});
if (!plan.holidays_enabled || !plan.holidays_region) return json([]);
const yearStr = Array.isArray(year) ? year[0] : year;
const result = await this.vacay.getHolidays(yearStr, plan.holidays_region);
return json(result.data ?? []);
}
}
+8 -2
View File
@@ -1,10 +1,16 @@
import { Module } from '@nestjs/common';
import { VacayController } from './vacay.controller';
import { VacayService } from './vacay.service';
import { VacayMcp } from './vacay.mcp';
/** Vacay addon domain (S1 — Phase 2 trip sub-domain). Registered in AppModule. */
/**
* Vacay addon domain (S1 — Phase 2 trip sub-domain). Registered in AppModule.
* VacayService is exported for the plugin host wiring (PluginHostDepsFactory);
* VacayMcp carries the DI-discovered MCP tools/resources.
*/
@Module({
controllers: [VacayController],
providers: [VacayService],
providers: [VacayService, VacayMcp],
exports: [VacayService],
})
export class VacayModule {}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -11,7 +11,7 @@ import { listBudgetItems, removeUserFromBudgetItems } from './budgetService';
import { listItems as listPackingItems } from '../nest/packing/packing.bridge';
import { listReservations, loadEndpointsByTrip, resyncReservationDays } from './reservationService';
import { listNotes as listCollabNotes } from '../nest/collab/collab.bridge';
import { shiftOwnerEntriesForTripWindow } from './vacayService';
import { shiftOwnerEntriesForTripWindow } from '../nest/vacay/vacay.bridge';
import { resolveTimeZone } from './timezoneService';
export const MS_PER_DAY = 86400000;
File diff suppressed because it is too large Load Diff
+61 -26
View File
@@ -1,8 +1,9 @@
/**
* Vacay module e2e — exercises the migrated /api/addons/vacay endpoints through
* the real JwtAuthGuard against a temp SQLite db. vacayService is mocked; this
* focuses on auth, status codes (POSTs stay 200) and a couple of validation/403
* bodies.
* the real JwtAuthGuard against a temp SQLite db. DI-native: VacayService runs
* its real SQL over the temp db (no legacy service mock); only the websocket
* and notification side channels stay mocked. Focuses on auth, status codes
* (POSTs stay 200), the Zod-pipe 400 envelope and a 403 body.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import request from 'supertest';
@@ -18,34 +19,70 @@ const { db } = vi.hoisted(() => {
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
// The vacay tables VacayService's real SQL touches (trimmed from src/db/schema.ts,
// with the fraction/kind columns the migrations add to vacay_entries).
tmp.exec(`CREATE TABLE vacay_plans (id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
block_weekends INTEGER DEFAULT 1, holidays_enabled INTEGER DEFAULT 0, holidays_region TEXT DEFAULT '',
school_holidays_enabled INTEGER DEFAULT 0, company_holidays_enabled INTEGER DEFAULT 1,
carry_over_enabled INTEGER DEFAULT 1, weekend_days TEXT, week_start INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(owner_id));`);
tmp.exec(`CREATE TABLE vacay_plan_members (id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT DEFAULT 'pending', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(plan_id, user_id));`);
tmp.exec(`CREATE TABLE vacay_user_colors (id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
color TEXT DEFAULT '#6366f1', UNIQUE(user_id, plan_id));`);
tmp.exec(`CREATE TABLE vacay_years (id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
year INTEGER NOT NULL, UNIQUE(plan_id, year));`);
tmp.exec(`CREATE TABLE vacay_user_years (id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
year INTEGER NOT NULL, vacation_days INTEGER DEFAULT 30, carried_over INTEGER DEFAULT 0,
UNIQUE(user_id, plan_id, year));`);
tmp.exec(`CREATE TABLE vacay_entries (id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
date TEXT NOT NULL, note TEXT DEFAULT '',
fraction REAL NOT NULL DEFAULT 1, kind TEXT NOT NULL DEFAULT 'vacation',
UNIQUE(user_id, plan_id, date));`);
tmp.exec(`CREATE TABLE vacay_company_holidays (id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
date TEXT NOT NULL, note TEXT DEFAULT '', UNIQUE(plan_id, date));`);
tmp.exec(`CREATE TABLE vacay_holiday_calendars (id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
type TEXT NOT NULL DEFAULT 'public_holiday', region TEXT NOT NULL, label TEXT,
color TEXT NOT NULL DEFAULT '#fecaca', sort_order INTEGER NOT NULL DEFAULT 0);`);
tmp.exec(`CREATE TABLE vacay_shares (id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
hidden INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(owner_id, user_id));`);
tmp.exec(`CREATE TABLE vacay_user_settings (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
year_type TEXT NOT NULL DEFAULT 'calendar', year_start_month INTEGER NOT NULL DEFAULT 1,
year_start_day INTEGER NOT NULL DEFAULT 1, hire_date TEXT);`);
return { db: tmp };
});
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
vi.mock('../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
const { svc } = vi.hoisted(() => ({
svc: {
getPlanData: vi.fn(), getActivePlanId: vi.fn(), getActivePlan: vi.fn(), updatePlan: vi.fn(),
addHolidayCalendar: vi.fn(), updateHolidayCalendar: vi.fn(), deleteHolidayCalendar: vi.fn(),
getPlanUsers: vi.fn(), setUserColor: vi.fn(), sendInvite: vi.fn(), acceptInvite: vi.fn(),
declineInvite: vi.fn(), cancelInvite: vi.fn(), dissolvePlan: vi.fn(), getAvailableUsers: vi.fn(),
listYears: vi.fn(), addYear: vi.fn(), deleteYear: vi.fn(), getEntries: vi.fn(),
toggleEntry: vi.fn(), toggleCompanyHoliday: vi.fn(), getStats: vi.fn(), updateStats: vi.fn(),
getCountries: vi.fn(), getHolidays: vi.fn(),
},
}));
vi.mock('../../src/services/vacayService', () => svc);
import { DatabaseModule } from '../../src/nest/database/database.module';
import { VacayModule } from '../../src/nest/vacay/vacay.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { broadcastToUser } from '../../src/websocket';
describe('Vacay e2e (real auth guard + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [VacayModule] }).compile();
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, VacayModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
@@ -60,11 +97,6 @@ describe('Vacay e2e (real auth guard + temp SQLite)', () => {
seedUser(db as never, { id: 1 });
app = await build();
server = app.getHttpServer();
svc.getActivePlanId.mockReturnValue(10);
svc.getActivePlan.mockReturnValue({ id: 10 });
svc.getPlanUsers.mockReturnValue([{ id: 1 }]);
svc.getPlanData.mockReturnValue({ plan: { id: 10 } });
svc.toggleEntry.mockReturnValue({ action: 'added' });
});
afterAll(async () => {
@@ -76,18 +108,21 @@ describe('Vacay e2e (real auth guard + temp SQLite)', () => {
expect(res.status).toBe(401);
});
it('200 plan for an authenticated user', async () => {
it('200 plan for an authenticated user (lazily creates the plan)', async () => {
const res = await request(server).get('/api/addons/vacay/plan').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body).toEqual({ plan: { id: 10 } });
expect(res.body.plan.owner_id).toBe(1);
expect(res.body.isOwner).toBe(true);
expect(db.prepare('SELECT id FROM vacay_plans WHERE owner_id = 1').get()).toBeDefined();
});
it('200 (not 201) on POST entries/toggle, forwarding the socket id', async () => {
const res = await request(server).post('/api/addons/vacay/entries/toggle')
.set('Cookie', sessionCookie(1)).set('X-Socket-Id', 'sock-7').send({ date: '2026-07-01' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ action: 'added' });
expect(svc.toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', undefined, undefined, 'sock-7');
expect(res.body).toEqual({ action: 'added', fraction: 1, kind: 'vacation' });
expect(db.prepare("SELECT id FROM vacay_entries WHERE user_id = 1 AND date = '2026-07-01'").get()).toBeDefined();
expect(vi.mocked(broadcastToUser)).toHaveBeenCalledWith(1, { type: 'vacay:update' }, 'sock-7');
});
it('400 from the Zod pipe on entries/toggle without a date', async () => {
@@ -16,6 +16,8 @@ import { TagsMcp } from '../../src/nest/tags/tags.mcp';
import { TagsService } from '../../src/nest/tags/tags.service';
import { TodoMcp } from '../../src/nest/todo/todo.mcp';
import { TodoService } from '../../src/nest/todo/todo.service';
import { VacayMcp } from '../../src/nest/vacay/vacay.mcp';
import { VacayService } from '../../src/nest/vacay/vacay.service';
/**
* Hand-wired counterpart of the boot-time discovery in McpRegistryService,
@@ -35,6 +37,7 @@ export function createMcpTestRegistry(): McpRegistry {
new DayNotesMcp(new DayNotesService(dbService)),
new AssignmentsMcp(new AssignmentsService(dbService)),
new CollabMcp(new CollabService(dbService)),
new VacayMcp(new VacayService(dbService)),
],
{ accessPolicy: trekMcpAccessPolicy },
);
+2
View File
@@ -15,6 +15,7 @@ import { LlmConfigResolver } from '../../src/nest/llm-parse/llm-config.resolver'
import { SettingsService } from '../../src/nest/settings/settings.service';
import { FilesService } from '../../src/nest/files/files.service';
import { CollabService } from '../../src/nest/collab/collab.service';
import { VacayService } from '../../src/nest/vacay/vacay.service';
/**
* Hand-wired counterpart of the PluginsModule DI graph for no-Nest tests
@@ -37,6 +38,7 @@ export function createHostDepsFactory(dbs: DatabaseService): PluginHostDepsFacto
dbs,
new FilesService(dbs),
new CollabService(dbs),
new VacayService(dbs),
);
}
+7 -9
View File
@@ -51,15 +51,13 @@ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
]),
}));
// Mock vacayService.getCountries to avoid real HTTP call to nager.at
vi.mock('../../src/services/vacayService', async () => {
const actual = await vi.importActual<typeof import('../../src/services/vacayService')>('../../src/services/vacayService');
return {
...actual,
getCountries: vi.fn().mockResolvedValue({
data: [{ countryCode: 'DE', name: 'Germany' }, { countryCode: 'FR', name: 'France' }],
}),
};
import { VacayService } from '../../src/nest/vacay/vacay.service';
// Stub VacayService.getCountries to avoid a real HTTP call to nager.at (the
// legacy path-level partial mock; the service is DI-native now, so spy on the
// prototype instead).
vi.spyOn(VacayService.prototype, 'getCountries').mockResolvedValue({
data: [{ countryCode: 'DE', name: 'Germany' }, { countryCode: 'FR', name: 'France' }],
});
import { buildApp } from '../../src/bootstrap';
+26 -15
View File
@@ -1,12 +1,15 @@
/**
* Unit tests for MCP vacay tools (vacay addon-gated):
* Unit tests for MCP vacay tools (vacay addon-gated, VacayMcp — DI-discovered
* via the hand-built test registry in tests/helpers/mcp-test-controllers.ts):
* get_vacay_plan, update_vacay_plan, set_vacay_color,
* list_vacay_years, add_vacay_year, delete_vacay_year,
* get_vacay_entries, toggle_vacay_entry, toggle_company_holiday,
* get_vacay_stats, update_vacay_stats,
* add_holiday_calendar, update_holiday_calendar, delete_holiday_calendar,
* list_holiday_countries, list_holidays.
* Resources: trek://vacay/plan, trek://vacay/entries/{year}.
* Resources: trek://vacay/plan, trek://vacay/entries/{year},
* trek://vacay/holidays/{year} — these ride the registry too (attached inside
* registerTools), so `withTools` must stay on even for resource reads.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
@@ -44,19 +47,6 @@ vi.mock('../../../src/services/adminService', () => ({
getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }),
}));
// Mock async service functions that make external calls
vi.mock('../../../src/services/vacayService', async (importOriginal) => {
const original = await importOriginal() as Record<string, unknown>;
return {
...original,
updatePlan: vi.fn().mockResolvedValue({
plan: { id: 1, block_weekends: true, holidays_enabled: false, company_holidays_enabled: false, carry_over_enabled: false, holiday_calendars: [] },
}),
getCountries: vi.fn().mockResolvedValue({ data: [{ code: 'US', name: 'United States' }] }),
getHolidays: vi.fn().mockResolvedValue({ data: [{ date: '2025-01-01', name: 'New Year' }] }),
};
});
// share_vacay_calendar fires a user notification after inserting; stub it out
vi.mock('../../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
@@ -65,6 +55,16 @@ import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
import { VacayService } from '../../../src/nest/vacay/vacay.service';
// Stub the async methods that make external calls (VacayService is DI-native;
// the registry constructs a real instance, so spy on the prototype — the
// successor of the legacy path-level partial mock of services/vacayService).
vi.spyOn(VacayService.prototype, 'updatePlan').mockResolvedValue({
plan: { id: 1, block_weekends: true, holidays_enabled: false, company_holidays_enabled: false, carry_over_enabled: false, holiday_calendars: [] },
} as never);
vi.spyOn(VacayService.prototype, 'getCountries').mockResolvedValue({ data: [{ code: 'US', name: 'United States' }] });
vi.spyOn(VacayService.prototype, 'getHolidays').mockResolvedValue({ data: [{ date: '2025-01-01', name: 'New Year' }] });
beforeAll(() => {
createTables(testDb);
@@ -602,3 +602,14 @@ describe('Resource: trek://vacay/entries/{year}', () => {
});
});
});
describe('Resource: trek://vacay/holidays/{year}', () => {
it('returns [] while public holidays are disabled or no region is set', async () => {
const { user } = createUser(testDb);
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: 'trek://vacay/holidays/2025' });
const data = parseResourceResult(result) as unknown[];
expect(data).toEqual([]);
});
});
});
@@ -4,8 +4,9 @@
* per-plugin data db is cached, a granted db:own call works through the wired
* host, and trip broadcasts are force-namespaced to plugin:{id}:{event}.
* DI-native domains (budget/reservations/tags/categories/todo/packing/
* day-notes/assignments/oauth/llm-config/files) are constructor-injected stubs;
* legacy services/* domains stay path-mocked until their own DI migration lands.
* day-notes/assignments/oauth/llm-config/files/collab/vacay) are
* constructor-injected stubs; legacy services/* domains stay path-mocked until
* their own DI migration lands.
*/
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
import fs from 'node:fs';
@@ -268,12 +269,6 @@ vi.mock('../../../src/services/atlasService', () => ({
createBucketItem: vi.fn((uid: number, data: { name: string }) => ({ id: 110, user_id: uid, name: data.name })),
deleteBucketItem: vi.fn((_uid: number, itemId: number) => Number(itemId) !== 404),
}));
vi.mock('../../../src/services/vacayService', () => ({
getPlanData: vi.fn((uid: number) => ({ plan: { id: 1, owner: uid } })),
getActivePlanId: vi.fn(() => 77),
toggleEntry: vi.fn((uid: number, planId: number) => ({ action: 'added', uid, planId })),
toggleCompanyHoliday: vi.fn((planId: number) => ({ action: 'added', planId })),
}));
vi.mock('../../../src/services/collectionsService', () => {
const httpError = (status: number, message: string) => { const e = new Error(message) as Error & { status: number }; e.status = status; throw e; };
return {
@@ -297,6 +292,14 @@ const dayNotesStub = {
dayExists: vi.fn((dayId: number) => dayId === 3),
} as unknown as DayNotesService;
// Vacay is a constructor-injected stub (same behaviors as the old path mock).
const vacayStub = {
getPlanData: vi.fn((uid: number) => ({ plan: { id: 1, owner: uid } })),
getActivePlanId: vi.fn(() => 77),
toggleEntry: vi.fn((uid: number, planId: number) => ({ action: 'added', uid, planId })),
toggleCompanyHoliday: vi.fn((planId: number) => ({ action: 'added', planId })),
} as unknown as VacayService;
import { PluginHostDepsFactory, type PluginCallRouter } from '../../../src/nest/plugins/host/plugin-host-deps.factory';
import { getPluginDataDb, closePluginDataDb } from '../../../src/nest/plugins/host/plugin-host-state';
import { db as mockDb } from '../../../src/db/database';
@@ -312,12 +315,13 @@ import type { PluginOAuthService } from '../../../src/nest/plugins/plugin-oauth.
import type { LlmConfigResolver } from '../../../src/nest/llm-parse/llm-config.resolver';
import type { FilesService } from '../../../src/nest/files/files.service';
import type { CollabService } from '../../../src/nest/collab/collab.service';
import type { VacayService } from '../../../src/nest/vacay/vacay.service';
import { DatabaseService } from '../../../src/nest/database/database.service';
// The factory under test, wired exactly like PluginsModule does — but with the
// DI-native domain services replaced by the stubs above. The shim keeps the
// ~45 historical call sites unchanged and supplies a default no-op router.
const factory = new PluginHostDepsFactory(budgetStub, reservationsStub, tagsStub, categoriesStub, todoStub, packingStub, oauthStub, dayNotesStub, assignmentsStub, llmConfigStub, new DatabaseService(mockDb), filesStub, collabStub);
const factory = new PluginHostDepsFactory(budgetStub, reservationsStub, tagsStub, categoriesStub, todoStub, packingStub, oauthStub, dayNotesStub, assignmentsStub, llmConfigStub, new DatabaseService(mockDb), filesStub, collabStub, vacayStub);
const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: () => {} };
const createRealRpcHost = (id: string, granted: ReadonlySet<string>, router: PluginCallRouter = stubRouter) => factory.create(id, granted, router);