mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
chore: move vacay backend to proper DI
This commit is contained in:
@@ -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')) {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)) });
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 ?? []);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+235
-241
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user