mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +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
Reference in New Issue
Block a user