diff --git a/server/src/mcp/resources.ts b/server/src/mcp/resources.ts index fa67c95cf..35d3b989c 100644 --- a/server/src/mcp/resources.ts +++ b/server/src/mcp/resources.ts @@ -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')) { diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index 017872240..56cc97738 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -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). diff --git a/server/src/mcp/tools/vacay.ts b/server/src/mcp/tools/vacay.ts deleted file mode 100644 index f66b8e5a4..000000000 --- a/server/src/mcp/tools/vacay.ts +++ /dev/null @@ -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)) }); - } - ); - } -} diff --git a/server/src/nest/README.md b/server/src/nest/README.md index 58e5ff202..4b92030eb 100644 --- a/server/src/nest/README.md +++ b/server/src/nest/README.md @@ -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 diff --git a/server/src/nest/plugins/DI-MIGRATION.md b/server/src/nest/plugins/DI-MIGRATION.md index d9152ebc1..1bd313edc 100644 --- a/server/src/nest/plugins/DI-MIGRATION.md +++ b/server/src/nest/plugins/DI-MIGRATION.md @@ -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. diff --git a/server/src/nest/plugins/host/plugin-host-deps.factory.ts b/server/src/nest/plugins/host/plugin-host-deps.factory.ts index 7ee102550..5f00be769 100644 --- a/server/src/nest/plugins/host/plugin-host-deps.factory.ts +++ b/server/src/nest/plugins/host/plugin-host-deps.factory.ts @@ -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) => { diff --git a/server/src/nest/plugins/plugins.module.ts b/server/src/nest/plugins/plugins.module.ts index 3f38b8484..60b35665b 100644 --- a/server/src/nest/plugins/plugins.module.ts +++ b/server/src/nest/plugins/plugins.module.ts @@ -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 diff --git a/server/src/nest/vacay/vacay.bridge.ts b/server/src/nest/vacay/vacay.bridge.ts new file mode 100644 index 000000000..4a2ff99c9 --- /dev/null +++ b/server/src/nest/vacay/vacay.bridge.ts @@ -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); +} diff --git a/server/src/nest/vacay/vacay.mcp.ts b/server/src/nest/vacay/vacay.mcp.ts new file mode 100644 index 000000000..ea3d64d8a --- /dev/null +++ b/server/src/nest/vacay/vacay.mcp.ts @@ -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, 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, 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, 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, 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, _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, 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 ?? []); + } +} diff --git a/server/src/nest/vacay/vacay.module.ts b/server/src/nest/vacay/vacay.module.ts index dec2befbc..f4e6b3116 100644 --- a/server/src/nest/vacay/vacay.module.ts +++ b/server/src/nest/vacay/vacay.module.ts @@ -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 {} diff --git a/server/src/nest/vacay/vacay.service.ts b/server/src/nest/vacay/vacay.service.ts index e9ad32a4f..423232720 100644 --- a/server/src/nest/vacay/vacay.service.ts +++ b/server/src/nest/vacay/vacay.service.ts @@ -1,156 +1,1241 @@ import { Injectable } from '@nestjs/common'; -import * as svc from '../../services/vacayService'; +import { broadcastToUser } from '../../websocket'; +import { DatabaseService } from '../database/database.service'; -type UpdatePlanBody = Parameters[1]; -// The wire contract admits holidays_region: null (the MCP tool already passes -// null through and SQLite stores NULL); the legacy UpdatePlanBody type is -// narrower than the runtime. Widened here at the wrapper seam. -type UpdatePlanInput = Omit & { holidays_region?: string | null }; +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface VacayPlan { + id: number; + owner_id: number; + block_weekends: number; + holidays_enabled: number; + holidays_region: string | null; + school_holidays_enabled: number; + company_holidays_enabled: number; + carry_over_enabled: number; +} + +export interface VacayUserYear { + user_id: number; + plan_id: number; + year: number; + vacation_days: number; + carried_over: number; +} + +export interface VacayUser { + id: number; + username: string; + email: string; +} + +export interface VacayPlanMember { + id: number; + plan_id: number; + user_id: number; + status: string; + created_at?: string; +} + +export interface Holiday { + date: string; + localName?: string; + name?: string; + global?: boolean; + counties?: string[] | null; +} + +export interface SchoolHoliday { + startDate?: string; + endDate?: string; + name?: Array<{ language?: string; text?: string }> | string; +} + +export interface VacayHolidayCalendar { + id: number; + plan_id: number; + type: 'public_holiday' | 'school_holiday'; + region: string; + label: string | null; + color: string; + sort_order: number; +} + +export interface VacayUserSettings { + user_id: number; + year_type: 'calendar' | 'fiscal' | 'anniversary'; + year_start_month: number; + year_start_day: number; + hire_date: string | null; +} + +export interface UpdatePlanBody { + block_weekends?: boolean; + holidays_enabled?: boolean; + // null clears the legacy single-region field (the MCP tool and the shared + // request schema both pass null through; SQLite stores NULL). + holidays_region?: string | null; + school_holidays_enabled?: boolean; + company_holidays_enabled?: boolean; + carry_over_enabled?: boolean; + weekend_days?: string; + week_start?: number; +} + +export interface VacayShare { + id: number; + owner_id: number; + user_id: number; + hidden: number; + created_at?: string; +} + +// --------------------------------------------------------------------------- +// Holiday cache (shared in-process) +// --------------------------------------------------------------------------- +// Deliberately module-level, exactly like the legacy services/vacayService.ts: +// the cache is process-wide, so a bridge- or test-constructed second instance +// shares it. DI-ifying it is a behavior change reserved for a fix commit. + +const holidayCache = new Map(); +const CACHE_TTL = 24 * 60 * 60 * 1000; + +// --------------------------------------------------------------------------- +// Color palette for auto-assign +// --------------------------------------------------------------------------- + +const COLORS = [ + '#6366f1', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444', + '#3b82f6', '#22c55e', '#06b6d4', '#f43f5e', '#a855f7', + '#10b981', '#0ea5e9', '#64748b', '#be185d', '#0d9488', +]; + +// --------------------------------------------------------------------------- +// Pure helpers (no DB access) +// --------------------------------------------------------------------------- + +/** Coerce an arbitrary input to a supported entry fraction: half (0.5) or full (1). */ +function normalizeFraction(value: unknown): number { + return Number(value) === 0.5 ? 0.5 : 1; +} + +/** Coerce an arbitrary input to a supported leave type: comp/flex or vacation (#1074). */ +function normalizeKind(value: unknown): 'vacation' | 'comp' { + return value === 'comp' ? 'comp' : 'vacation'; +} + +const pad2 = (n: number) => String(n).padStart(2, '0'); + +/** Days a month has in every year — February caps at 28 so no boundary lands on a date a common year lacks. */ +function daysAlwaysInMonth(month: number): number { + if (month === 2) return 28; + return [4, 6, 9, 11].includes(month) ? 30 : 31; +} + +/** Coerce an arbitrary input to a supported leave-year type (#737). */ +function normalizeYearType(value: unknown): 'calendar' | 'fiscal' | 'anniversary' { + return value === 'fiscal' || value === 'anniversary' ? value : 'calendar'; +} /** - * Thin Nest wrapper around the existing vacay service. All plan logic, the - * holiday-calendar handling, invite flow and the WebSocket broadcasts (driven by - * the forwarded socket id) stay in vacayService, so behaviour is unchanged. + * The calendar year a window's last day falls in. `end` is exclusive, so a window + * ending on Jan 1 (the calendar default) stops inside the previous year. + */ +function windowEndYear(end: string): number { + const y = parseInt(end.slice(0, 4), 10); + return end.endsWith('-01-01') ? y - 1 : y; +} + +/** + * Vacay domain service — owns the vacay SQL (moved 1:1 from the legacy + * services/vacayService.ts: identical statements, the `||` falsy-coercion + * defaults next to `??` ones, the post-write re-selects, the dynamic SET-list + * updates, and the deliberately unwrapped multi-statement writes — the legacy + * module used no transactions, and adding them is a behavior change reserved + * for a fix commit). Broadcasts go straight to `broadcastToUser` inside the + * same try/catch swallows the legacy lazy require sat in; notifications stay + * fire-and-forget dynamic imports. The nager.at / openholidaysapi.org fetch + * quirks (no timeout, no resp.ok check on the nager endpoints, TTL-less cache + * read in applyHolidayCalendars) are preserved as-is. + * The only consumer outside the Nest container is the legacy tripService, + * which goes through vacay.bridge.ts instead of importing this class. */ @Injectable() export class VacayService { - getPlanData(userId: number) { - return svc.getPlanData(userId); + constructor(private readonly db: DatabaseService) {} + + // ------------------------------------------------------------------------- + // Entitlement helpers + // ------------------------------------------------------------------------- + + /** + * Vacation days a user has used in a year — the SUM of entry fractions, so a + * half day (#552) counts as 0.5 and a full day as 1. Entries predating the + * feature have fraction = 1, so this matches the old COUNT(*) for them. + */ + private usedDays(userId: number, planId: number, year: number): number { + // Comp/Flex days (#1074) are free — kind='comp' contributes 0 to the entitlement, + // vacation days contribute their fraction. Entries predating the column are + // 'vacation' by default. The window (#737) is the user's leave-year period; for + // 'calendar' it is Jan 1 – Dec 31, byte-identical to the old date-prefix match. + const { start, end } = this.resolveYearWindow(userId, year); + const row = this.db.get<{ used: number }>( + "SELECT COALESCE(SUM(CASE WHEN kind = 'comp' THEN 0 ELSE fraction END), 0) AS used FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date >= ? AND date < ?", + userId, planId, start, end + )!; + return row.used; + } + + /** Comp/Flex days (#1074) used in a user's leave-year period — SUM of fractions for kind='comp'. */ + private compUsedDays(userId: number, planId: number, year: number): number { + const { start, end } = this.resolveYearWindow(userId, year); + const row = this.db.get<{ used: number }>( + "SELECT COALESCE(SUM(fraction), 0) AS used FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date >= ? AND date < ? AND kind = 'comp'", + userId, planId, start, end + )!; + return row.used; + } + + // ------------------------------------------------------------------------- + // Configurable vacation year (#737) + // ------------------------------------------------------------------------- + + getUserYearSettings(userId: number): VacayUserSettings | undefined { + return this.db.get('SELECT * FROM vacay_user_settings WHERE user_id = ?', userId); + } + + /** A user's leave-year settings with the calendar defaults filled in (#737). */ + getYearSettings(userId: number): VacayUserSettings { + return this.getUserYearSettings(userId) ?? { user_id: userId, year_type: 'calendar', year_start_month: 1, year_start_day: 1, hire_date: null }; + } + + /** + * Resolve a user's leave-year window for a period (#737). The `year` integer names + * the period; 'calendar' returns Jan 1 – Dec 31 (the unchanged default, byte-identical + * to the old `date LIKE 'YYYY-%'`), 'fiscal' starts on the configured month/day, and + * 'anniversary' on the hire date's month/day. Window is [start, end) — start inclusive, + * end exclusive. Because periods are consecutive, `year-1` is always the window that + * ends where `year`'s begins, so the carry-over chains stay valid unchanged. + */ + resolveYearWindow(userId: number, year: number): { start: string; end: string } { + const s = this.getUserYearSettings(userId); + // 'anniversary' before a hire date is entered has nothing to anchor to, so it + // reads as the calendar default rather than borrowing a month left behind by a + // previous 'fiscal' setting — the client mirror resolves it the same way. + if (!s || s.year_type === 'calendar' || (s.year_type === 'anniversary' && !s.hire_date)) { + return { start: `${year}-01-01`, end: `${year + 1}-01-01` }; + } + let month = s.year_start_month || 1; + let day = s.year_start_day || 1; + if (s.year_type === 'anniversary') { + const parts = s.hire_date!.split('-'); + month = parseInt(parts[1], 10) || 1; + day = parseInt(parts[2], 10) || 1; + } + month = Math.min(12, Math.max(1, month)); + // A Feb 29 hire date (or a stored Feb 30) would name a boundary that most years + // simply do not have, and a string comparison against it silently shifts the + // whole window. Clamp to a day every year really has. + day = Math.min(day, daysAlwaysInMonth(month)); + return { start: `${year}-${pad2(month)}-${pad2(day)}`, end: `${year + 1}-${pad2(month)}-${pad2(day)}` }; + } + + /** + * The [start, end) range a year identifier covers when read on someone's behalf + * (#737). Without a viewer — MCP resources, which are plan-scoped — it stays the + * plain calendar year, which is exactly what a 'calendar' user resolves to. + */ + private viewerWindow(year: number | string, viewerId?: number): { start: string; end: string } { + const y = typeof year === 'number' ? year : parseInt(year, 10); + // A non-numeric year matched nothing under the old date prefix; an empty range + // matches nothing either, so a bad parameter still yields an empty result. + if (!Number.isFinite(y)) return { start: '', end: '' }; + if (viewerId == null) return { start: `${y}-01-01`, end: `${y + 1}-01-01` }; + return this.resolveYearWindow(viewerId, y); + } + + /** + * The range the calendar grid renders for a period: the twelve whole months the + * window starts in. For every window that begins on the 1st — all of 'calendar', + * and a fiscal year set to a month start — this is exactly the counting window. + * + * Known limitation for a start day past the 1st (UK's Apr 6, an Oct 16 hire date): + * the rendered range is month-aligned at both ends, so it is shifted rather than + * widened. The first few days of the start month are drawn but belong to the + * previous period, and the equally few days at the far end count here but fall + * outside the twelve cards. Entitlement arithmetic stays day-exact either way; + * only the grid's edges are approximate. Rendering a 13th month card would fix it + * and was deliberately not taken. + */ + private viewerGridWindow(year: number | string, viewerId?: number): { start: string; end: string } { + const w = this.viewerWindow(year, viewerId); + if (!w.start) return w; + return { start: `${w.start.slice(0, 7)}-01`, end: `${w.end.slice(0, 7)}-01` }; + } + + /** + * The period identifier whose window contains `date` for this user (#737). With a + * window that starts later in the year, today still belongs to the period named + * after the previous calendar year — 'calendar' users always get today's year. + */ + currentPeriodYear(userId: number, date = new Date()): number { + const y = date.getFullYear(); + const iso = `${y}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`; + return iso < this.resolveYearWindow(userId, y).start ? y - 1 : y; + } + + /** Upsert a user's leave-year settings (#737). */ + updateYearSettings( + userId: number, + data: { year_type?: unknown; year_start_month?: unknown; year_start_day?: unknown; hire_date?: unknown }, + ): VacayUserSettings { + const type = normalizeYearType(data.year_type); + const month = Math.min(12, Math.max(1, parseInt(String(data.year_start_month ?? 1), 10) || 1)); + const day = Math.min(31, Math.max(1, parseInt(String(data.year_start_day ?? 1), 10) || 1)); + const hire = typeof data.hire_date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(data.hire_date) ? data.hire_date : null; + this.db.run(` + INSERT INTO vacay_user_settings (user_id, year_type, year_start_month, year_start_day, hire_date) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET year_type = excluded.year_type, year_start_month = excluded.year_start_month, + year_start_day = excluded.year_start_day, hire_date = excluded.hire_date + `, userId, type, month, day, hire); + return this.getUserYearSettings(userId)!; + } + + // ------------------------------------------------------------------------- + // Plan management + // ------------------------------------------------------------------------- + + getOwnPlan(userId: number): VacayPlan { + let plan = this.db.get('SELECT * FROM vacay_plans WHERE owner_id = ?', userId); + if (!plan) { + this.db.run('INSERT INTO vacay_plans (owner_id) VALUES (?)', userId); + plan = this.db.get('SELECT * FROM vacay_plans WHERE owner_id = ?', userId)!; + // Seed the period today falls into — with a shifted leave year (#737) that is + // not necessarily the current calendar year. + const yr = this.currentPeriodYear(userId); + this.db.run('INSERT OR IGNORE INTO vacay_years (plan_id, year) VALUES (?, ?)', plan.id, yr); + this.db.run('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)', userId, plan.id, yr); + this.db.run('INSERT OR IGNORE INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)', userId, plan.id, '#6366f1'); + } + return plan; + } + + getActivePlan(userId: number): VacayPlan { + const membership = this.db.get<{ plan_id: number }>(` + SELECT plan_id FROM vacay_plan_members WHERE user_id = ? AND status = 'accepted' + `, userId); + if (membership) { + return this.db.get('SELECT * FROM vacay_plans WHERE id = ?', membership.plan_id)!; + } + return this.getOwnPlan(userId); } getActivePlanId(userId: number): number { - return svc.getActivePlanId(userId); + return this.getActivePlan(userId).id; } - getActivePlan(userId: number) { - return svc.getActivePlan(userId); + shiftOwnerEntriesForTripWindow( + ownerId: number, + oldStart: string, + oldEnd: string, + newStart: string + ): void { + const row = this.db.get<{ days: number }>( + 'SELECT CAST(julianday(?) - julianday(?) AS INTEGER) AS days', + newStart, oldStart + ); + const offset = row?.days ?? 0; + if (offset === 0) return; + + const plan = this.getOwnPlan(ownerId); + + this.db.run( + `UPDATE OR IGNORE vacay_entries + SET date = date(date, ? || ' days') + WHERE plan_id = ? + AND user_id = ? + AND date BETWEEN ? AND ?`, + `${offset >= 0 ? '+' : ''}${offset}`, plan.id, ownerId, oldStart, oldEnd + ); } - updatePlan(planId: number, body: UpdatePlanInput, socketId: string | undefined) { - return svc.updatePlan(planId, body as UpdatePlanBody, socketId); + getPlanUsers(planId: number): VacayUser[] { + const plan = this.db.get('SELECT * FROM vacay_plans WHERE id = ?', planId); + if (!plan) return []; + const owner = this.db.get('SELECT id, username, email FROM users WHERE id = ?', plan.owner_id)!; + const members = this.db.all(` + SELECT u.id, u.username, u.email FROM vacay_plan_members m + JOIN users u ON m.user_id = u.id + WHERE m.plan_id = ? AND m.status = 'accepted' + `, planId); + return [owner, ...members]; } - addHolidayCalendar(planId: number, region: string, label: string | null, color: string | undefined, sortOrder: number | undefined, socketId: string | undefined, type?: 'public_holiday' | 'school_holiday') { - return svc.addHolidayCalendar(planId, region, label, color, sortOrder, socketId, type); + // ------------------------------------------------------------------------- + // WebSocket notifications + // ------------------------------------------------------------------------- + + notifyPlanUsers(planId: number, excludeSid: string | undefined, event = 'vacay:update'): void { + try { + const plan = this.db.get<{ owner_id: number }>('SELECT owner_id FROM vacay_plans WHERE id = ?', planId); + if (!plan) return; + const userIds = [plan.owner_id]; + const members = this.db.all<{ user_id: number }>("SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted'", planId); + members.forEach(m => userIds.push(m.user_id)); + userIds.forEach(id => broadcastToUser(id, { type: event }, excludeSid)); + // Pending-invite events carry nothing a read-only viewer could see; every + // other event may change entries, colors or company holidays. + if (event !== 'vacay:invite' && event !== 'vacay:declined' && event !== 'vacay:cancelled') { + this.notifyShareViewers(userIds, excludeSid); + } + } catch { /* websocket not available */ } } - updateHolidayCalendar(id: number, planId: number, body: Parameters[2], socketId: string | undefined) { - return svc.updateHolidayCalendar(id, planId, body, socketId); + notifyShareViewers(ownerIds: number[], excludeSid?: string): void { + if (ownerIds.length === 0) return; + try { + const rows = this.db.all<{ user_id: number }>( + `SELECT DISTINCT user_id FROM vacay_shares WHERE owner_id IN (${ownerIds.map(() => '?').join(',')})`, + ...ownerIds + ); + rows.forEach(r => broadcastToUser(r.user_id, { type: 'vacay:shared-update' }, excludeSid)); + } catch { /* websocket not available */ } } - deleteHolidayCalendar(id: number, planId: number, socketId: string | undefined): boolean { - return svc.deleteHolidayCalendar(id, planId, socketId); + // ------------------------------------------------------------------------- + // Holiday calendar helpers + // ------------------------------------------------------------------------- + + async applyHolidayCalendars(planId: number): Promise { + const plan = this.db.get<{ holidays_enabled: number }>('SELECT holidays_enabled FROM vacay_plans WHERE id = ?', planId); + if (!plan?.holidays_enabled) return; + const calendars = this.db.all("SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? AND type = 'public_holiday' ORDER BY sort_order, id", planId); + if (calendars.length === 0) return; + const years = this.db.all<{ year: number }>('SELECT year FROM vacay_years WHERE plan_id = ?', planId); + // A shifted leave year (#737) runs into the next calendar year, so collect the + // calendar years the members' windows actually touch — not just the period ids. + // With everyone on 'calendar' this is the same set as before. + const members = this.getPlanUsers(planId); + const calendarYears = new Set(); + for (const { year } of years) { + calendarYears.add(year); + for (const m of members) calendarYears.add(windowEndYear(this.resolveYearWindow(m.id, year).end)); + } + for (const cal of calendars) { + const country = cal.region.split('-')[0]; + const region = cal.region.includes('-') ? cal.region : null; + for (const year of calendarYears) { + try { + const cacheKey = `${year}-${country}`; + let holidays = holidayCache.get(cacheKey)?.data as Holiday[] | undefined; + if (!holidays) { + const resp = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/${country}`); + holidays = await resp.json() as Holiday[]; + holidayCache.set(cacheKey, { data: holidays, time: Date.now() }); + } + const hasRegions = holidays.some((h: Holiday) => h.counties && h.counties.length > 0); + if (hasRegions && !region) continue; + for (const h of holidays) { + if (h.global || !h.counties || (region && h.counties.includes(region))) { + this.db.run('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?', planId, h.date); + this.db.run('DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date = ?', planId, h.date); + } + } + } catch { /* API error, skip */ } + } + } } - getPlanUsers(planId: number) { - return svc.getPlanUsers(planId); + async migrateHolidayCalendars(planId: number, plan: VacayPlan): Promise { + const existing = this.db.get('SELECT id FROM vacay_holiday_calendars WHERE plan_id = ?', planId); + if (existing) return; + if (plan.holidays_enabled && plan.holidays_region) { + this.db.run( + 'INSERT INTO vacay_holiday_calendars (plan_id, region, label, color, sort_order) VALUES (?, ?, NULL, ?, 0)', + planId, plan.holidays_region, '#fecaca' + ); + } } + // ------------------------------------------------------------------------- + // Plan settings + // ------------------------------------------------------------------------- + + async updatePlan(planId: number, body: UpdatePlanBody, socketId: string | undefined) { + const { block_weekends, holidays_enabled, holidays_region, school_holidays_enabled, company_holidays_enabled, carry_over_enabled, weekend_days, week_start } = body; + + const updates: string[] = []; + const params: (string | number | null)[] = []; + if (block_weekends !== undefined) { updates.push('block_weekends = ?'); params.push(block_weekends ? 1 : 0); } + if (holidays_enabled !== undefined) { updates.push('holidays_enabled = ?'); params.push(holidays_enabled ? 1 : 0); } + if (holidays_region !== undefined) { updates.push('holidays_region = ?'); params.push(holidays_region); } + if (school_holidays_enabled !== undefined) { updates.push('school_holidays_enabled = ?'); params.push(school_holidays_enabled ? 1 : 0); } + if (company_holidays_enabled !== undefined) { updates.push('company_holidays_enabled = ?'); params.push(company_holidays_enabled ? 1 : 0); } + if (carry_over_enabled !== undefined) { updates.push('carry_over_enabled = ?'); params.push(carry_over_enabled ? 1 : 0); } + if (weekend_days !== undefined) { updates.push('weekend_days = ?'); params.push(String(weekend_days)); } + if (week_start !== undefined) { updates.push('week_start = ?'); params.push(week_start === 0 ? 0 : 1); } + + if (updates.length > 0) { + params.push(planId); + this.db.run(`UPDATE vacay_plans SET ${updates.join(', ')} WHERE id = ?`, ...params); + } + + if (company_holidays_enabled === true) { + const companyDates = this.db.all<{ date: string }>('SELECT date FROM vacay_company_holidays WHERE plan_id = ?', planId); + for (const { date } of companyDates) { + this.db.run('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?', planId, date); + } + } + + const updatedPlan = this.db.get('SELECT * FROM vacay_plans WHERE id = ?', planId)!; + await this.migrateHolidayCalendars(planId, updatedPlan); + await this.applyHolidayCalendars(planId); + + if (carry_over_enabled === false) { + this.db.run('UPDATE vacay_user_years SET carried_over = 0 WHERE plan_id = ?', planId); + } + + if (carry_over_enabled === true) { + const years = this.db.all<{ year: number }>('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year', planId); + const users = this.getPlanUsers(planId); + for (let i = 0; i < years.length - 1; i++) { + const yr = years[i].year; + const nextYr = years[i + 1].year; + for (const u of users) { + const used = this.usedDays(u.id, planId, yr); + const config = this.db.get('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?', u.id, planId, yr); + const total = (config ? config.vacation_days : 30) + (config ? config.carried_over : 0); + const carry = Math.max(0, total - used); + this.db.run(` + INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?) + ON CONFLICT(user_id, plan_id, year) DO UPDATE SET carried_over = ? + `, u.id, planId, nextYr, carry, carry); + } + } + } + + this.notifyPlanUsers(planId, socketId, 'vacay:settings'); + + const updated = this.db.get('SELECT * FROM vacay_plans WHERE id = ?', planId)!; + const updatedCalendars = this.db.all('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id', planId); + return { + plan: { + ...updated, + block_weekends: !!updated.block_weekends, + holidays_enabled: !!updated.holidays_enabled, + school_holidays_enabled: !!updated.school_holidays_enabled, + company_holidays_enabled: !!updated.company_holidays_enabled, + carry_over_enabled: !!updated.carry_over_enabled, + holiday_calendars: updatedCalendars, + }, + }; + } + + // ------------------------------------------------------------------------- + // Holiday calendars CRUD + // ------------------------------------------------------------------------- + + addHolidayCalendar(planId: number, region: string, label: string | null, color: string | undefined, sortOrder: number | undefined, socketId: string | undefined, type: 'public_holiday' | 'school_holiday' = 'public_holiday') { + const result = this.db.run( + 'INSERT INTO vacay_holiday_calendars (plan_id, type, region, label, color, sort_order) VALUES (?, ?, ?, ?, ?, ?)', + planId, type, region, label || null, color || (type === 'school_holiday' ? '#a5f3fc' : '#fecaca'), sortOrder ?? 0 + ); + const cal = this.db.get('SELECT * FROM vacay_holiday_calendars WHERE id = ?', result.lastInsertRowid)!; + this.notifyPlanUsers(planId, socketId, 'vacay:settings'); + return cal; + } + + updateHolidayCalendar( + calId: number, + planId: number, + body: { region?: string; label?: string | null; color?: string; sort_order?: number; type?: 'public_holiday' | 'school_holiday' }, + socketId: string | undefined, + ): VacayHolidayCalendar | null { + const cal = this.db.get('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?', calId, planId); + if (!cal) return null; + const { region, label, color, sort_order, type } = body; + const updates: string[] = []; + const params: (string | number | null)[] = []; + if (region !== undefined) { updates.push('region = ?'); params.push(region); } + if (type !== undefined) { updates.push('type = ?'); params.push(type); } + if (label !== undefined) { updates.push('label = ?'); params.push(label); } + if (color !== undefined) { updates.push('color = ?'); params.push(color); } + if (sort_order !== undefined) { updates.push('sort_order = ?'); params.push(sort_order); } + if (updates.length > 0) { + params.push(calId); + this.db.run(`UPDATE vacay_holiday_calendars SET ${updates.join(', ')} WHERE id = ?`, ...params); + } + const updated = this.db.get('SELECT * FROM vacay_holiday_calendars WHERE id = ?', calId)!; + this.notifyPlanUsers(planId, socketId, 'vacay:settings'); + return updated; + } + + deleteHolidayCalendar(calId: number, planId: number, socketId: string | undefined): boolean { + const cal = this.db.get('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?', calId, planId); + if (!cal) return false; + this.db.run('DELETE FROM vacay_holiday_calendars WHERE id = ?', calId); + this.notifyPlanUsers(planId, socketId, 'vacay:settings'); + return true; + } + + // ------------------------------------------------------------------------- + // User colors + // ------------------------------------------------------------------------- + setUserColor(userId: number, planId: number, color: string | undefined, socketId: string | undefined): void { - svc.setUserColor(userId, planId, color, socketId); + this.db.run(` + INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?) + ON CONFLICT(user_id, plan_id) DO UPDATE SET color = excluded.color + `, userId, planId, color || '#6366f1'); + this.notifyPlanUsers(planId, socketId, 'vacay:update'); } - sendInvite(planId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number) { - return svc.sendInvite(planId, inviterId, inviterUsername, inviterEmail, targetUserId); + // ------------------------------------------------------------------------- + // Invitations + // ------------------------------------------------------------------------- + + sendInvite(planId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number): { error?: string; status?: number } { + if (targetUserId === inviterId) return { error: 'Cannot invite yourself', status: 400 }; + + const targetUser = this.db.get('SELECT id, username FROM users WHERE id = ?', targetUserId); + if (!targetUser) return { error: 'User not found', status: 404 }; + + const existing = this.db.get<{ id: number; status: string }>('SELECT id, status FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?', planId, targetUserId); + if (existing) { + if (existing.status === 'accepted') return { error: 'Already fused', status: 400 }; + if (existing.status === 'pending') return { error: 'Invite already pending', status: 400 }; + } + + const targetFusion = this.db.get("SELECT id FROM vacay_plan_members WHERE user_id = ? AND status = 'accepted'", targetUserId); + if (targetFusion) return { error: 'User is already fused with another plan', status: 400 }; + + this.db.run('INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, ?)', planId, targetUserId, 'pending'); + + try { + broadcastToUser(targetUserId, { + type: 'vacay:invite', + from: { id: inviterId, username: inviterUsername }, + planId, + }); + } catch { /* websocket not available */ } + + // Notify invited user + import('../../services/notificationService').then(({ send }) => { + send({ event: 'vacay_invite', actorId: inviterId, scope: 'user', targetId: targetUserId, params: { actor: inviterEmail, planId: String(planId) } }).catch(() => {}); + }); + + return {}; } - acceptInvite(userId: number, planId: number, socketId: string | undefined) { - return svc.acceptInvite(userId, planId, socketId); + acceptInvite(userId: number, planId: number, socketId: string | undefined): { error?: string; status?: number } { + const invite = this.db.get("SELECT * FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'", planId, userId); + if (!invite) return { error: 'No pending invite', status: 404 }; + + this.db.run("UPDATE vacay_plan_members SET status = 'accepted' WHERE id = ?", invite.id); + + // Migrate data from user's own plan + const ownPlan = this.db.get<{ id: number }>('SELECT id FROM vacay_plans WHERE owner_id = ?', userId); + if (ownPlan && ownPlan.id !== planId) { + this.db.run('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?', planId, ownPlan.id, userId); + const ownYears = this.db.all('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ?', userId, ownPlan.id); + for (const y of ownYears) { + this.db.run('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, ?, ?)', userId, planId, y.year, y.vacation_days, y.carried_over); + } + const colorRow = this.db.get<{ color: string }>('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?', userId, ownPlan.id); + if (colorRow) { + this.db.run('INSERT OR IGNORE INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)', userId, planId, colorRow.color); + } + } + + // Auto-assign unique color + const existingColors = this.db.all<{ color: string }>('SELECT color FROM vacay_user_colors WHERE plan_id = ? AND user_id != ?', planId, userId).map(r => r.color); + const myColor = this.db.get<{ color: string }>('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?', userId, planId); + const effectiveColor = myColor?.color || '#6366f1'; + if (existingColors.includes(effectiveColor)) { + const available = COLORS.find(c => !existingColors.includes(c)); + if (available) { + this.db.run(`INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?) + ON CONFLICT(user_id, plan_id) DO UPDATE SET color = excluded.color`, userId, planId, available); + } + } else if (!myColor) { + this.db.run('INSERT OR IGNORE INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)', userId, planId, effectiveColor); + } + + // Ensure user has rows for all plan years + const targetYears = this.db.all<{ year: number }>('SELECT year FROM vacay_years WHERE plan_id = ?', planId); + for (const y of targetYears) { + this.db.run('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)', userId, planId, y.year); + } + + this.notifyPlanUsers(planId, socketId, 'vacay:accepted'); + return {}; } declineInvite(userId: number, planId: number, socketId: string | undefined): void { - svc.declineInvite(userId, planId, socketId); + this.db.run("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'", planId, userId); + this.notifyPlanUsers(planId, socketId, 'vacay:declined'); } cancelInvite(planId: number, targetUserId: number): void { - svc.cancelInvite(planId, targetUserId); + this.db.run("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'", planId, targetUserId); + + try { + broadcastToUser(targetUserId, { type: 'vacay:cancelled' }); + } catch { /* */ } } + // ------------------------------------------------------------------------- + // Plan dissolution + // ------------------------------------------------------------------------- + dissolvePlan(userId: number, socketId: string | undefined): void { - svc.dissolvePlan(userId, socketId); + const plan = this.getActivePlan(userId); + const isOwnerFlag = plan.owner_id === userId; + + const allUserIds = this.getPlanUsers(plan.id).map(u => u.id); + const companyHolidays = this.db.all<{ date: string; note: string }>('SELECT date, note FROM vacay_company_holidays WHERE plan_id = ?', plan.id); + + if (isOwnerFlag) { + const members = this.db.all<{ user_id: number }>("SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted'", plan.id); + for (const m of members) { + const memberPlan = this.getOwnPlan(m.user_id); + this.db.run('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?', memberPlan.id, plan.id, m.user_id); + for (const ch of companyHolidays) { + this.db.run('INSERT OR IGNORE INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)', memberPlan.id, ch.date, ch.note); + } + } + this.db.run('DELETE FROM vacay_plan_members WHERE plan_id = ?', plan.id); + } else { + const ownPlan = this.getOwnPlan(userId); + this.db.run('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?', ownPlan.id, plan.id, userId); + for (const ch of companyHolidays) { + this.db.run('INSERT OR IGNORE INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)', ownPlan.id, ch.date, ch.note); + } + this.db.run('DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?', plan.id, userId); + } + + try { + allUserIds.filter(id => id !== userId).forEach(id => broadcastToUser(id, { type: 'vacay:dissolved' })); + } catch { /* */ } + // Everyone's entries just moved back to their own plans — refresh read-only viewers. + this.notifyShareViewers(allUserIds, socketId); } + // ------------------------------------------------------------------------- + // Available users + // ------------------------------------------------------------------------- + getAvailableUsers(userId: number, planId: number) { - return svc.getAvailableUsers(userId, planId); + return this.db.all(` + SELECT u.id, u.username, u.email FROM users u + WHERE u.id != ? + AND u.id NOT IN (SELECT user_id FROM vacay_plan_members WHERE plan_id = ?) + AND u.id NOT IN (SELECT user_id FROM vacay_plan_members WHERE status = 'accepted') + AND u.id NOT IN (SELECT owner_id FROM vacay_plans WHERE id IN ( + SELECT plan_id FROM vacay_plan_members WHERE status = 'accepted' + )) + ORDER BY u.username + `, userId, planId); } - listYears(planId: number): number[] { - return svc.listYears(planId); + // ------------------------------------------------------------------------- + // Read-only calendar shares (#444/#667) + // ------------------------------------------------------------------------- + // + // A share lets another user VIEW someone's vacation days without fusing plans: + // no edit rights, no data migration. The share follows the person (owner_id), + // not a plan, so it keeps working across fusion and dissolution — viewers see + // the owner's entries in whatever plan the owner is currently active in. + + /** Like getActivePlan, but never lazily creates a plan for the user. */ + private peekActivePlan(userId: number): VacayPlan | undefined { + const membership = this.db.get<{ plan_id: number }>(` + SELECT plan_id FROM vacay_plan_members WHERE user_id = ? AND status = 'accepted' + `, userId); + if (membership) { + return this.db.get('SELECT * FROM vacay_plans WHERE id = ?', membership.plan_id); + } + return this.db.get('SELECT * FROM vacay_plans WHERE owner_id = ?', userId); } - addYear(planId: number, year: number, socketId: string | undefined): number[] { - return svc.addYear(planId, year, socketId); + /** Colors already taken in the viewer's own calendar (their plan's members). */ + private viewerColors(viewerId: number): Set { + const plan = this.peekActivePlan(viewerId); + if (!plan) return new Set(['#6366f1']); + const rows = this.db.all<{ color: string }>('SELECT color FROM vacay_user_colors WHERE plan_id = ?', plan.id); + return new Set(rows.length > 0 ? rows.map(r => r.color) : ['#6366f1']); } - deleteYear(planId: number, year: number, socketId: string | undefined): number[] { - return svc.deleteYear(planId, year, socketId); + /** + * Display color for a shared calendar. Starts from the owner's own color, but + * remaps to the first free preset when it collides with the viewer's plan + * members or an earlier share — otherwise two people on the default indigo + * would be indistinguishable in the overlay. + */ + private shareDisplayColor(ownerId: number, usedColors: Set): string { + const plan = this.peekActivePlan(ownerId); + const row = plan + ? this.db.get<{ color: string }>('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?', ownerId, plan.id) + : undefined; + let color = row?.color || '#6366f1'; + if (usedColors.has(color)) { + // Preset pool exhausted? Derive a stable per-owner hue instead of colliding. + color = COLORS.find(c => !usedColors.has(c)) || `hsl(${Math.round((ownerId * 137.508) % 360)} 65% 60%)`; + } + usedColors.add(color); + return color; } - getEntries(planId: number, year: string, viewerId?: number) { - return svc.getEntries(planId, year, viewerId); - } - - getYearSettings(userId: number) { - return svc.getYearSettings(userId); - } - - updateYearSettings(userId: number, data: Parameters[1]) { - return svc.updateUserYearSettings(userId, data); - } - - toggleEntry(userId: number, planId: number, date: string, fraction: unknown, kind: unknown, socketId: string | undefined) { - return svc.toggleEntry(userId, planId, date, fraction, kind, socketId); - } - - toggleCompanyHoliday(planId: number, date: string, note: string | undefined, socketId: string | undefined) { - return svc.toggleCompanyHoliday(planId, date, note, socketId); - } - - getStats(planId: number, year: number) { - return svc.getStats(planId, year); - } - - updateStats(userId: number, planId: number, year: number, vacationDays: number, socketId: string | undefined): void { - svc.updateStats(userId, planId, year, vacationDays, socketId); - } - - getCountries() { - return svc.getCountries(); - } - - getSchoolHolidayRegions(country: string, language?: string) { - return svc.getSchoolHolidayRegions(country, language); - } - - getHolidays(year: string, country: string) { - return svc.getHolidays(year, country); + /** Users the viewer already sees in full via their active plan (owner + members). */ + private viewerCoMemberIds(viewerId: number): Set { + const plan = this.peekActivePlan(viewerId); + return new Set(plan ? this.getPlanUsers(plan.id).map(u => u.id) : []); } listShares(userId: number) { - return svc.listShares(userId); + // Usernames only, like the share picker — emails stay out of the share surface. + const outgoing = this.db.all<{ id: number; user_id: number; username: string }>(` + SELECT s.id, s.user_id, u.username + FROM vacay_shares s JOIN users u ON s.user_id = u.id + WHERE s.owner_id = ? ORDER BY s.id + `, userId); + const incomingRows = this.db.all<{ id: number; owner_id: number; hidden: number; username: string }>(` + SELECT s.id, s.owner_id, s.hidden, u.username + FROM vacay_shares s JOIN users u ON s.owner_id = u.id + WHERE s.user_id = ? ORDER BY s.id + `, userId); + // Shares from someone the viewer is meanwhile fused with lie dormant — the + // plan already shows that calendar in full. They resume after dissolution. + const coMembers = this.viewerCoMemberIds(userId); + const usedColors = this.viewerColors(userId); + const incoming = incomingRows.filter(s => !coMembers.has(s.owner_id)).map(s => ({ + id: s.id, + owner_id: s.owner_id, + username: s.username, + color: this.shareDisplayColor(s.owner_id, usedColors), + hidden: !!s.hidden, + })); + return { outgoing, incoming }; } - shareCalendar(ownerId: number, ownerEmail: string, targetUserId: number, socketId?: string) { - return svc.shareCalendar(ownerId, ownerEmail, targetUserId, socketId); + shareCalendar(ownerId: number, ownerEmail: string, targetUserId: number, socketId?: string): { error?: string; status?: number } { + if (targetUserId === ownerId) return { error: 'Cannot share with yourself', status: 400 }; + + const targetUser = this.db.get('SELECT id FROM users WHERE id = ?', targetUserId); + if (!targetUser) return { error: 'User not found', status: 404 }; + + const existing = this.db.get('SELECT id FROM vacay_shares WHERE owner_id = ? AND user_id = ?', ownerId, targetUserId); + if (existing) return { error: 'Already shared', status: 400 }; + + // Plan members already see the whole calendar — sharing with them is moot. + if (this.getPlanUsers(this.getActivePlanId(ownerId)).find(u => u.id === targetUserId)) { + return { error: 'User is already in your calendar', status: 400 }; + } + + this.db.run('INSERT INTO vacay_shares (owner_id, user_id) VALUES (?, ?)', ownerId, targetUserId); + + try { + broadcastToUser(targetUserId, { type: 'vacay:share', from: { id: ownerId } }); + // The owner's other devices refresh their outgoing list too. + broadcastToUser(ownerId, { type: 'vacay:share', from: { id: ownerId } }, socketId); + } catch { /* websocket not available */ } + + import('../../services/notificationService').then(({ send }) => { + send({ event: 'vacay_share', actorId: ownerId, scope: 'user', targetId: targetUserId, params: { actor: ownerEmail } }).catch(() => {}); + }); + + return {}; } - removeShare(shareId: number, userId: number, socketId: string | undefined): boolean { - return svc.removeShare(shareId, userId, socketId); + removeShare(shareId: number, userId: number, socketId?: string): boolean { + const share = this.db.get('SELECT * FROM vacay_shares WHERE id = ?', shareId); + // The owner revokes, the viewer removes — both may delete, nobody else. + if (!share || (share.owner_id !== userId && share.user_id !== userId)) return false; + this.db.run('DELETE FROM vacay_shares WHERE id = ?', shareId); + try { + broadcastToUser(share.owner_id, { type: 'vacay:share-removed' }, socketId); + broadcastToUser(share.user_id, { type: 'vacay:share-removed' }, socketId); + } catch { /* websocket not available */ } + return true; } - setShareHidden(shareId: number, userId: number, hidden: boolean, socketId: string | undefined): boolean { - return svc.setShareHidden(shareId, userId, hidden, socketId); + setShareHidden(shareId: number, userId: number, hidden: boolean, socketId?: string): boolean { + const share = this.db.get('SELECT * FROM vacay_shares WHERE id = ?', shareId); + if (!share || share.user_id !== userId) return false; + this.db.run('UPDATE vacay_shares SET hidden = ? WHERE id = ?', hidden ? 1 : 0, shareId); + try { + // Keep the viewer's other devices in sync; nobody else is affected. + broadcastToUser(userId, { type: 'vacay:shared-update' }, socketId); + } catch { /* websocket not available */ } + return true; } getShareAvailableUsers(userId: number) { - return svc.getShareAvailableUsers(userId); + const planId = this.getActivePlanId(userId); + // Username only — unlike the fusion picker this lists users from other plans + // too, so exposing their emails here would widen the instance directory. + return this.db.all(` + SELECT u.id, u.username FROM users u + WHERE u.id != ? + AND u.id NOT IN (SELECT user_id FROM vacay_shares WHERE owner_id = ?) + AND u.id NOT IN ( + SELECT owner_id FROM vacay_plans WHERE id = ? + UNION + SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted' + ) + ORDER BY u.username + `, userId, userId, planId, planId); } getSharedCalendars(viewerId: number, year: string) { - return svc.getSharedCalendars(viewerId, year); + // Shared calendars are drawn into the viewer's grid, so they load over the + // viewer's range (#737) even when the owner's leave year is shaped differently. + const { start, end } = this.viewerGridWindow(year, viewerId); + const shares = this.db.all<{ id: number; owner_id: number; hidden: number; username: string }>(` + SELECT s.id, s.owner_id, s.hidden, u.username + FROM vacay_shares s JOIN users u ON s.owner_id = u.id + WHERE s.user_id = ? ORDER BY s.id + `, viewerId); + + // Same dormancy rule as listShares: fused co-members are already fully visible. + const coMembers = this.viewerCoMemberIds(viewerId); + const usedColors = this.viewerColors(viewerId); + return shares.filter(s => !coMembers.has(s.owner_id)).map(s => { + const color = this.shareDisplayColor(s.owner_id, usedColors); + const plan = this.peekActivePlan(s.owner_id); + if (!plan) { + return { share_id: s.id, owner_id: s.owner_id, owner_name: s.username, color, hidden: !!s.hidden, entries: [], companyHolidays: [] }; + } + const entries = this.db.all( + 'SELECT date, fraction, kind FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date >= ? AND date < ? ORDER BY date', + plan.id, s.owner_id, start, end + ); + // Company holidays are plan-wide context for "when is this person off"; + // only exposed while the owner has the feature enabled, and dates only — + // the note text may be authored by plan members who aren't part of the share. + const companyHolidays = plan.company_holidays_enabled + ? this.db.all('SELECT date FROM vacay_company_holidays WHERE plan_id = ? AND date >= ? AND date < ? ORDER BY date', plan.id, start, end) + : []; + return { share_id: s.id, owner_id: s.owner_id, owner_name: s.username, color, hidden: !!s.hidden, entries, companyHolidays }; + }); } - getSchoolHolidays(year: string, country: string, subdivision?: string | null, language?: string, group?: string | null) { - return svc.getSchoolHolidays(year, country, subdivision, language, group); + // ------------------------------------------------------------------------- + // Years + // ------------------------------------------------------------------------- + + listYears(planId: number): number[] { + const rows = this.db.all<{ year: number }>('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year', planId); + return rows.map(y => y.year); + } + + addYear(planId: number, year: number, socketId: string | undefined): number[] { + try { + this.db.run('INSERT INTO vacay_years (plan_id, year) VALUES (?, ?)', planId, year); + const plan = this.db.get('SELECT * FROM vacay_plans WHERE id = ?', planId); + const carryOverEnabled = plan ? !!plan.carry_over_enabled : true; + const users = this.getPlanUsers(planId); + for (const u of users) { + let carriedOver = 0; + if (carryOverEnabled) { + const prevConfig = this.db.get('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?', u.id, planId, year - 1); + if (prevConfig) { + const used = this.usedDays(u.id, planId, year - 1); + const total = prevConfig.vacation_days + prevConfig.carried_over; + carriedOver = Math.max(0, total - used); + } + } + this.db.run('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?)', u.id, planId, year, carriedOver); + } + } catch { /* year already exists */ } + this.notifyPlanUsers(planId, socketId, 'vacay:settings'); + return this.listYears(planId); + } + + deleteYear(planId: number, year: number, socketId: string | undefined): number[] { + this.db.run('DELETE FROM vacay_years WHERE plan_id = ? AND year = ?', planId, year); + // Members can be on differently shaped leave years (#737), so entries go per + // author over that author's period rather than by one shared year prefix. + // Authors are read off the entries themselves so orphans are cleared too. + const authors = this.db.all<{ user_id: number }>('SELECT DISTINCT user_id FROM vacay_entries WHERE plan_id = ?', planId); + for (const { user_id } of authors) { + const { start, end } = this.resolveYearWindow(user_id, year); + this.db.run('DELETE FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date >= ? AND date < ?', planId, user_id, start, end); + } + // Company holidays belong to the plan, not to a member, and every member sees + // them over their own window. In a fused plan with mixed year types the safe + // range is therefore the intersection of all member windows — anything outside + // it still sits inside a period somebody else has not deleted. + const owner = this.db.get<{ owner_id: number }>('SELECT owner_id FROM vacay_plans WHERE id = ?', planId); + const members = this.getPlanUsers(planId); + const windows = (members.length > 0 ? members.map(m => m.id) : [owner?.owner_id ?? -1]).map(id => this.resolveYearWindow(id, year)); + const holidayStart = windows.reduce((a, w) => (w.start > a ? w.start : a), windows[0].start); + const holidayEnd = windows.reduce((a, w) => (w.end < a ? w.end : a), windows[0].end); + if (holidayStart < holidayEnd) { + this.db.run('DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date >= ? AND date < ?', planId, holidayStart, holidayEnd); + } + this.db.run('DELETE FROM vacay_user_years WHERE plan_id = ? AND year = ?', planId, year); + + // Recalculate carry-over for year+1 if it exists, since its previous year has changed + const nextYearExists = this.db.get('SELECT id FROM vacay_years WHERE plan_id = ? AND year = ?', planId, year + 1); + if (nextYearExists) { + const plan = this.db.get('SELECT * FROM vacay_plans WHERE id = ?', planId); + const carryOverEnabled = plan ? !!plan.carry_over_enabled : true; + const users = this.getPlanUsers(planId); + const prevYear = this.db.get<{ year: number }>('SELECT year FROM vacay_years WHERE plan_id = ? AND year < ? ORDER BY year DESC LIMIT 1', planId, year + 1); + + for (const u of users) { + let carry = 0; + if (carryOverEnabled && prevYear) { + const prevConfig = this.db.get('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?', u.id, planId, prevYear.year); + if (prevConfig) { + const used = this.usedDays(u.id, planId, prevYear.year); + const total = prevConfig.vacation_days + prevConfig.carried_over; + carry = Math.max(0, total - used); + } + } + this.db.run('UPDATE vacay_user_years SET carried_over = ? WHERE user_id = ? AND plan_id = ? AND year = ?', carry, u.id, planId, year + 1); + } + } + + this.notifyPlanUsers(planId, socketId, 'vacay:settings'); + return this.listYears(planId); + } + + // ------------------------------------------------------------------------- + // Entries + // ------------------------------------------------------------------------- + + /** + * Entries and company holidays for the grid. The range is the viewer's leave-year + * window (#737) — a shifted year spans two calendar years, so the old year prefix + * would drop the second half. For 'calendar' the range is Jan 1 – Dec 31 again. + */ + getEntries(planId: number, year: string, viewerId?: number) { + const { start, end } = this.viewerGridWindow(year, viewerId); + const entries = this.db.all(` + SELECT e.*, u.username as person_name, COALESCE(c.color, '#6366f1') as person_color + FROM vacay_entries e + JOIN users u ON e.user_id = u.id + LEFT JOIN vacay_user_colors c ON c.user_id = e.user_id AND c.plan_id = e.plan_id + WHERE e.plan_id = ? AND e.date >= ? AND e.date < ? + `, planId, start, end); + const companyHolidays = this.db.all('SELECT * FROM vacay_company_holidays WHERE plan_id = ? AND date >= ? AND date < ?', planId, start, end); + return { entries, companyHolidays }; + } + + toggleEntry(userId: number, planId: number, date: string, fraction?: unknown, kind?: unknown, socketId?: string): { action: string; fraction?: number; kind?: string } { + const frac = normalizeFraction(fraction); + const knd = normalizeKind(kind); + const existing = this.db.get<{ id: number; fraction: number; kind: string | null }>('SELECT id, fraction, kind FROM vacay_entries WHERE user_id = ? AND date = ? AND plan_id = ?', userId, date, planId); + if (existing) { + // Clicking the exact same day again (same type AND same fraction) clears it; + // clicking a different type or fraction converts it in place (#552/#1074). + if (existing.fraction === frac && (existing.kind || 'vacation') === knd) { + this.db.run('DELETE FROM vacay_entries WHERE id = ?', existing.id); + this.notifyPlanUsers(planId, socketId); + return { action: 'removed' }; + } + this.db.run('UPDATE vacay_entries SET fraction = ?, kind = ? WHERE id = ?', frac, knd, existing.id); + this.notifyPlanUsers(planId, socketId); + return { action: 'updated', fraction: frac, kind: knd }; + } + this.db.run('INSERT INTO vacay_entries (plan_id, user_id, date, note, fraction, kind) VALUES (?, ?, ?, ?, ?, ?)', planId, userId, date, '', frac, knd); + this.notifyPlanUsers(planId, socketId); + return { action: 'added', fraction: frac, kind: knd }; + } + + toggleCompanyHoliday(planId: number, date: string, note: string | undefined, socketId: string | undefined): { action: string } { + const existing = this.db.get<{ id: number }>('SELECT id FROM vacay_company_holidays WHERE plan_id = ? AND date = ?', planId, date); + if (existing) { + this.db.run('DELETE FROM vacay_company_holidays WHERE id = ?', existing.id); + this.notifyPlanUsers(planId, socketId); + return { action: 'removed' }; + } else { + this.db.run('INSERT INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)', planId, date, note || ''); + this.db.run('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?', planId, date); + this.notifyPlanUsers(planId, socketId); + return { action: 'added' }; + } + } + + // ------------------------------------------------------------------------- + // Stats + // ------------------------------------------------------------------------- + + getStats(planId: number, year: number) { + const plan = this.db.get('SELECT * FROM vacay_plans WHERE id = ?', planId); + const carryOverEnabled = plan ? !!plan.carry_over_enabled : true; + const users = this.getPlanUsers(planId); + + return users.map(u => { + const used = this.usedDays(u.id, planId, year); + const compUsed = this.compUsedDays(u.id, planId, year); + const config = this.db.get('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?', u.id, planId, year); + const vacationDays = config ? config.vacation_days : 30; + const carriedOver = carryOverEnabled ? (config ? config.carried_over : 0) : 0; + const total = vacationDays + carriedOver; + const remaining = total - used; + const colorRow = this.db.get<{ color: string }>('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?', u.id, planId); + // The period this row was computed over (#737) — the UI labels the window and + // the carry-over source with it instead of assuming Jan–Dec / year − 1. + const window = this.resolveYearWindow(u.id, year); + + const nextYearExists = this.db.get('SELECT id FROM vacay_years WHERE plan_id = ? AND year = ?', planId, year + 1); + if (nextYearExists && carryOverEnabled) { + const carry = Math.max(0, remaining); + this.db.run(` + INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?) + ON CONFLICT(user_id, plan_id, year) DO UPDATE SET carried_over = ? + `, u.id, planId, year + 1, carry, carry); + } + + return { + user_id: u.id, person_name: u.username, person_color: colorRow?.color || '#6366f1', + year, vacation_days: vacationDays, carried_over: carriedOver, + total_available: total, used, remaining, comp_used: compUsed, + window_start: window.start, window_end: window.end, + }; + }); + } + + updateStats(userId: number, planId: number, year: number, vacationDays: number, socketId: string | undefined): void { + this.db.run(` + INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, ?, 0) + ON CONFLICT(user_id, plan_id, year) DO UPDATE SET vacation_days = excluded.vacation_days + `, userId, planId, year, vacationDays); + this.notifyPlanUsers(planId, socketId); + } + + // ------------------------------------------------------------------------- + // GET /plan composite + // ------------------------------------------------------------------------- + + getPlanData(userId: number) { + const plan = this.getActivePlan(userId); + const activePlanId = plan.id; + + const users = this.getPlanUsers(activePlanId).map(u => { + const colorRow = this.db.get<{ color: string }>('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?', u.id, activePlanId); + return { ...u, color: colorRow?.color || '#6366f1' }; + }); + + const pendingInvites = this.db.all(` + SELECT m.id, m.user_id, u.username, u.email, m.created_at + FROM vacay_plan_members m JOIN users u ON m.user_id = u.id + WHERE m.plan_id = ? AND m.status = 'pending' + `, activePlanId); + + const incomingInvites = this.db.all(` + SELECT m.id, m.plan_id, u.username, u.email, m.created_at + FROM vacay_plan_members m + JOIN vacay_plans p ON m.plan_id = p.id + JOIN users u ON p.owner_id = u.id + WHERE m.user_id = ? AND m.status = 'pending' + `, userId); + + const holidayCalendars = this.db.all('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id', activePlanId); + + return { + plan: { + ...plan, + block_weekends: !!plan.block_weekends, + holidays_enabled: !!plan.holidays_enabled, + school_holidays_enabled: !!plan.school_holidays_enabled, + company_holidays_enabled: !!plan.company_holidays_enabled, + carry_over_enabled: !!plan.carry_over_enabled, + holiday_calendars: holidayCalendars, + }, + users, + pendingInvites, + incomingInvites, + isOwner: plan.owner_id === userId, + isFused: users.length > 1, + }; + } + + // ------------------------------------------------------------------------- + // Holidays (nager.at proxy with cache) + // ------------------------------------------------------------------------- + + async getCountries(): Promise<{ data?: unknown; error?: string }> { + const cacheKey = 'countries'; + const cached = holidayCache.get(cacheKey); + if (cached && Date.now() - cached.time < CACHE_TTL) return { data: cached.data }; + try { + const resp = await fetch('https://date.nager.at/api/v3/AvailableCountries'); + const data = await resp.json(); + holidayCache.set(cacheKey, { data, time: Date.now() }); + return { data }; + } catch { + return { error: 'Failed to fetch countries' }; + } + } + + async getHolidays(year: string, country: string): Promise<{ data?: unknown; error?: string }> { + const cacheKey = `${year}-${country}`; + const cached = holidayCache.get(cacheKey); + if (cached && Date.now() - cached.time < CACHE_TTL) return { data: cached.data }; + try { + const resp = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/${country}`); + const data = await resp.json(); + holidayCache.set(cacheKey, { data, time: Date.now() }); + return { data }; + } catch { + return { error: 'Failed to fetch holidays' }; + } + } + + async getSchoolHolidayRegions(country: string, language = 'EN'): Promise<{ data?: unknown; error?: string }> { + const normalizedLanguage = String(language || 'EN').slice(0, 2).toUpperCase(); + const cacheKey = `school-regions-${country}-${normalizedLanguage}`; + const cached = holidayCache.get(cacheKey); + if (cached && Date.now() - cached.time < CACHE_TTL) return { data: cached.data }; + try { + const [groupsResp, subdivisionsResp] = await Promise.all([ + fetch(`https://openholidaysapi.org/Groups?countryIsoCode=${country}&languageIsoCode=${normalizedLanguage}`, { headers: { accept: 'text/json' } }), + fetch(`https://openholidaysapi.org/Subdivisions?countryIsoCode=${country}&languageIsoCode=${normalizedLanguage}`, { headers: { accept: 'text/json' } }), + ]); + if (!groupsResp.ok || !subdivisionsResp.ok) return { error: 'Failed to fetch school holiday regions' }; + const data = { + groups: await groupsResp.json(), + subdivisions: await subdivisionsResp.json(), + }; + holidayCache.set(cacheKey, { data, time: Date.now() }); + return { data }; + } catch { + return { error: 'Failed to fetch school holiday regions' }; + } + } + + async getSchoolHolidays(year: string, country: string, subdivision?: string | null, language = 'EN', group?: string | null): Promise<{ data?: unknown; error?: string }> { + const normalizedLanguage = String(language || 'EN').slice(0, 2).toUpperCase(); + const normalizedSubdivision = subdivision || ''; + const normalizedGroup = group || ''; + const cacheKey = `school-${year}-${country}-${normalizedSubdivision || 'all'}-${normalizedGroup || 'all'}-${normalizedLanguage}`; + const cached = holidayCache.get(cacheKey); + if (cached && Date.now() - cached.time < CACHE_TTL) return { data: cached.data }; + try { + const params = new URLSearchParams({ + countryIsoCode: country, + languageIsoCode: normalizedLanguage, + validFrom: `${year}-01-01`, + validTo: `${year}-12-31`, + }); + if (normalizedSubdivision) params.set('subdivisionCode', normalizedSubdivision); + if (normalizedGroup) params.set('groupCode', normalizedGroup); + const resp = await fetch(`https://openholidaysapi.org/SchoolHolidays?${params.toString()}`, { + headers: { accept: 'text/json' }, + }); + if (!resp.ok) return { error: 'Failed to fetch school holidays' }; + const data = await resp.json(); + holidayCache.set(cacheKey, { data, time: Date.now() }); + return { data }; + } catch { + return { error: 'Failed to fetch school holidays' }; + } } } diff --git a/server/src/services/tripService.ts b/server/src/services/tripService.ts index ada0e78ac..8eaa36221 100644 --- a/server/src/services/tripService.ts +++ b/server/src/services/tripService.ts @@ -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; diff --git a/server/src/services/vacayService.ts b/server/src/services/vacayService.ts deleted file mode 100644 index 087291220..000000000 --- a/server/src/services/vacayService.ts +++ /dev/null @@ -1,1211 +0,0 @@ -import { db } from '../db/database'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface VacayPlan { - id: number; - owner_id: number; - block_weekends: number; - holidays_enabled: number; - holidays_region: string | null; - school_holidays_enabled: number; - company_holidays_enabled: number; - carry_over_enabled: number; -} - -export interface VacayUserYear { - user_id: number; - plan_id: number; - year: number; - vacation_days: number; - carried_over: number; -} - -export interface VacayUser { - id: number; - username: string; - email: string; -} - -export interface VacayPlanMember { - id: number; - plan_id: number; - user_id: number; - status: string; - created_at?: string; -} - -export interface Holiday { - date: string; - localName?: string; - name?: string; - global?: boolean; - counties?: string[] | null; -} - -export interface SchoolHoliday { - startDate?: string; - endDate?: string; - name?: Array<{ language?: string; text?: string }> | string; -} - -export interface VacayHolidayCalendar { - id: number; - plan_id: number; - type: 'public_holiday' | 'school_holiday'; - region: string; - label: string | null; - color: string; - sort_order: number; -} - -// --------------------------------------------------------------------------- -// Holiday cache (shared in-process) -// --------------------------------------------------------------------------- - -const holidayCache = new Map(); -const CACHE_TTL = 24 * 60 * 60 * 1000; - -// --------------------------------------------------------------------------- -// Entitlement helpers -// --------------------------------------------------------------------------- - -/** - * Vacation days a user has used in a year — the SUM of entry fractions, so a - * half day (#552) counts as 0.5 and a full day as 1. Entries predating the - * feature have fraction = 1, so this matches the old COUNT(*) for them. - */ -function usedDays(userId: number, planId: number, year: number): number { - // Comp/Flex days (#1074) are free — kind='comp' contributes 0 to the entitlement, - // vacation days contribute their fraction. Entries predating the column are - // 'vacation' by default. The window (#737) is the user's leave-year period; for - // 'calendar' it is Jan 1 – Dec 31, byte-identical to the old date-prefix match. - const { start, end } = resolveYearWindow(userId, year); - const row = db.prepare( - "SELECT COALESCE(SUM(CASE WHEN kind = 'comp' THEN 0 ELSE fraction END), 0) AS used FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date >= ? AND date < ?" - ).get(userId, planId, start, end) as { used: number }; - return row.used; -} - -/** Comp/Flex days (#1074) used in a user's leave-year period — SUM of fractions for kind='comp'. */ -function compUsedDays(userId: number, planId: number, year: number): number { - const { start, end } = resolveYearWindow(userId, year); - const row = db.prepare( - "SELECT COALESCE(SUM(fraction), 0) AS used FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date >= ? AND date < ? AND kind = 'comp'" - ).get(userId, planId, start, end) as { used: number }; - return row.used; -} - -/** Coerce an arbitrary input to a supported entry fraction: half (0.5) or full (1). */ -function normalizeFraction(value: unknown): number { - return Number(value) === 0.5 ? 0.5 : 1; -} - -/** Coerce an arbitrary input to a supported leave type: comp/flex or vacation (#1074). */ -function normalizeKind(value: unknown): 'vacation' | 'comp' { - return value === 'comp' ? 'comp' : 'vacation'; -} - -// --------------------------------------------------------------------------- -// Configurable vacation year (#737) -// --------------------------------------------------------------------------- - -export interface VacayUserSettings { - user_id: number; - year_type: 'calendar' | 'fiscal' | 'anniversary'; - year_start_month: number; - year_start_day: number; - hire_date: string | null; -} - -export function getUserYearSettings(userId: number): VacayUserSettings | undefined { - return db.prepare('SELECT * FROM vacay_user_settings WHERE user_id = ?').get(userId) as VacayUserSettings | undefined; -} - -/** A user's leave-year settings with the calendar defaults filled in (#737). */ -export function getYearSettings(userId: number): VacayUserSettings { - return getUserYearSettings(userId) ?? { user_id: userId, year_type: 'calendar', year_start_month: 1, year_start_day: 1, hire_date: null }; -} - -const pad2 = (n: number) => String(n).padStart(2, '0'); - -/** Days a month has in every year — February caps at 28 so no boundary lands on a date a common year lacks. */ -function daysAlwaysInMonth(month: number): number { - if (month === 2) return 28; - return [4, 6, 9, 11].includes(month) ? 30 : 31; -} - -/** Coerce an arbitrary input to a supported leave-year type (#737). */ -function normalizeYearType(value: unknown): 'calendar' | 'fiscal' | 'anniversary' { - return value === 'fiscal' || value === 'anniversary' ? value : 'calendar'; -} - -/** - * Resolve a user's leave-year window for a period (#737). The `year` integer names - * the period; 'calendar' returns Jan 1 – Dec 31 (the unchanged default, byte-identical - * to the old `date LIKE 'YYYY-%'`), 'fiscal' starts on the configured month/day, and - * 'anniversary' on the hire date's month/day. Window is [start, end) — start inclusive, - * end exclusive. Because periods are consecutive, `year-1` is always the window that - * ends where `year`'s begins, so the carry-over chains stay valid unchanged. - */ -export function resolveYearWindow(userId: number, year: number): { start: string; end: string } { - const s = getUserYearSettings(userId); - // 'anniversary' before a hire date is entered has nothing to anchor to, so it - // reads as the calendar default rather than borrowing a month left behind by a - // previous 'fiscal' setting — the client mirror resolves it the same way. - if (!s || s.year_type === 'calendar' || (s.year_type === 'anniversary' && !s.hire_date)) { - return { start: `${year}-01-01`, end: `${year + 1}-01-01` }; - } - let month = s.year_start_month || 1; - let day = s.year_start_day || 1; - if (s.year_type === 'anniversary') { - const parts = s.hire_date!.split('-'); - month = parseInt(parts[1], 10) || 1; - day = parseInt(parts[2], 10) || 1; - } - month = Math.min(12, Math.max(1, month)); - // A Feb 29 hire date (or a stored Feb 30) would name a boundary that most years - // simply do not have, and a string comparison against it silently shifts the - // whole window. Clamp to a day every year really has. - day = Math.min(day, daysAlwaysInMonth(month)); - return { start: `${year}-${pad2(month)}-${pad2(day)}`, end: `${year + 1}-${pad2(month)}-${pad2(day)}` }; -} - -/** - * The [start, end) range a year identifier covers when read on someone's behalf - * (#737). Without a viewer — MCP resources, which are plan-scoped — it stays the - * plain calendar year, which is exactly what a 'calendar' user resolves to. - */ -function viewerWindow(year: number | string, viewerId?: number): { start: string; end: string } { - const y = typeof year === 'number' ? year : parseInt(year, 10); - // A non-numeric year matched nothing under the old date prefix; an empty range - // matches nothing either, so a bad parameter still yields an empty result. - if (!Number.isFinite(y)) return { start: '', end: '' }; - if (viewerId == null) return { start: `${y}-01-01`, end: `${y + 1}-01-01` }; - return resolveYearWindow(viewerId, y); -} - -/** - * The range the calendar grid renders for a period: the twelve whole months the - * window starts in. For every window that begins on the 1st — all of 'calendar', - * and a fiscal year set to a month start — this is exactly the counting window. - * - * Known limitation for a start day past the 1st (UK's Apr 6, an Oct 16 hire date): - * the rendered range is month-aligned at both ends, so it is shifted rather than - * widened. The first few days of the start month are drawn but belong to the - * previous period, and the equally few days at the far end count here but fall - * outside the twelve cards. Entitlement arithmetic stays day-exact either way; - * only the grid's edges are approximate. Rendering a 13th month card would fix it - * and was deliberately not taken. - */ -function viewerGridWindow(year: number | string, viewerId?: number): { start: string; end: string } { - const w = viewerWindow(year, viewerId); - if (!w.start) return w; - return { start: `${w.start.slice(0, 7)}-01`, end: `${w.end.slice(0, 7)}-01` }; -} - -/** - * The calendar year a window's last day falls in. `end` is exclusive, so a window - * ending on Jan 1 (the calendar default) stops inside the previous year. - */ -function windowEndYear(end: string): number { - const y = parseInt(end.slice(0, 4), 10); - return end.endsWith('-01-01') ? y - 1 : y; -} - -/** - * The period identifier whose window contains `date` for this user (#737). With a - * window that starts later in the year, today still belongs to the period named - * after the previous calendar year — 'calendar' users always get today's year. - */ -export function currentPeriodYear(userId: number, date = new Date()): number { - const y = date.getFullYear(); - const iso = `${y}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`; - return iso < resolveYearWindow(userId, y).start ? y - 1 : y; -} - -/** Upsert a user's leave-year settings (#737). */ -export function updateUserYearSettings( - userId: number, - data: { year_type?: unknown; year_start_month?: unknown; year_start_day?: unknown; hire_date?: unknown }, -): VacayUserSettings { - const type = normalizeYearType(data.year_type); - const month = Math.min(12, Math.max(1, parseInt(String(data.year_start_month ?? 1), 10) || 1)); - const day = Math.min(31, Math.max(1, parseInt(String(data.year_start_day ?? 1), 10) || 1)); - const hire = typeof data.hire_date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(data.hire_date) ? data.hire_date : null; - db.prepare(` - INSERT INTO vacay_user_settings (user_id, year_type, year_start_month, year_start_day, hire_date) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(user_id) DO UPDATE SET year_type = excluded.year_type, year_start_month = excluded.year_start_month, - year_start_day = excluded.year_start_day, hire_date = excluded.hire_date - `).run(userId, type, month, day, hire); - return getUserYearSettings(userId)!; -} - -// --------------------------------------------------------------------------- -// Color palette for auto-assign -// --------------------------------------------------------------------------- - -const COLORS = [ - '#6366f1', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444', - '#3b82f6', '#22c55e', '#06b6d4', '#f43f5e', '#a855f7', - '#10b981', '#0ea5e9', '#64748b', '#be185d', '#0d9488', -]; - -// --------------------------------------------------------------------------- -// Plan management -// --------------------------------------------------------------------------- - -export function getOwnPlan(userId: number): VacayPlan { - let plan = db.prepare('SELECT * FROM vacay_plans WHERE owner_id = ?').get(userId) as VacayPlan | undefined; - if (!plan) { - db.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(userId); - plan = db.prepare('SELECT * FROM vacay_plans WHERE owner_id = ?').get(userId) as VacayPlan; - // Seed the period today falls into — with a shifted leave year (#737) that is - // not necessarily the current calendar year. - const yr = currentPeriodYear(userId); - db.prepare('INSERT OR IGNORE INTO vacay_years (plan_id, year) VALUES (?, ?)').run(plan.id, yr); - db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)').run(userId, plan.id, yr); - db.prepare('INSERT OR IGNORE INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)').run(userId, plan.id, '#6366f1'); - } - return plan; -} - -export function getActivePlan(userId: number): VacayPlan { - const membership = db.prepare(` - SELECT plan_id FROM vacay_plan_members WHERE user_id = ? AND status = 'accepted' - `).get(userId) as { plan_id: number } | undefined; - if (membership) { - return db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(membership.plan_id) as VacayPlan; - } - return getOwnPlan(userId); -} - -export function getActivePlanId(userId: number): number { - return getActivePlan(userId).id; -} - -export function shiftOwnerEntriesForTripWindow( - ownerId: number, - oldStart: string, - oldEnd: string, - newStart: string -): void { - const row = db.prepare( - 'SELECT CAST(julianday(?) - julianday(?) AS INTEGER) AS days' - ).get(newStart, oldStart) as { days: number } | undefined; - const offset = row?.days ?? 0; - if (offset === 0) return; - - const plan = getOwnPlan(ownerId); - - db.prepare( - `UPDATE OR IGNORE vacay_entries - SET date = date(date, ? || ' days') - WHERE plan_id = ? - AND user_id = ? - AND date BETWEEN ? AND ?` - ).run(`${offset >= 0 ? '+' : ''}${offset}`, plan.id, ownerId, oldStart, oldEnd); -} - -export function getPlanUsers(planId: number): VacayUser[] { - const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan | undefined; - if (!plan) return []; - const owner = db.prepare('SELECT id, username, email FROM users WHERE id = ?').get(plan.owner_id) as VacayUser; - const members = db.prepare(` - SELECT u.id, u.username, u.email FROM vacay_plan_members m - JOIN users u ON m.user_id = u.id - WHERE m.plan_id = ? AND m.status = 'accepted' - `).all(planId) as VacayUser[]; - return [owner, ...members]; -} - -// --------------------------------------------------------------------------- -// WebSocket notifications -// --------------------------------------------------------------------------- - -export function notifyPlanUsers(planId: number, excludeSid: string | undefined, event = 'vacay:update'): void { - try { - const { broadcastToUser } = require('../websocket'); - const plan = db.prepare('SELECT owner_id FROM vacay_plans WHERE id = ?').get(planId) as { owner_id: number } | undefined; - if (!plan) return; - const userIds = [plan.owner_id]; - const members = db.prepare("SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted'").all(planId) as { user_id: number }[]; - members.forEach(m => userIds.push(m.user_id)); - userIds.forEach(id => broadcastToUser(id, { type: event }, excludeSid)); - // Pending-invite events carry nothing a read-only viewer could see; every - // other event may change entries, colors or company holidays. - if (event !== 'vacay:invite' && event !== 'vacay:declined' && event !== 'vacay:cancelled') { - notifyShareViewers(userIds, excludeSid); - } - } catch { /* websocket not available */ } -} - -export function notifyShareViewers(ownerIds: number[], excludeSid?: string): void { - if (ownerIds.length === 0) return; - try { - const { broadcastToUser } = require('../websocket'); - const rows = db.prepare( - `SELECT DISTINCT user_id FROM vacay_shares WHERE owner_id IN (${ownerIds.map(() => '?').join(',')})` - ).all(...ownerIds) as { user_id: number }[]; - rows.forEach(r => broadcastToUser(r.user_id, { type: 'vacay:shared-update' }, excludeSid)); - } catch { /* websocket not available */ } -} - -// --------------------------------------------------------------------------- -// Holiday calendar helpers -// --------------------------------------------------------------------------- - -export async function applyHolidayCalendars(planId: number): Promise { - const plan = db.prepare('SELECT holidays_enabled FROM vacay_plans WHERE id = ?').get(planId) as { holidays_enabled: number } | undefined; - if (!plan?.holidays_enabled) return; - const calendars = db.prepare("SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? AND type = 'public_holiday' ORDER BY sort_order, id").all(planId) as VacayHolidayCalendar[]; - if (calendars.length === 0) return; - const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(planId) as { year: number }[]; - // A shifted leave year (#737) runs into the next calendar year, so collect the - // calendar years the members' windows actually touch — not just the period ids. - // With everyone on 'calendar' this is the same set as before. - const members = getPlanUsers(planId); - const calendarYears = new Set(); - for (const { year } of years) { - calendarYears.add(year); - for (const m of members) calendarYears.add(windowEndYear(resolveYearWindow(m.id, year).end)); - } - for (const cal of calendars) { - const country = cal.region.split('-')[0]; - const region = cal.region.includes('-') ? cal.region : null; - for (const year of calendarYears) { - try { - const cacheKey = `${year}-${country}`; - let holidays = holidayCache.get(cacheKey)?.data as Holiday[] | undefined; - if (!holidays) { - const resp = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/${country}`); - holidays = await resp.json() as Holiday[]; - holidayCache.set(cacheKey, { data: holidays, time: Date.now() }); - } - const hasRegions = holidays.some((h: Holiday) => h.counties && h.counties.length > 0); - if (hasRegions && !region) continue; - for (const h of holidays) { - if (h.global || !h.counties || (region && h.counties.includes(region))) { - db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, h.date); - db.prepare('DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').run(planId, h.date); - } - } - } catch { /* API error, skip */ } - } - } -} - -export async function migrateHolidayCalendars(planId: number, plan: VacayPlan): Promise { - const existing = db.prepare('SELECT id FROM vacay_holiday_calendars WHERE plan_id = ?').get(planId); - if (existing) return; - if (plan.holidays_enabled && plan.holidays_region) { - db.prepare( - 'INSERT INTO vacay_holiday_calendars (plan_id, region, label, color, sort_order) VALUES (?, ?, NULL, ?, 0)' - ).run(planId, plan.holidays_region, '#fecaca'); - } -} - -// --------------------------------------------------------------------------- -// Plan settings -// --------------------------------------------------------------------------- - -export interface UpdatePlanBody { - block_weekends?: boolean; - holidays_enabled?: boolean; - holidays_region?: string; - school_holidays_enabled?: boolean; - company_holidays_enabled?: boolean; - carry_over_enabled?: boolean; - weekend_days?: string; - week_start?: number; -} - -export async function updatePlan(planId: number, body: UpdatePlanBody, socketId: string | undefined) { - const { block_weekends, holidays_enabled, holidays_region, school_holidays_enabled, company_holidays_enabled, carry_over_enabled, weekend_days, week_start } = body; - - const updates: string[] = []; - const params: (string | number)[] = []; - if (block_weekends !== undefined) { updates.push('block_weekends = ?'); params.push(block_weekends ? 1 : 0); } - if (holidays_enabled !== undefined) { updates.push('holidays_enabled = ?'); params.push(holidays_enabled ? 1 : 0); } - if (holidays_region !== undefined) { updates.push('holidays_region = ?'); params.push(holidays_region); } - if (school_holidays_enabled !== undefined) { updates.push('school_holidays_enabled = ?'); params.push(school_holidays_enabled ? 1 : 0); } - if (company_holidays_enabled !== undefined) { updates.push('company_holidays_enabled = ?'); params.push(company_holidays_enabled ? 1 : 0); } - if (carry_over_enabled !== undefined) { updates.push('carry_over_enabled = ?'); params.push(carry_over_enabled ? 1 : 0); } - if (weekend_days !== undefined) { updates.push('weekend_days = ?'); params.push(String(weekend_days)); } - if (week_start !== undefined) { updates.push('week_start = ?'); params.push(week_start === 0 ? 0 : 1); } - - if (updates.length > 0) { - params.push(planId); - db.prepare(`UPDATE vacay_plans SET ${updates.join(', ')} WHERE id = ?`).run(...params); - } - - if (company_holidays_enabled === true) { - const companyDates = db.prepare('SELECT date FROM vacay_company_holidays WHERE plan_id = ?').all(planId) as { date: string }[]; - for (const { date } of companyDates) { - db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, date); - } - } - - const updatedPlan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan; - await migrateHolidayCalendars(planId, updatedPlan); - await applyHolidayCalendars(planId); - - if (carry_over_enabled === false) { - db.prepare('UPDATE vacay_user_years SET carried_over = 0 WHERE plan_id = ?').run(planId); - } - - if (carry_over_enabled === true) { - const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId) as { year: number }[]; - const users = getPlanUsers(planId); - for (let i = 0; i < years.length - 1; i++) { - const yr = years[i].year; - const nextYr = years[i + 1].year; - for (const u of users) { - const used = usedDays(u.id, planId, yr); - const config = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, yr) as VacayUserYear | undefined; - const total = (config ? config.vacation_days : 30) + (config ? config.carried_over : 0); - const carry = Math.max(0, total - used); - db.prepare(` - INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?) - ON CONFLICT(user_id, plan_id, year) DO UPDATE SET carried_over = ? - `).run(u.id, planId, nextYr, carry, carry); - } - } - } - - notifyPlanUsers(planId, socketId, 'vacay:settings'); - - const updated = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan; - const updatedCalendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(planId) as VacayHolidayCalendar[]; - return { - plan: { - ...updated, - block_weekends: !!updated.block_weekends, - holidays_enabled: !!updated.holidays_enabled, - school_holidays_enabled: !!updated.school_holidays_enabled, - company_holidays_enabled: !!updated.company_holidays_enabled, - carry_over_enabled: !!updated.carry_over_enabled, - holiday_calendars: updatedCalendars, - }, - }; -} - -// --------------------------------------------------------------------------- -// Holiday calendars CRUD -// --------------------------------------------------------------------------- - -export function addHolidayCalendar(planId: number, region: string, label: string | null, color: string | undefined, sortOrder: number | undefined, socketId: string | undefined, type: 'public_holiday' | 'school_holiday' = 'public_holiday') { - const result = db.prepare( - 'INSERT INTO vacay_holiday_calendars (plan_id, type, region, label, color, sort_order) VALUES (?, ?, ?, ?, ?, ?)' - ).run(planId, type, region, label || null, color || (type === 'school_holiday' ? '#a5f3fc' : '#fecaca'), sortOrder ?? 0); - const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ?').get(result.lastInsertRowid) as VacayHolidayCalendar; - notifyPlanUsers(planId, socketId, 'vacay:settings'); - return cal; -} - -export function updateHolidayCalendar( - calId: number, - planId: number, - body: { region?: string; label?: string | null; color?: string; sort_order?: number; type?: 'public_holiday' | 'school_holiday' }, - socketId: string | undefined, -): VacayHolidayCalendar | null { - const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?').get(calId, planId) as VacayHolidayCalendar | undefined; - if (!cal) return null; - const { region, label, color, sort_order, type } = body; - const updates: string[] = []; - const params: (string | number | null)[] = []; - if (region !== undefined) { updates.push('region = ?'); params.push(region); } - if (type !== undefined) { updates.push('type = ?'); params.push(type); } - if (label !== undefined) { updates.push('label = ?'); params.push(label); } - if (color !== undefined) { updates.push('color = ?'); params.push(color); } - if (sort_order !== undefined) { updates.push('sort_order = ?'); params.push(sort_order); } - if (updates.length > 0) { - params.push(calId); - db.prepare(`UPDATE vacay_holiday_calendars SET ${updates.join(', ')} WHERE id = ?`).run(...params); - } - const updated = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ?').get(calId) as VacayHolidayCalendar; - notifyPlanUsers(planId, socketId, 'vacay:settings'); - return updated; -} - -export function deleteHolidayCalendar(calId: number, planId: number, socketId: string | undefined): boolean { - const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?').get(calId, planId); - if (!cal) return false; - db.prepare('DELETE FROM vacay_holiday_calendars WHERE id = ?').run(calId); - notifyPlanUsers(planId, socketId, 'vacay:settings'); - return true; -} - -// --------------------------------------------------------------------------- -// User colors -// --------------------------------------------------------------------------- - -export function setUserColor(userId: number, planId: number, color: string | undefined, socketId: string | undefined): void { - db.prepare(` - INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?) - ON CONFLICT(user_id, plan_id) DO UPDATE SET color = excluded.color - `).run(userId, planId, color || '#6366f1'); - notifyPlanUsers(planId, socketId, 'vacay:update'); -} - -// --------------------------------------------------------------------------- -// Invitations -// --------------------------------------------------------------------------- - -export function sendInvite(planId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number): { error?: string; status?: number } { - if (targetUserId === inviterId) return { error: 'Cannot invite yourself', status: 400 }; - - const targetUser = db.prepare('SELECT id, username FROM users WHERE id = ?').get(targetUserId); - if (!targetUser) return { error: 'User not found', status: 404 }; - - const existing = db.prepare('SELECT id, status FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?').get(planId, targetUserId) as { id: number; status: string } | undefined; - if (existing) { - if (existing.status === 'accepted') return { error: 'Already fused', status: 400 }; - if (existing.status === 'pending') return { error: 'Invite already pending', status: 400 }; - } - - const targetFusion = db.prepare("SELECT id FROM vacay_plan_members WHERE user_id = ? AND status = 'accepted'").get(targetUserId); - if (targetFusion) return { error: 'User is already fused with another plan', status: 400 }; - - db.prepare('INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, ?)').run(planId, targetUserId, 'pending'); - - try { - const { broadcastToUser } = require('../websocket'); - broadcastToUser(targetUserId, { - type: 'vacay:invite', - from: { id: inviterId, username: inviterUsername }, - planId, - }); - } catch { /* websocket not available */ } - - // Notify invited user - import('../services/notificationService').then(({ send }) => { - send({ event: 'vacay_invite', actorId: inviterId, scope: 'user', targetId: targetUserId, params: { actor: inviterEmail, planId: String(planId) } }).catch(() => {}); - }); - - return {}; -} - -export function acceptInvite(userId: number, planId: number, socketId: string | undefined): { error?: string; status?: number } { - const invite = db.prepare("SELECT * FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'").get(planId, userId) as VacayPlanMember | undefined; - if (!invite) return { error: 'No pending invite', status: 404 }; - - db.prepare("UPDATE vacay_plan_members SET status = 'accepted' WHERE id = ?").run(invite.id); - - // Migrate data from user's own plan - const ownPlan = db.prepare('SELECT id FROM vacay_plans WHERE owner_id = ?').get(userId) as { id: number } | undefined; - if (ownPlan && ownPlan.id !== planId) { - db.prepare('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?').run(planId, ownPlan.id, userId); - const ownYears = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ?').all(userId, ownPlan.id) as VacayUserYear[]; - for (const y of ownYears) { - db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, ?, ?)').run(userId, planId, y.year, y.vacation_days, y.carried_over); - } - const colorRow = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(userId, ownPlan.id) as { color: string } | undefined; - if (colorRow) { - db.prepare('INSERT OR IGNORE INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)').run(userId, planId, colorRow.color); - } - } - - // Auto-assign unique color - const existingColors = (db.prepare('SELECT color FROM vacay_user_colors WHERE plan_id = ? AND user_id != ?').all(planId, userId) as { color: string }[]).map(r => r.color); - const myColor = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(userId, planId) as { color: string } | undefined; - const effectiveColor = myColor?.color || '#6366f1'; - if (existingColors.includes(effectiveColor)) { - const available = COLORS.find(c => !existingColors.includes(c)); - if (available) { - db.prepare(`INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?) - ON CONFLICT(user_id, plan_id) DO UPDATE SET color = excluded.color`).run(userId, planId, available); - } - } else if (!myColor) { - db.prepare('INSERT OR IGNORE INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)').run(userId, planId, effectiveColor); - } - - // Ensure user has rows for all plan years - const targetYears = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(planId) as { year: number }[]; - for (const y of targetYears) { - db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)').run(userId, planId, y.year); - } - - notifyPlanUsers(planId, socketId, 'vacay:accepted'); - return {}; -} - -export function declineInvite(userId: number, planId: number, socketId: string | undefined): void { - db.prepare("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'").run(planId, userId); - notifyPlanUsers(planId, socketId, 'vacay:declined'); -} - -export function cancelInvite(planId: number, targetUserId: number): void { - db.prepare("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'").run(planId, targetUserId); - - try { - const { broadcastToUser } = require('../websocket'); - broadcastToUser(targetUserId, { type: 'vacay:cancelled' }); - } catch { /* */ } -} - -// --------------------------------------------------------------------------- -// Plan dissolution -// --------------------------------------------------------------------------- - -export function dissolvePlan(userId: number, socketId: string | undefined): void { - const plan = getActivePlan(userId); - const isOwnerFlag = plan.owner_id === userId; - - const allUserIds = getPlanUsers(plan.id).map(u => u.id); - const companyHolidays = db.prepare('SELECT date, note FROM vacay_company_holidays WHERE plan_id = ?').all(plan.id) as { date: string; note: string }[]; - - if (isOwnerFlag) { - const members = db.prepare("SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted'").all(plan.id) as { user_id: number }[]; - for (const m of members) { - const memberPlan = getOwnPlan(m.user_id); - db.prepare('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?').run(memberPlan.id, plan.id, m.user_id); - for (const ch of companyHolidays) { - db.prepare('INSERT OR IGNORE INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(memberPlan.id, ch.date, ch.note); - } - } - db.prepare('DELETE FROM vacay_plan_members WHERE plan_id = ?').run(plan.id); - } else { - const ownPlan = getOwnPlan(userId); - db.prepare('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?').run(ownPlan.id, plan.id, userId); - for (const ch of companyHolidays) { - db.prepare('INSERT OR IGNORE INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(ownPlan.id, ch.date, ch.note); - } - db.prepare("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?").run(plan.id, userId); - } - - try { - const { broadcastToUser } = require('../websocket'); - allUserIds.filter(id => id !== userId).forEach(id => broadcastToUser(id, { type: 'vacay:dissolved' })); - } catch { /* */ } - // Everyone's entries just moved back to their own plans — refresh read-only viewers. - notifyShareViewers(allUserIds, socketId); -} - -// --------------------------------------------------------------------------- -// Available users -// --------------------------------------------------------------------------- - -export function getAvailableUsers(userId: number, planId: number) { - return db.prepare(` - SELECT u.id, u.username, u.email FROM users u - WHERE u.id != ? - AND u.id NOT IN (SELECT user_id FROM vacay_plan_members WHERE plan_id = ?) - AND u.id NOT IN (SELECT user_id FROM vacay_plan_members WHERE status = 'accepted') - AND u.id NOT IN (SELECT owner_id FROM vacay_plans WHERE id IN ( - SELECT plan_id FROM vacay_plan_members WHERE status = 'accepted' - )) - ORDER BY u.username - `).all(userId, planId); -} - -// --------------------------------------------------------------------------- -// Read-only calendar shares (#444/#667) -// --------------------------------------------------------------------------- -// -// A share lets another user VIEW someone's vacation days without fusing plans: -// no edit rights, no data migration. The share follows the person (owner_id), -// not a plan, so it keeps working across fusion and dissolution — viewers see -// the owner's entries in whatever plan the owner is currently active in. - -export interface VacayShare { - id: number; - owner_id: number; - user_id: number; - hidden: number; - created_at?: string; -} - -/** Like getActivePlan, but never lazily creates a plan for the user. */ -function peekActivePlan(userId: number): VacayPlan | undefined { - const membership = db.prepare(` - SELECT plan_id FROM vacay_plan_members WHERE user_id = ? AND status = 'accepted' - `).get(userId) as { plan_id: number } | undefined; - if (membership) { - return db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(membership.plan_id) as VacayPlan | undefined; - } - return db.prepare('SELECT * FROM vacay_plans WHERE owner_id = ?').get(userId) as VacayPlan | undefined; -} - -/** Colors already taken in the viewer's own calendar (their plan's members). */ -function viewerColors(viewerId: number): Set { - const plan = peekActivePlan(viewerId); - if (!plan) return new Set(['#6366f1']); - const rows = db.prepare('SELECT color FROM vacay_user_colors WHERE plan_id = ?').all(plan.id) as { color: string }[]; - return new Set(rows.length > 0 ? rows.map(r => r.color) : ['#6366f1']); -} - -/** - * Display color for a shared calendar. Starts from the owner's own color, but - * remaps to the first free preset when it collides with the viewer's plan - * members or an earlier share — otherwise two people on the default indigo - * would be indistinguishable in the overlay. - */ -function shareDisplayColor(ownerId: number, usedColors: Set): string { - const plan = peekActivePlan(ownerId); - const row = plan - ? db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(ownerId, plan.id) as { color: string } | undefined - : undefined; - let color = row?.color || '#6366f1'; - if (usedColors.has(color)) { - // Preset pool exhausted? Derive a stable per-owner hue instead of colliding. - color = COLORS.find(c => !usedColors.has(c)) || `hsl(${Math.round((ownerId * 137.508) % 360)} 65% 60%)`; - } - usedColors.add(color); - return color; -} - -/** Users the viewer already sees in full via their active plan (owner + members). */ -function viewerCoMemberIds(viewerId: number): Set { - const plan = peekActivePlan(viewerId); - return new Set(plan ? getPlanUsers(plan.id).map(u => u.id) : []); -} - -export function listShares(userId: number) { - // Usernames only, like the share picker — emails stay out of the share surface. - const outgoing = db.prepare(` - SELECT s.id, s.user_id, u.username - FROM vacay_shares s JOIN users u ON s.user_id = u.id - WHERE s.owner_id = ? ORDER BY s.id - `).all(userId) as { id: number; user_id: number; username: string }[]; - const incomingRows = db.prepare(` - SELECT s.id, s.owner_id, s.hidden, u.username - FROM vacay_shares s JOIN users u ON s.owner_id = u.id - WHERE s.user_id = ? ORDER BY s.id - `).all(userId) as { id: number; owner_id: number; hidden: number; username: string }[]; - // Shares from someone the viewer is meanwhile fused with lie dormant — the - // plan already shows that calendar in full. They resume after dissolution. - const coMembers = viewerCoMemberIds(userId); - const usedColors = viewerColors(userId); - const incoming = incomingRows.filter(s => !coMembers.has(s.owner_id)).map(s => ({ - id: s.id, - owner_id: s.owner_id, - username: s.username, - color: shareDisplayColor(s.owner_id, usedColors), - hidden: !!s.hidden, - })); - return { outgoing, incoming }; -} - -export function shareCalendar(ownerId: number, ownerEmail: string, targetUserId: number, socketId?: string): { error?: string; status?: number } { - if (targetUserId === ownerId) return { error: 'Cannot share with yourself', status: 400 }; - - const targetUser = db.prepare('SELECT id FROM users WHERE id = ?').get(targetUserId); - if (!targetUser) return { error: 'User not found', status: 404 }; - - const existing = db.prepare('SELECT id FROM vacay_shares WHERE owner_id = ? AND user_id = ?').get(ownerId, targetUserId); - if (existing) return { error: 'Already shared', status: 400 }; - - // Plan members already see the whole calendar — sharing with them is moot. - if (getPlanUsers(getActivePlanId(ownerId)).find(u => u.id === targetUserId)) { - return { error: 'User is already in your calendar', status: 400 }; - } - - db.prepare('INSERT INTO vacay_shares (owner_id, user_id) VALUES (?, ?)').run(ownerId, targetUserId); - - try { - const { broadcastToUser } = require('../websocket'); - broadcastToUser(targetUserId, { type: 'vacay:share', from: { id: ownerId } }); - // The owner's other devices refresh their outgoing list too. - broadcastToUser(ownerId, { type: 'vacay:share', from: { id: ownerId } }, socketId); - } catch { /* websocket not available */ } - - import('../services/notificationService').then(({ send }) => { - send({ event: 'vacay_share', actorId: ownerId, scope: 'user', targetId: targetUserId, params: { actor: ownerEmail } }).catch(() => {}); - }); - - return {}; -} - -export function removeShare(shareId: number, userId: number, socketId?: string): boolean { - const share = db.prepare('SELECT * FROM vacay_shares WHERE id = ?').get(shareId) as VacayShare | undefined; - // The owner revokes, the viewer removes — both may delete, nobody else. - if (!share || (share.owner_id !== userId && share.user_id !== userId)) return false; - db.prepare('DELETE FROM vacay_shares WHERE id = ?').run(shareId); - try { - const { broadcastToUser } = require('../websocket'); - broadcastToUser(share.owner_id, { type: 'vacay:share-removed' }, socketId); - broadcastToUser(share.user_id, { type: 'vacay:share-removed' }, socketId); - } catch { /* websocket not available */ } - return true; -} - -export function setShareHidden(shareId: number, userId: number, hidden: boolean, socketId?: string): boolean { - const share = db.prepare('SELECT * FROM vacay_shares WHERE id = ?').get(shareId) as VacayShare | undefined; - if (!share || share.user_id !== userId) return false; - db.prepare('UPDATE vacay_shares SET hidden = ? WHERE id = ?').run(hidden ? 1 : 0, shareId); - try { - // Keep the viewer's other devices in sync; nobody else is affected. - const { broadcastToUser } = require('../websocket'); - broadcastToUser(userId, { type: 'vacay:shared-update' }, socketId); - } catch { /* websocket not available */ } - return true; -} - -export function getShareAvailableUsers(userId: number) { - const planId = getActivePlanId(userId); - // Username only — unlike the fusion picker this lists users from other plans - // too, so exposing their emails here would widen the instance directory. - return db.prepare(` - SELECT u.id, u.username FROM users u - WHERE u.id != ? - AND u.id NOT IN (SELECT user_id FROM vacay_shares WHERE owner_id = ?) - AND u.id NOT IN ( - SELECT owner_id FROM vacay_plans WHERE id = ? - UNION - SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted' - ) - ORDER BY u.username - `).all(userId, userId, planId, planId); -} - -export function getSharedCalendars(viewerId: number, year: string) { - // Shared calendars are drawn into the viewer's grid, so they load over the - // viewer's range (#737) even when the owner's leave year is shaped differently. - const { start, end } = viewerGridWindow(year, viewerId); - const shares = db.prepare(` - SELECT s.id, s.owner_id, s.hidden, u.username - FROM vacay_shares s JOIN users u ON s.owner_id = u.id - WHERE s.user_id = ? ORDER BY s.id - `).all(viewerId) as { id: number; owner_id: number; hidden: number; username: string }[]; - - // Same dormancy rule as listShares: fused co-members are already fully visible. - const coMembers = viewerCoMemberIds(viewerId); - const usedColors = viewerColors(viewerId); - return shares.filter(s => !coMembers.has(s.owner_id)).map(s => { - const color = shareDisplayColor(s.owner_id, usedColors); - const plan = peekActivePlan(s.owner_id); - if (!plan) { - return { share_id: s.id, owner_id: s.owner_id, owner_name: s.username, color, hidden: !!s.hidden, entries: [], companyHolidays: [] }; - } - const entries = db.prepare( - 'SELECT date, fraction, kind FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date >= ? AND date < ? ORDER BY date' - ).all(plan.id, s.owner_id, start, end); - // Company holidays are plan-wide context for "when is this person off"; - // only exposed while the owner has the feature enabled, and dates only — - // the note text may be authored by plan members who aren't part of the share. - const companyHolidays = plan.company_holidays_enabled - ? db.prepare('SELECT date FROM vacay_company_holidays WHERE plan_id = ? AND date >= ? AND date < ? ORDER BY date').all(plan.id, start, end) - : []; - return { share_id: s.id, owner_id: s.owner_id, owner_name: s.username, color, hidden: !!s.hidden, entries, companyHolidays }; - }); -} - -// --------------------------------------------------------------------------- -// Years -// --------------------------------------------------------------------------- - -export function listYears(planId: number): number[] { - const rows = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId) as { year: number }[]; - return rows.map(y => y.year); -} - -export function addYear(planId: number, year: number, socketId: string | undefined): number[] { - try { - db.prepare('INSERT INTO vacay_years (plan_id, year) VALUES (?, ?)').run(planId, year); - const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan | undefined; - const carryOverEnabled = plan ? !!plan.carry_over_enabled : true; - const users = getPlanUsers(planId); - for (const u of users) { - let carriedOver = 0; - if (carryOverEnabled) { - const prevConfig = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, year - 1) as VacayUserYear | undefined; - if (prevConfig) { - const used = usedDays(u.id, planId, year - 1); - const total = prevConfig.vacation_days + prevConfig.carried_over; - carriedOver = Math.max(0, total - used); - } - } - db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?)').run(u.id, planId, year, carriedOver); - } - } catch { /* year already exists */ } - notifyPlanUsers(planId, socketId, 'vacay:settings'); - return listYears(planId); -} - -export function deleteYear(planId: number, year: number, socketId: string | undefined): number[] { - db.prepare('DELETE FROM vacay_years WHERE plan_id = ? AND year = ?').run(planId, year); - // Members can be on differently shaped leave years (#737), so entries go per - // author over that author's period rather than by one shared year prefix. - // Authors are read off the entries themselves so orphans are cleared too. - const authors = db.prepare('SELECT DISTINCT user_id FROM vacay_entries WHERE plan_id = ?').all(planId) as { user_id: number }[]; - for (const { user_id } of authors) { - const { start, end } = resolveYearWindow(user_id, year); - db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date >= ? AND date < ?').run(planId, user_id, start, end); - } - // Company holidays belong to the plan, not to a member, and every member sees - // them over their own window. In a fused plan with mixed year types the safe - // range is therefore the intersection of all member windows — anything outside - // it still sits inside a period somebody else has not deleted. - const owner = db.prepare('SELECT owner_id FROM vacay_plans WHERE id = ?').get(planId) as { owner_id: number } | undefined; - const members = getPlanUsers(planId); - const windows = (members.length > 0 ? members.map(m => m.id) : [owner?.owner_id ?? -1]).map(id => resolveYearWindow(id, year)); - const holidayStart = windows.reduce((a, w) => (w.start > a ? w.start : a), windows[0].start); - const holidayEnd = windows.reduce((a, w) => (w.end < a ? w.end : a), windows[0].end); - if (holidayStart < holidayEnd) { - db.prepare('DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date >= ? AND date < ?').run(planId, holidayStart, holidayEnd); - } - db.prepare('DELETE FROM vacay_user_years WHERE plan_id = ? AND year = ?').run(planId, year); - - // Recalculate carry-over for year+1 if it exists, since its previous year has changed - const nextYearExists = db.prepare('SELECT id FROM vacay_years WHERE plan_id = ? AND year = ?').get(planId, year + 1); - if (nextYearExists) { - const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan | undefined; - const carryOverEnabled = plan ? !!plan.carry_over_enabled : true; - const users = getPlanUsers(planId); - const prevYear = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? AND year < ? ORDER BY year DESC LIMIT 1').get(planId, year + 1) as { year: number } | undefined; - - for (const u of users) { - let carry = 0; - if (carryOverEnabled && prevYear) { - const prevConfig = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, prevYear.year) as VacayUserYear | undefined; - if (prevConfig) { - const used = usedDays(u.id, planId, prevYear.year); - const total = prevConfig.vacation_days + prevConfig.carried_over; - carry = Math.max(0, total - used); - } - } - db.prepare('UPDATE vacay_user_years SET carried_over = ? WHERE user_id = ? AND plan_id = ? AND year = ?').run(carry, u.id, planId, year + 1); - } - } - - notifyPlanUsers(planId, socketId, 'vacay:settings'); - return listYears(planId); -} - -// --------------------------------------------------------------------------- -// Entries -// --------------------------------------------------------------------------- - -/** - * Entries and company holidays for the grid. The range is the viewer's leave-year - * window (#737) — a shifted year spans two calendar years, so the old year prefix - * would drop the second half. For 'calendar' the range is Jan 1 – Dec 31 again. - */ -export function getEntries(planId: number, year: string, viewerId?: number) { - const { start, end } = viewerGridWindow(year, viewerId); - const entries = db.prepare(` - SELECT e.*, u.username as person_name, COALESCE(c.color, '#6366f1') as person_color - FROM vacay_entries e - JOIN users u ON e.user_id = u.id - LEFT JOIN vacay_user_colors c ON c.user_id = e.user_id AND c.plan_id = e.plan_id - WHERE e.plan_id = ? AND e.date >= ? AND e.date < ? - `).all(planId, start, end); - const companyHolidays = db.prepare('SELECT * FROM vacay_company_holidays WHERE plan_id = ? AND date >= ? AND date < ?').all(planId, start, end); - return { entries, companyHolidays }; -} - -export function toggleEntry(userId: number, planId: number, date: string, fraction?: unknown, kind?: unknown, socketId?: string): { action: string; fraction?: number; kind?: string } { - const frac = normalizeFraction(fraction); - const knd = normalizeKind(kind); - const existing = db.prepare('SELECT id, fraction, kind FROM vacay_entries WHERE user_id = ? AND date = ? AND plan_id = ?').get(userId, date, planId) as { id: number; fraction: number; kind: string | null } | undefined; - if (existing) { - // Clicking the exact same day again (same type AND same fraction) clears it; - // clicking a different type or fraction converts it in place (#552/#1074). - if (existing.fraction === frac && (existing.kind || 'vacation') === knd) { - db.prepare('DELETE FROM vacay_entries WHERE id = ?').run(existing.id); - notifyPlanUsers(planId, socketId); - return { action: 'removed' }; - } - db.prepare('UPDATE vacay_entries SET fraction = ?, kind = ? WHERE id = ?').run(frac, knd, existing.id); - notifyPlanUsers(planId, socketId); - return { action: 'updated', fraction: frac, kind: knd }; - } - db.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note, fraction, kind) VALUES (?, ?, ?, ?, ?, ?)').run(planId, userId, date, '', frac, knd); - notifyPlanUsers(planId, socketId); - return { action: 'added', fraction: frac, kind: knd }; -} - -export function toggleCompanyHoliday(planId: number, date: string, note: string | undefined, socketId: string | undefined): { action: string } { - const existing = db.prepare('SELECT id FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').get(planId, date) as { id: number } | undefined; - if (existing) { - db.prepare('DELETE FROM vacay_company_holidays WHERE id = ?').run(existing.id); - notifyPlanUsers(planId, socketId); - return { action: 'removed' }; - } else { - db.prepare('INSERT INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(planId, date, note || ''); - db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, date); - notifyPlanUsers(planId, socketId); - return { action: 'added' }; - } -} - -// --------------------------------------------------------------------------- -// Stats -// --------------------------------------------------------------------------- - -export function getStats(planId: number, year: number) { - const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan | undefined; - const carryOverEnabled = plan ? !!plan.carry_over_enabled : true; - const users = getPlanUsers(planId); - - return users.map(u => { - const used = usedDays(u.id, planId, year); - const compUsed = compUsedDays(u.id, planId, year); - const config = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, year) as VacayUserYear | undefined; - const vacationDays = config ? config.vacation_days : 30; - const carriedOver = carryOverEnabled ? (config ? config.carried_over : 0) : 0; - const total = vacationDays + carriedOver; - const remaining = total - used; - const colorRow = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(u.id, planId) as { color: string } | undefined; - // The period this row was computed over (#737) — the UI labels the window and - // the carry-over source with it instead of assuming Jan–Dec / year − 1. - const window = resolveYearWindow(u.id, year); - - const nextYearExists = db.prepare('SELECT id FROM vacay_years WHERE plan_id = ? AND year = ?').get(planId, year + 1); - if (nextYearExists && carryOverEnabled) { - const carry = Math.max(0, remaining); - db.prepare(` - INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?) - ON CONFLICT(user_id, plan_id, year) DO UPDATE SET carried_over = ? - `).run(u.id, planId, year + 1, carry, carry); - } - - return { - user_id: u.id, person_name: u.username, person_color: colorRow?.color || '#6366f1', - year, vacation_days: vacationDays, carried_over: carriedOver, - total_available: total, used, remaining, comp_used: compUsed, - window_start: window.start, window_end: window.end, - }; - }); -} - -export function updateStats(userId: number, planId: number, year: number, vacationDays: number, socketId: string | undefined): void { - db.prepare(` - INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, ?, 0) - ON CONFLICT(user_id, plan_id, year) DO UPDATE SET vacation_days = excluded.vacation_days - `).run(userId, planId, year, vacationDays); - notifyPlanUsers(planId, socketId); -} - -// --------------------------------------------------------------------------- -// GET /plan composite -// --------------------------------------------------------------------------- - -export function getPlanData(userId: number) { - const plan = getActivePlan(userId); - const activePlanId = plan.id; - - const users = getPlanUsers(activePlanId).map(u => { - const colorRow = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(u.id, activePlanId) as { color: string } | undefined; - return { ...u, color: colorRow?.color || '#6366f1' }; - }); - - const pendingInvites = db.prepare(` - SELECT m.id, m.user_id, u.username, u.email, m.created_at - FROM vacay_plan_members m JOIN users u ON m.user_id = u.id - WHERE m.plan_id = ? AND m.status = 'pending' - `).all(activePlanId); - - const incomingInvites = db.prepare(` - SELECT m.id, m.plan_id, u.username, u.email, m.created_at - FROM vacay_plan_members m - JOIN vacay_plans p ON m.plan_id = p.id - JOIN users u ON p.owner_id = u.id - WHERE m.user_id = ? AND m.status = 'pending' - `).all(userId); - - const holidayCalendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(activePlanId) as VacayHolidayCalendar[]; - - return { - plan: { - ...plan, - block_weekends: !!plan.block_weekends, - holidays_enabled: !!plan.holidays_enabled, - school_holidays_enabled: !!plan.school_holidays_enabled, - company_holidays_enabled: !!plan.company_holidays_enabled, - carry_over_enabled: !!plan.carry_over_enabled, - holiday_calendars: holidayCalendars, - }, - users, - pendingInvites, - incomingInvites, - isOwner: plan.owner_id === userId, - isFused: users.length > 1, - }; -} - -// --------------------------------------------------------------------------- -// Holidays (nager.at proxy with cache) -// --------------------------------------------------------------------------- - -export async function getCountries(): Promise<{ data?: unknown; error?: string }> { - const cacheKey = 'countries'; - const cached = holidayCache.get(cacheKey); - if (cached && Date.now() - cached.time < CACHE_TTL) return { data: cached.data }; - try { - const resp = await fetch('https://date.nager.at/api/v3/AvailableCountries'); - const data = await resp.json(); - holidayCache.set(cacheKey, { data, time: Date.now() }); - return { data }; - } catch { - return { error: 'Failed to fetch countries' }; - } -} - -export async function getHolidays(year: string, country: string): Promise<{ data?: unknown; error?: string }> { - const cacheKey = `${year}-${country}`; - const cached = holidayCache.get(cacheKey); - if (cached && Date.now() - cached.time < CACHE_TTL) return { data: cached.data }; - try { - const resp = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/${country}`); - const data = await resp.json(); - holidayCache.set(cacheKey, { data, time: Date.now() }); - return { data }; - } catch { - return { error: 'Failed to fetch holidays' }; - } -} - -export async function getSchoolHolidayRegions(country: string, language = 'EN'): Promise<{ data?: unknown; error?: string }> { - const normalizedLanguage = String(language || 'EN').slice(0, 2).toUpperCase(); - const cacheKey = `school-regions-${country}-${normalizedLanguage}`; - const cached = holidayCache.get(cacheKey); - if (cached && Date.now() - cached.time < CACHE_TTL) return { data: cached.data }; - try { - const [groupsResp, subdivisionsResp] = await Promise.all([ - fetch(`https://openholidaysapi.org/Groups?countryIsoCode=${country}&languageIsoCode=${normalizedLanguage}`, { headers: { accept: 'text/json' } }), - fetch(`https://openholidaysapi.org/Subdivisions?countryIsoCode=${country}&languageIsoCode=${normalizedLanguage}`, { headers: { accept: 'text/json' } }), - ]); - if (!groupsResp.ok || !subdivisionsResp.ok) return { error: 'Failed to fetch school holiday regions' }; - const data = { - groups: await groupsResp.json(), - subdivisions: await subdivisionsResp.json(), - }; - holidayCache.set(cacheKey, { data, time: Date.now() }); - return { data }; - } catch { - return { error: 'Failed to fetch school holiday regions' }; - } -} - -export async function getSchoolHolidays(year: string, country: string, subdivision?: string | null, language = 'EN', group?: string | null): Promise<{ data?: unknown; error?: string }> { - const normalizedLanguage = String(language || 'EN').slice(0, 2).toUpperCase(); - const normalizedSubdivision = subdivision || ''; - const normalizedGroup = group || ''; - const cacheKey = `school-${year}-${country}-${normalizedSubdivision || 'all'}-${normalizedGroup || 'all'}-${normalizedLanguage}`; - const cached = holidayCache.get(cacheKey); - if (cached && Date.now() - cached.time < CACHE_TTL) return { data: cached.data }; - try { - const params = new URLSearchParams({ - countryIsoCode: country, - languageIsoCode: normalizedLanguage, - validFrom: `${year}-01-01`, - validTo: `${year}-12-31`, - }); - if (normalizedSubdivision) params.set('subdivisionCode', normalizedSubdivision); - if (normalizedGroup) params.set('groupCode', normalizedGroup); - const resp = await fetch(`https://openholidaysapi.org/SchoolHolidays?${params.toString()}`, { - headers: { accept: 'text/json' }, - }); - if (!resp.ok) return { error: 'Failed to fetch school holidays' }; - const data = await resp.json(); - holidayCache.set(cacheKey, { data, time: Date.now() }); - return { data }; - } catch { - return { error: 'Failed to fetch school holidays' }; - } -} diff --git a/server/tests/e2e/vacay.e2e.test.ts b/server/tests/e2e/vacay.e2e.test.ts index 02426cb8c..9f7e23632 100644 --- a/server/tests/e2e/vacay.e2e.test.ts +++ b/server/tests/e2e/vacay.e2e.test.ts @@ -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>; 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 () => { diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index 503976373..3e2b2c05f 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -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 }, ); diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index 36116e5d2..634f2e3d0 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -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), ); } diff --git a/server/tests/integration/vacay.test.ts b/server/tests/integration/vacay.test.ts index f63cf47e7..8e8167472 100644 --- a/server/tests/integration/vacay.test.ts +++ b/server/tests/integration/vacay.test.ts @@ -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('../../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'; diff --git a/server/tests/unit/mcp/tools-vacay.test.ts b/server/tests/unit/mcp/tools-vacay.test.ts index 0795c33a1..0712834f3 100644 --- a/server/tests/unit/mcp/tools-vacay.test.ts +++ b/server/tests/unit/mcp/tools-vacay.test.ts @@ -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; - 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([]); + }); + }); +}); diff --git a/server/tests/unit/services/vacayService.test.ts b/server/tests/unit/nest/vacay.service.test.ts similarity index 73% rename from server/tests/unit/services/vacayService.test.ts rename to server/tests/unit/nest/vacay.service.test.ts index 9f1c3e9a3..f3d6a9967 100644 --- a/server/tests/unit/services/vacayService.test.ts +++ b/server/tests/unit/nest/vacay.service.test.ts @@ -33,46 +33,26 @@ import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser } from '../../helpers/factories'; -import { - getOwnPlan, - getActivePlan, - getPlanUsers, - migrateHolidayCalendars, - updatePlan, - addHolidayCalendar, - updateHolidayCalendar, - deleteHolidayCalendar, - setUserColor, - acceptInvite, - declineInvite, - cancelInvite, - getAvailableUsers, - listYears, - addYear, - deleteYear, - getEntries, - toggleEntry, - toggleCompanyHoliday, - getStats, - applyHolidayCalendars, - listShares, - shareCalendar, - removeShare, - setShareHidden, - getShareAvailableUsers, - getSharedCalendars, - resolveYearWindow, - updateUserYearSettings, - getUserYearSettings, - getYearSettings, - currentPeriodYear, -} from '../../../src/services/vacayService'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import { VacayService } from '../../../src/nest/vacay/vacay.service'; +import { shiftOwnerEntriesForTripWindow } from '../../../src/nest/vacay/vacay.bridge'; + +// VACAY-SVC-001 through VACAY-SVC-066 moved 1:1 from the legacy +// tests/unit/services/vacayService.test.ts (the named-function imports became +// method calls on a directly constructed VacayService; the legacy +// updateUserYearSettings is the class's updateYearSettings). VACAY-SVC-067 +// pins the vacay.bridge delegation for the one outside-container consumer. +const svc = new VacayService(new DatabaseService(testDb)); // ── Lifecycle ───────────────────────────────────────────────────────────────── -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + // Warm the (mocked) notificationService module: sendInvite/shareCalendar do a + // fire-and-forget dynamic import of it, and a cold load can otherwise race the + // worker teardown ("Cannot load ... after the environment was torn down"). + await import('../../../src/services/notificationService'); }); beforeEach(() => { @@ -102,7 +82,7 @@ function insertMember(planId: number, userId: number, status: 'pending' | 'accep /** Fast helper: create a user and immediately materialise their own plan. */ function setupUserWithPlan() { const { user } = createUser(testDb); - const plan = getOwnPlan(user.id); + const plan = svc.getOwnPlan(user.id); return { user, plan }; } @@ -111,7 +91,7 @@ function setupUserWithPlan() { describe('getOwnPlan', () => { it('VACAY-SVC-001: creates a new plan on first call for a fresh user', () => { const { user } = createUser(testDb); - const plan = getOwnPlan(user.id); + const plan = svc.getOwnPlan(user.id); expect(plan).toBeDefined(); expect(plan.owner_id).toBe(user.id); @@ -120,15 +100,15 @@ describe('getOwnPlan', () => { it('VACAY-SVC-002: returns the same plan on a second call (idempotent)', () => { const { user } = createUser(testDb); - const first = getOwnPlan(user.id); - const second = getOwnPlan(user.id); + const first = svc.getOwnPlan(user.id); + const second = svc.getOwnPlan(user.id); expect(second.id).toBe(first.id); }); it('VACAY-SVC-003: seeds the current year row in vacay_years after plan creation', () => { const { user } = createUser(testDb); - const plan = getOwnPlan(user.id); + const plan = svc.getOwnPlan(user.id); const yr = new Date().getFullYear(); const row = testDb @@ -140,7 +120,7 @@ describe('getOwnPlan', () => { it('VACAY-SVC-004: seeds the current year user_year row with default 30 vacation_days', () => { const { user } = createUser(testDb); - const plan = getOwnPlan(user.id); + const plan = svc.getOwnPlan(user.id); const yr = new Date().getFullYear(); const row = testDb @@ -157,7 +137,7 @@ describe('getOwnPlan', () => { describe('getActivePlan', () => { it('VACAY-SVC-005: returns own plan when user has no accepted membership in another plan', () => { const { user, plan } = setupUserWithPlan(); - const active = getActivePlan(user.id); + const active = svc.getActivePlan(user.id); expect(active.id).toBe(plan.id); expect(active.owner_id).toBe(user.id); @@ -167,22 +147,22 @@ describe('getActivePlan', () => { const { user: owner, plan: ownerPlan } = setupUserWithPlan(); const { user: member } = createUser(testDb); // Make sure member also has their own plan materialised first - getOwnPlan(member.id); + svc.getOwnPlan(member.id); insertMember(ownerPlan.id, member.id, 'accepted'); - const active = getActivePlan(member.id); + const active = svc.getActivePlan(member.id); expect(active.id).toBe(ownerPlan.id); }); it('VACAY-SVC-007: pending membership does NOT override own plan as active', () => { const { user: owner, plan: ownerPlan } = setupUserWithPlan(); const { user: member } = createUser(testDb); - getOwnPlan(member.id); + svc.getOwnPlan(member.id); insertMember(ownerPlan.id, member.id, 'pending'); - const active = getActivePlan(member.id); + const active = svc.getActivePlan(member.id); // Should still point to member's own plan expect(active.owner_id).toBe(member.id); }); @@ -193,7 +173,7 @@ describe('getActivePlan', () => { describe('getPlanUsers', () => { it('VACAY-SVC-008: returns [owner] for a solo plan', () => { const { user, plan } = setupUserWithPlan(); - const users = getPlanUsers(plan.id); + const users = svc.getPlanUsers(plan.id); expect(users).toHaveLength(1); expect(users[0].id).toBe(user.id); @@ -204,7 +184,7 @@ describe('getPlanUsers', () => { const { user: member } = createUser(testDb); insertMember(plan.id, member.id, 'accepted'); - const users = getPlanUsers(plan.id); + const users = svc.getPlanUsers(plan.id); expect(users).toHaveLength(2); expect(users.map(u => u.id)).toContain(owner.id); @@ -216,12 +196,12 @@ describe('getPlanUsers', () => { const { user: pendingUser } = createUser(testDb); insertMember(plan.id, pendingUser.id, 'pending'); - const users = getPlanUsers(plan.id); + const users = svc.getPlanUsers(plan.id); expect(users.map(u => u.id)).not.toContain(pendingUser.id); }); it('VACAY-SVC-011: returns empty array for a non-existent plan id', () => { - const users = getPlanUsers(99999); + const users = svc.getPlanUsers(99999); expect(users).toEqual([]); }); }); @@ -233,7 +213,7 @@ describe('migrateHolidayCalendars', () => { const { plan } = setupUserWithPlan(); const planRow = { ...plan, holidays_enabled: 0, holidays_region: 'DE' }; - await migrateHolidayCalendars(plan.id, planRow); + await svc.migrateHolidayCalendars(plan.id, planRow); const rows = testDb .prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ?') @@ -245,7 +225,7 @@ describe('migrateHolidayCalendars', () => { const { plan } = setupUserWithPlan(); const planRow = { ...plan, holidays_enabled: 1, holidays_region: 'DE' }; - await migrateHolidayCalendars(plan.id, planRow); + await svc.migrateHolidayCalendars(plan.id, planRow); const rows = testDb .prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ?') @@ -258,9 +238,9 @@ describe('migrateHolidayCalendars', () => { const { plan } = setupUserWithPlan(); const planRow = { ...plan, holidays_enabled: 1, holidays_region: 'FR' }; - await migrateHolidayCalendars(plan.id, planRow); + await svc.migrateHolidayCalendars(plan.id, planRow); // Call a second time — should NOT insert another row - await migrateHolidayCalendars(plan.id, planRow); + await svc.migrateHolidayCalendars(plan.id, planRow); const rows = testDb .prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ?') @@ -275,7 +255,7 @@ describe('updatePlan', () => { it('VACAY-SVC-015: updates block_weekends flag', async () => { const { plan } = setupUserWithPlan(); - await updatePlan(plan.id, { block_weekends: true }, undefined); + await svc.updatePlan(plan.id, { block_weekends: true }, undefined); const updated = testDb .prepare('SELECT block_weekends FROM vacay_plans WHERE id = ?') @@ -286,7 +266,7 @@ describe('updatePlan', () => { it('VACAY-SVC-016: updates holidays_enabled flag', async () => { const { plan } = setupUserWithPlan(); - await updatePlan(plan.id, { holidays_enabled: true }, undefined); + await svc.updatePlan(plan.id, { holidays_enabled: true }, undefined); const updated = testDb .prepare('SELECT holidays_enabled FROM vacay_plans WHERE id = ?') @@ -297,7 +277,7 @@ describe('updatePlan', () => { it('VACAY-SVC-017: returns the updated plan object with boolean-coerced flags', async () => { const { plan } = setupUserWithPlan(); - const result = await updatePlan(plan.id, { block_weekends: false }, undefined); + const result = await svc.updatePlan(plan.id, { block_weekends: false }, undefined); expect(result.plan.block_weekends).toBe(false); expect(typeof result.plan.holidays_enabled).toBe('boolean'); @@ -312,7 +292,7 @@ describe('updatePlan', () => { .prepare('UPDATE vacay_user_years SET carried_over = 5 WHERE user_id = ? AND plan_id = ? AND year = ?') .run(user.id, plan.id, yr); - await updatePlan(plan.id, { carry_over_enabled: false }, undefined); + await svc.updatePlan(plan.id, { carry_over_enabled: false }, undefined); const row = testDb .prepare('SELECT carried_over FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?') @@ -327,7 +307,7 @@ describe('addHolidayCalendar', () => { it('VACAY-SVC-019: inserts a new calendar row and returns the calendar object', () => { const { plan } = setupUserWithPlan(); - const cal = addHolidayCalendar(plan.id, 'GB', 'UK Holidays', '#ff0000', 0, undefined); + const cal = svc.addHolidayCalendar(plan.id, 'GB', 'UK Holidays', '#ff0000', 0, undefined); expect(cal).toBeDefined(); expect(cal.id).toBeGreaterThan(0); @@ -339,7 +319,7 @@ describe('addHolidayCalendar', () => { it('VACAY-SVC-020: uses default color #fecaca when no color is provided', () => { const { plan } = setupUserWithPlan(); - const cal = addHolidayCalendar(plan.id, 'US', null, undefined, 0, undefined); + const cal = svc.addHolidayCalendar(plan.id, 'US', null, undefined, 0, undefined); expect(cal.color).toBe('#fecaca'); }); @@ -350,9 +330,9 @@ describe('addHolidayCalendar', () => { describe('updateHolidayCalendar', () => { it('VACAY-SVC-021: changes label and color on an existing calendar', () => { const { plan } = setupUserWithPlan(); - const cal = addHolidayCalendar(plan.id, 'DE', 'Germany', '#aabbcc', 0, undefined); + const cal = svc.addHolidayCalendar(plan.id, 'DE', 'Germany', '#aabbcc', 0, undefined); - const updated = updateHolidayCalendar(cal.id, plan.id, { label: 'Deutschland', color: '#112233' }, undefined); + const updated = svc.updateHolidayCalendar(cal.id, plan.id, { label: 'Deutschland', color: '#112233' }, undefined); expect(updated).not.toBeNull(); expect(updated!.label).toBe('Deutschland'); @@ -362,7 +342,7 @@ describe('updateHolidayCalendar', () => { it('VACAY-SVC-022: returns null when the calendar id does not exist in the plan', () => { const { plan } = setupUserWithPlan(); - const result = updateHolidayCalendar(99999, plan.id, { label: 'Nope' }, undefined); + const result = svc.updateHolidayCalendar(99999, plan.id, { label: 'Nope' }, undefined); expect(result).toBeNull(); }); @@ -373,9 +353,9 @@ describe('updateHolidayCalendar', () => { describe('deleteHolidayCalendar', () => { it('VACAY-SVC-023: removes the calendar row and returns true on success', () => { const { plan } = setupUserWithPlan(); - const cal = addHolidayCalendar(plan.id, 'FR', null, undefined, 0, undefined); + const cal = svc.addHolidayCalendar(plan.id, 'FR', null, undefined, 0, undefined); - const result = deleteHolidayCalendar(cal.id, plan.id, undefined); + const result = svc.deleteHolidayCalendar(cal.id, plan.id, undefined); expect(result).toBe(true); const row = testDb.prepare('SELECT id FROM vacay_holiday_calendars WHERE id = ?').get(cal.id); @@ -385,7 +365,7 @@ describe('deleteHolidayCalendar', () => { it('VACAY-SVC-024: returns false when the calendar does not exist', () => { const { plan } = setupUserWithPlan(); - const result = deleteHolidayCalendar(99999, plan.id, undefined); + const result = svc.deleteHolidayCalendar(99999, plan.id, undefined); expect(result).toBe(false); }); @@ -397,7 +377,7 @@ describe('setUserColor', () => { it('VACAY-SVC-025: inserts a color for a user in a plan', () => { const { user, plan } = setupUserWithPlan(); - setUserColor(user.id, plan.id, '#123456', undefined); + svc.setUserColor(user.id, plan.id, '#123456', undefined); const row = testDb .prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?') @@ -407,9 +387,9 @@ describe('setUserColor', () => { it('VACAY-SVC-026: updates the color when called a second time (upsert)', () => { const { user, plan } = setupUserWithPlan(); - setUserColor(user.id, plan.id, '#aaaaaa', undefined); + svc.setUserColor(user.id, plan.id, '#aaaaaa', undefined); - setUserColor(user.id, plan.id, '#bbbbbb', undefined); + svc.setUserColor(user.id, plan.id, '#bbbbbb', undefined); const row = testDb .prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?') @@ -425,7 +405,7 @@ describe('listYears', () => { const { plan } = setupUserWithPlan(); const yr = new Date().getFullYear(); - const years = listYears(plan.id); + const years = svc.listYears(plan.id); expect(years).toContain(yr); }); @@ -436,9 +416,9 @@ describe('addYear', () => { const { user, plan } = setupUserWithPlan(); const newYear = new Date().getFullYear() + 2; - addYear(plan.id, newYear, undefined); + svc.addYear(plan.id, newYear, undefined); - const years = listYears(plan.id); + const years = svc.listYears(plan.id); expect(years).toContain(newYear); const userYear = testDb @@ -466,7 +446,7 @@ describe('addYear', () => { testDb.prepare('INSERT OR IGNORE INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(plan.id, user.id, dateStr, ''); } - addYear(plan.id, nextYear, undefined); + svc.addYear(plan.id, nextYear, undefined); const userYear = testDb .prepare('SELECT carried_over FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?') @@ -481,13 +461,13 @@ describe('deleteYear', () => { const { user, plan } = setupUserWithPlan(); const targetYear = new Date().getFullYear() + 3; - addYear(plan.id, targetYear, undefined); + svc.addYear(plan.id, targetYear, undefined); // Insert an entry for that year testDb .prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)') .run(plan.id, user.id, `${targetYear}-07-15`, ''); - deleteYear(plan.id, targetYear, undefined); + svc.deleteYear(plan.id, targetYear, undefined); const yearRow = testDb .prepare('SELECT * FROM vacay_years WHERE plan_id = ? AND year = ?') @@ -508,7 +488,7 @@ describe('getEntries', () => { const { plan } = setupUserWithPlan(); const yr = new Date().getFullYear().toString(); - const result = getEntries(plan.id, yr); + const result = svc.getEntries(plan.id, yr); expect(result.entries).toEqual([]); expect(result.companyHolidays).toEqual([]); @@ -519,7 +499,7 @@ describe('toggleEntry', () => { it('VACAY-SVC-032: adds an entry on first call (action: added)', () => { const { user, plan } = setupUserWithPlan(); - const result = toggleEntry(user.id, plan.id, '2025-08-01', undefined); + const result = svc.toggleEntry(user.id, plan.id, '2025-08-01', undefined); expect(result.action).toBe('added'); const row = testDb @@ -531,8 +511,8 @@ describe('toggleEntry', () => { it('VACAY-SVC-033: removes the entry on second call (action: removed)', () => { const { user, plan } = setupUserWithPlan(); - toggleEntry(user.id, plan.id, '2025-08-02', undefined); - const result = toggleEntry(user.id, plan.id, '2025-08-02', undefined); + svc.toggleEntry(user.id, plan.id, '2025-08-02', undefined); + const result = svc.toggleEntry(user.id, plan.id, '2025-08-02', undefined); expect(result.action).toBe('removed'); const row = testDb @@ -544,7 +524,7 @@ describe('toggleEntry', () => { it('VACAY-SVC-033a: logs a half day when fraction is 0.5 (#552)', () => { const { user, plan } = setupUserWithPlan(); - const result = toggleEntry(user.id, plan.id, '2025-08-05', 0.5); + const result = svc.toggleEntry(user.id, plan.id, '2025-08-05', 0.5); expect(result).toMatchObject({ action: 'added', fraction: 0.5 }); const row = testDb @@ -556,8 +536,8 @@ describe('toggleEntry', () => { it('VACAY-SVC-033b: converts a full day into a half day in place (action: updated)', () => { const { user, plan } = setupUserWithPlan(); - toggleEntry(user.id, plan.id, '2025-08-06', 1); - const result = toggleEntry(user.id, plan.id, '2025-08-06', 0.5); + svc.toggleEntry(user.id, plan.id, '2025-08-06', 1); + const result = svc.toggleEntry(user.id, plan.id, '2025-08-06', 0.5); expect(result).toMatchObject({ action: 'updated', fraction: 0.5 }); const row = testDb @@ -569,8 +549,8 @@ describe('toggleEntry', () => { it('VACAY-SVC-033c: toggling the same half day again clears it (action: removed)', () => { const { user, plan } = setupUserWithPlan(); - toggleEntry(user.id, plan.id, '2025-08-07', 0.5); - const result = toggleEntry(user.id, plan.id, '2025-08-07', 0.5); + svc.toggleEntry(user.id, plan.id, '2025-08-07', 0.5); + const result = svc.toggleEntry(user.id, plan.id, '2025-08-07', 0.5); expect(result.action).toBe('removed'); const row = testDb @@ -586,7 +566,7 @@ describe('toggleCompanyHoliday', () => { it('VACAY-SVC-034: adds a company holiday on first call (action: added)', () => { const { plan } = setupUserWithPlan(); - const result = toggleCompanyHoliday(plan.id, '2025-12-25', 'Christmas', undefined); + const result = svc.toggleCompanyHoliday(plan.id, '2025-12-25', 'Christmas', undefined); expect(result.action).toBe('added'); const row = testDb @@ -598,8 +578,8 @@ describe('toggleCompanyHoliday', () => { it('VACAY-SVC-035: removes the company holiday on second call (action: removed)', () => { const { plan } = setupUserWithPlan(); - toggleCompanyHoliday(plan.id, '2025-12-26', 'Boxing Day', undefined); - const result = toggleCompanyHoliday(plan.id, '2025-12-26', undefined, undefined); + svc.toggleCompanyHoliday(plan.id, '2025-12-26', 'Boxing Day', undefined); + const result = svc.toggleCompanyHoliday(plan.id, '2025-12-26', undefined, undefined); expect(result.action).toBe('removed'); const row = testDb @@ -612,10 +592,10 @@ describe('toggleCompanyHoliday', () => { const { user, plan } = setupUserWithPlan(); // First add a personal entry on that date - toggleEntry(user.id, plan.id, '2025-05-01', undefined); + svc.toggleEntry(user.id, plan.id, '2025-05-01', undefined); // Now declare it a company holiday — the personal entry should be wiped - toggleCompanyHoliday(plan.id, '2025-05-01', 'Labour Day', undefined); + svc.toggleCompanyHoliday(plan.id, '2025-05-01', 'Labour Day', undefined); const personalEntry = testDb .prepare('SELECT * FROM vacay_entries WHERE plan_id = ? AND date = ?') @@ -630,10 +610,10 @@ describe('acceptInvite', () => { it('VACAY-SVC-037: changes membership status to accepted', () => { const { user: owner, plan: ownerPlan } = setupUserWithPlan(); const { user: invitee } = createUser(testDb); - getOwnPlan(invitee.id); // ensure own plan exists for data migration path + svc.getOwnPlan(invitee.id); // ensure own plan exists for data migration path insertMember(ownerPlan.id, invitee.id, 'pending'); - const result = acceptInvite(invitee.id, ownerPlan.id, undefined); + const result = svc.acceptInvite(invitee.id, ownerPlan.id, undefined); expect(result.error).toBeUndefined(); const row = testDb @@ -645,7 +625,7 @@ describe('acceptInvite', () => { it('VACAY-SVC-038: returns 404 error when there is no pending invite', () => { const { user } = createUser(testDb); - const result = acceptInvite(user.id, 99999, undefined); + const result = svc.acceptInvite(user.id, 99999, undefined); expect(result.status).toBe(404); expect(result.error).toBeDefined(); @@ -654,12 +634,12 @@ describe('acceptInvite', () => { it('VACAY-SVC-039: accepted member becomes visible via getActivePlan', () => { const { user: owner, plan: ownerPlan } = setupUserWithPlan(); const { user: invitee } = createUser(testDb); - getOwnPlan(invitee.id); + svc.getOwnPlan(invitee.id); insertMember(ownerPlan.id, invitee.id, 'pending'); - acceptInvite(invitee.id, ownerPlan.id, undefined); + svc.acceptInvite(invitee.id, ownerPlan.id, undefined); - const active = getActivePlan(invitee.id); + const active = svc.getActivePlan(invitee.id); expect(active.id).toBe(ownerPlan.id); }); }); @@ -670,7 +650,7 @@ describe('declineInvite', () => { const { user: invitee } = createUser(testDb); insertMember(ownerPlan.id, invitee.id, 'pending'); - declineInvite(invitee.id, ownerPlan.id, undefined); + svc.declineInvite(invitee.id, ownerPlan.id, undefined); const row = testDb .prepare('SELECT * FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?') @@ -685,7 +665,7 @@ describe('cancelInvite', () => { const { user: target } = createUser(testDb); insertMember(ownerPlan.id, target.id, 'pending'); - cancelInvite(ownerPlan.id, target.id); + svc.cancelInvite(ownerPlan.id, target.id); const row = testDb .prepare('SELECT * FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?') @@ -700,9 +680,9 @@ describe('getAvailableUsers', () => { it('VACAY-SVC-042: returns users not already in the plan and not fused elsewhere', () => { const { user: owner, plan } = setupUserWithPlan(); const { user: unrelated } = createUser(testDb); - getOwnPlan(unrelated.id); + svc.getOwnPlan(unrelated.id); - const available = getAvailableUsers(owner.id, plan.id) as { id: number }[]; + const available = svc.getAvailableUsers(owner.id, plan.id) as { id: number }[]; expect(available.map(u => u.id)).toContain(unrelated.id); // Owner themselves should NOT appear (excluded by u.id != ?) @@ -715,7 +695,7 @@ describe('getAvailableUsers', () => { const { plan: otherPlan } = setupUserWithPlan(); insertMember(otherPlan.id, alreadyFused.id, 'accepted'); - const available = getAvailableUsers(owner.id, plan.id) as { id: number }[]; + const available = svc.getAvailableUsers(owner.id, plan.id) as { id: number }[]; expect(available.map(u => u.id)).not.toContain(alreadyFused.id); }); @@ -728,7 +708,7 @@ describe('getStats', () => { const { user, plan } = setupUserWithPlan(); const yr = new Date().getFullYear(); - const stats = getStats(plan.id, yr); + const stats = svc.getStats(plan.id, yr); expect(stats).toHaveLength(1); expect(stats[0]).toMatchObject({ @@ -744,10 +724,10 @@ describe('getStats', () => { const { user, plan } = setupUserWithPlan(); const yr = new Date().getFullYear(); - toggleEntry(user.id, plan.id, `${yr}-09-10`, undefined); - toggleEntry(user.id, plan.id, `${yr}-09-11`, undefined); + svc.toggleEntry(user.id, plan.id, `${yr}-09-10`, undefined); + svc.toggleEntry(user.id, plan.id, `${yr}-09-11`, undefined); - const stats = getStats(plan.id, yr); + const stats = svc.getStats(plan.id, yr); expect(stats[0].used).toBe(2); expect(stats[0].remaining).toBe(28); @@ -757,10 +737,10 @@ describe('getStats', () => { const { user, plan } = setupUserWithPlan(); const yr = new Date().getFullYear(); - toggleEntry(user.id, plan.id, `${yr}-09-12`, 1); // full day - toggleEntry(user.id, plan.id, `${yr}-09-13`, 0.5); // half day + svc.toggleEntry(user.id, plan.id, `${yr}-09-12`, 1); // full day + svc.toggleEntry(user.id, plan.id, `${yr}-09-13`, 0.5); // half day - const stats = getStats(plan.id, yr); + const stats = svc.getStats(plan.id, yr); expect(stats[0].used).toBe(1.5); expect(stats[0].remaining).toBe(28.5); @@ -770,10 +750,10 @@ describe('getStats', () => { const { user, plan } = setupUserWithPlan(); const yr = new Date().getFullYear(); - toggleEntry(user.id, plan.id, `${yr}-09-14`, 1, 'vacation'); - toggleEntry(user.id, plan.id, `${yr}-09-15`, 1, 'comp'); + svc.toggleEntry(user.id, plan.id, `${yr}-09-14`, 1, 'vacation'); + svc.toggleEntry(user.id, plan.id, `${yr}-09-15`, 1, 'comp'); - const stats = getStats(plan.id, yr); + const stats = svc.getStats(plan.id, yr); expect(stats[0].used).toBe(1); expect(stats[0].remaining).toBe(29); @@ -783,9 +763,9 @@ describe('getStats', () => { const { user, plan } = setupUserWithPlan(); const yr = new Date().getFullYear(); - toggleEntry(user.id, plan.id, `${yr}-09-16`, 0.5, 'comp'); + svc.toggleEntry(user.id, plan.id, `${yr}-09-16`, 0.5, 'comp'); - const stats = getStats(plan.id, yr); + const stats = svc.getStats(plan.id, yr); expect(stats[0].used).toBe(0); expect(stats[0].remaining).toBe(30); @@ -795,11 +775,11 @@ describe('getStats', () => { const { user, plan } = setupUserWithPlan(); const yr = new Date().getFullYear(); - toggleEntry(user.id, plan.id, `${yr}-09-17`, 1, 'comp'); - toggleEntry(user.id, plan.id, `${yr}-09-18`, 0.5, 'comp'); - toggleEntry(user.id, plan.id, `${yr}-09-19`, 1, 'vacation'); + svc.toggleEntry(user.id, plan.id, `${yr}-09-17`, 1, 'comp'); + svc.toggleEntry(user.id, plan.id, `${yr}-09-18`, 0.5, 'comp'); + svc.toggleEntry(user.id, plan.id, `${yr}-09-19`, 1, 'vacation'); - const stats = getStats(plan.id, yr); + const stats = svc.getStats(plan.id, yr); expect(stats[0].comp_used).toBe(1.5); expect(stats[0].used).toBe(1); @@ -809,20 +789,20 @@ describe('getStats', () => { const { user, plan } = setupUserWithPlan(); const yr = new Date().getFullYear(); - toggleEntry(user.id, plan.id, `${yr}-09-20`, 1, 'vacation'); - expect(getStats(plan.id, yr)[0].used).toBe(1); + svc.toggleEntry(user.id, plan.id, `${yr}-09-20`, 1, 'vacation'); + expect(svc.getStats(plan.id, yr)[0].used).toBe(1); - const result = toggleEntry(user.id, plan.id, `${yr}-09-20`, 1, 'comp'); + const result = svc.toggleEntry(user.id, plan.id, `${yr}-09-20`, 1, 'comp'); expect(result).toMatchObject({ action: 'updated', kind: 'comp' }); - expect(getStats(plan.id, yr)[0].used).toBe(0); + expect(svc.getStats(plan.id, yr)[0].used).toBe(0); }); it('VACAY-SVC-045f: getStats reports the window it counted over (#737)', () => { const { user, plan } = setupUserWithPlan(); - updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7, year_start_day: 1 }); + svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7, year_start_day: 1 }); - const stats = getStats(plan.id, 2026); + const stats = svc.getStats(plan.id, 2026); expect(stats[0]).toMatchObject({ window_start: '2026-07-01', window_end: '2027-07-01' }); }); @@ -834,66 +814,66 @@ describe('resolveYearWindow', () => { it('VACAY-SVC-045g: defaults to the plain calendar year when nothing is configured', () => { const { user } = setupUserWithPlan(); - expect(resolveYearWindow(user.id, 2026)).toEqual({ start: '2026-01-01', end: '2027-01-01' }); + expect(svc.resolveYearWindow(user.id, 2026)).toEqual({ start: '2026-01-01', end: '2027-01-01' }); }); it('VACAY-SVC-045h: an explicit calendar setting resolves identically', () => { const { user } = setupUserWithPlan(); - updateUserYearSettings(user.id, { year_type: 'calendar' }); + svc.updateYearSettings(user.id, { year_type: 'calendar' }); - expect(resolveYearWindow(user.id, 2026)).toEqual({ start: '2026-01-01', end: '2027-01-01' }); + expect(svc.resolveYearWindow(user.id, 2026)).toEqual({ start: '2026-01-01', end: '2027-01-01' }); }); it('VACAY-SVC-045i: a fiscal year starts on the configured month and day', () => { const { user } = setupUserWithPlan(); - updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 4, year_start_day: 6 }); + svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 4, year_start_day: 6 }); - expect(resolveYearWindow(user.id, 2026)).toEqual({ start: '2026-04-06', end: '2027-04-06' }); + expect(svc.resolveYearWindow(user.id, 2026)).toEqual({ start: '2026-04-06', end: '2027-04-06' }); }); it('VACAY-SVC-045j: an anniversary year follows the hire date month and day', () => { const { user } = setupUserWithPlan(); - updateUserYearSettings(user.id, { year_type: 'anniversary', hire_date: '2019-09-16' }); + svc.updateYearSettings(user.id, { year_type: 'anniversary', hire_date: '2019-09-16' }); - expect(resolveYearWindow(user.id, 2026)).toEqual({ start: '2026-09-16', end: '2027-09-16' }); + expect(svc.resolveYearWindow(user.id, 2026)).toEqual({ start: '2026-09-16', end: '2027-09-16' }); }); it('VACAY-SVC-045k: an anniversary year without a hire date falls back to January 1', () => { const { user } = setupUserWithPlan(); - updateUserYearSettings(user.id, { year_type: 'anniversary' }); + svc.updateYearSettings(user.id, { year_type: 'anniversary' }); - expect(resolveYearWindow(user.id, 2026)).toEqual({ start: '2026-01-01', end: '2027-01-01' }); + expect(svc.resolveYearWindow(user.id, 2026)).toEqual({ start: '2026-01-01', end: '2027-01-01' }); }); it('VACAY-SVC-045k1: an anniversary year ignores a month left behind by a previous fiscal setting', () => { const { user } = setupUserWithPlan(); // What the settings UI sends when you pick Fiscal/April and then click // "Hire date" before typing one — it carries the whole settings object. - updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 4, year_start_day: 1 }); - updateUserYearSettings(user.id, { year_type: 'anniversary', year_start_month: 4, year_start_day: 1, hire_date: null }); + svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 4, year_start_day: 1 }); + svc.updateYearSettings(user.id, { year_type: 'anniversary', year_start_month: 4, year_start_day: 1, hire_date: null }); - expect(resolveYearWindow(user.id, 2026)).toEqual({ start: '2026-01-01', end: '2027-01-01' }); + expect(svc.resolveYearWindow(user.id, 2026)).toEqual({ start: '2026-01-01', end: '2027-01-01' }); }); it('VACAY-SVC-045k2: a Feb 29 hire date resolves to Feb 28, a boundary every year has', () => { const { user } = setupUserWithPlan(); - updateUserYearSettings(user.id, { year_type: 'anniversary', hire_date: '2024-02-29' }); + svc.updateYearSettings(user.id, { year_type: 'anniversary', hire_date: '2024-02-29' }); - expect(resolveYearWindow(user.id, 2026)).toEqual({ start: '2026-02-28', end: '2027-02-28' }); + expect(svc.resolveYearWindow(user.id, 2026)).toEqual({ start: '2026-02-28', end: '2027-02-28' }); }); it('VACAY-SVC-045k3: a fiscal day the month cannot have is clamped down to one it can', () => { const { user } = setupUserWithPlan(); - updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 2, year_start_day: 31 }); + svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 2, year_start_day: 31 }); - expect(resolveYearWindow(user.id, 2026)).toEqual({ start: '2026-02-28', end: '2027-02-28' }); + expect(svc.resolveYearWindow(user.id, 2026)).toEqual({ start: '2026-02-28', end: '2027-02-28' }); }); it('VACAY-SVC-045l: consecutive periods meet exactly, so the carry-over chain stays intact', () => { const { user } = setupUserWithPlan(); - updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 }); + svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 }); - expect(resolveYearWindow(user.id, 2025).end).toBe(resolveYearWindow(user.id, 2026).start); + expect(svc.resolveYearWindow(user.id, 2025).end).toBe(svc.resolveYearWindow(user.id, 2026).start); }); }); @@ -901,8 +881,8 @@ describe('updateUserYearSettings', () => { it('VACAY-SVC-045m: upserts, so a second call replaces the first', () => { const { user } = setupUserWithPlan(); - updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7, year_start_day: 1 }); - const saved = updateUserYearSettings(user.id, { year_type: 'calendar' }); + svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7, year_start_day: 1 }); + const saved = svc.updateYearSettings(user.id, { year_type: 'calendar' }); expect(saved.year_type).toBe('calendar'); expect(testDb.prepare('SELECT COUNT(*) AS n FROM vacay_user_settings WHERE user_id = ?').get(user.id)).toEqual({ n: 1 }); @@ -911,7 +891,7 @@ describe('updateUserYearSettings', () => { it('VACAY-SVC-045n: clamps an out-of-range month and day instead of storing them', () => { const { user } = setupUserWithPlan(); - const saved = updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 99, year_start_day: 0 }); + const saved = svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 99, year_start_day: 0 }); expect(saved.year_start_month).toBe(12); expect(saved.year_start_day).toBe(1); @@ -920,7 +900,7 @@ describe('updateUserYearSettings', () => { it('VACAY-SVC-045o: drops a malformed hire date rather than persisting it', () => { const { user } = setupUserWithPlan(); - const saved = updateUserYearSettings(user.id, { year_type: 'anniversary', hire_date: 'not-a-date' }); + const saved = svc.updateYearSettings(user.id, { year_type: 'anniversary', hire_date: 'not-a-date' }); expect(saved.hire_date).toBeNull(); }); @@ -928,7 +908,7 @@ describe('updateUserYearSettings', () => { it('VACAY-SVC-045p: an unknown year type falls back to calendar', () => { const { user } = setupUserWithPlan(); - expect(updateUserYearSettings(user.id, { year_type: 'quarterly' }).year_type).toBe('calendar'); + expect(svc.updateYearSettings(user.id, { year_type: 'quarterly' }).year_type).toBe('calendar'); }); }); @@ -936,8 +916,8 @@ describe('getYearSettings', () => { it('VACAY-SVC-045q: fills in the calendar defaults for a user who never configured anything', () => { const { user } = setupUserWithPlan(); - expect(getUserYearSettings(user.id)).toBeUndefined(); - expect(getYearSettings(user.id)).toEqual({ + expect(svc.getUserYearSettings(user.id)).toBeUndefined(); + expect(svc.getYearSettings(user.id)).toEqual({ user_id: user.id, year_type: 'calendar', year_start_month: 1, year_start_day: 1, hire_date: null, }); }); @@ -947,45 +927,45 @@ describe('currentPeriodYear', () => { it('VACAY-SVC-045r: a calendar user is always in the period named after today’s year', () => { const { user } = setupUserWithPlan(); - expect(currentPeriodYear(user.id, new Date('2026-03-15T12:00:00'))).toBe(2026); + expect(svc.currentPeriodYear(user.id, new Date('2026-03-15T12:00:00'))).toBe(2026); }); it('VACAY-SVC-045s: before a fiscal year starts, today still belongs to the previous period', () => { const { user } = setupUserWithPlan(); - updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 }); + svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 }); - expect(currentPeriodYear(user.id, new Date('2026-03-15T12:00:00'))).toBe(2025); - expect(currentPeriodYear(user.id, new Date('2026-08-15T12:00:00'))).toBe(2026); + expect(svc.currentPeriodYear(user.id, new Date('2026-03-15T12:00:00'))).toBe(2025); + expect(svc.currentPeriodYear(user.id, new Date('2026-08-15T12:00:00'))).toBe(2026); }); }); describe('usage over a shifted window (#737)', () => { it('VACAY-SVC-045t: a day in the next calendar year still counts toward the fiscal period', () => { const { user, plan } = setupUserWithPlan(); - updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 }); + svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 }); - toggleEntry(user.id, plan.id, '2026-08-10', 1, 'vacation'); // inside, first half - toggleEntry(user.id, plan.id, '2027-02-10', 1, 'vacation'); // inside, second half + svc.toggleEntry(user.id, plan.id, '2026-08-10', 1, 'vacation'); // inside, first half + svc.toggleEntry(user.id, plan.id, '2027-02-10', 1, 'vacation'); // inside, second half - expect(getStats(plan.id, 2026)[0].used).toBe(2); + expect(svc.getStats(plan.id, 2026)[0].used).toBe(2); }); it('VACAY-SVC-045u: days outside the window belong to the neighbouring periods, not this one', () => { const { user, plan } = setupUserWithPlan(); - updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 }); + svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 }); - toggleEntry(user.id, plan.id, '2026-06-30', 1, 'vacation'); // last day of the previous period - toggleEntry(user.id, plan.id, '2027-07-01', 1, 'vacation'); // first day of the next period + svc.toggleEntry(user.id, plan.id, '2026-06-30', 1, 'vacation'); // last day of the previous period + svc.toggleEntry(user.id, plan.id, '2027-07-01', 1, 'vacation'); // first day of the next period - expect(getStats(plan.id, 2026)[0].used).toBe(0); - expect(getStats(plan.id, 2025)[0].used).toBe(1); - expect(getStats(plan.id, 2027)[0].used).toBe(1); + expect(svc.getStats(plan.id, 2026)[0].used).toBe(0); + expect(svc.getStats(plan.id, 2025)[0].used).toBe(1); + expect(svc.getStats(plan.id, 2027)[0].used).toBe(1); }); // Periods well past the year seeded with the plan, so addYear really inserts. it('VACAY-SVC-045v: carry-over is computed over the previous period, not the previous calendar year', () => { const { user, plan } = setupUserWithPlan(); - updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 }); + svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 }); testDb.prepare('UPDATE vacay_plans SET carry_over_enabled = 1 WHERE id = ?').run(plan.id); testDb.prepare(` INSERT OR REPLACE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) @@ -993,10 +973,10 @@ describe('usage over a shifted window (#737)', () => { `).run(user.id, plan.id); // Two days inside the 2030 period (Jul 2030 – Jun 2031), one of them in 2031. - toggleEntry(user.id, plan.id, '2030-09-01', 1, 'vacation'); - toggleEntry(user.id, plan.id, '2031-02-01', 1, 'vacation'); + svc.toggleEntry(user.id, plan.id, '2030-09-01', 1, 'vacation'); + svc.toggleEntry(user.id, plan.id, '2031-02-01', 1, 'vacation'); - addYear(plan.id, 2031, undefined); + svc.addYear(plan.id, 2031, undefined); const row = testDb .prepare('SELECT carried_over FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = 2031') @@ -1006,16 +986,16 @@ describe('usage over a shifted window (#737)', () => { it('VACAY-SVC-045w: comp days are excluded from the carry-over of a shifted period too', () => { const { user, plan } = setupUserWithPlan(); - updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 }); + svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 }); testDb.prepare('UPDATE vacay_plans SET carry_over_enabled = 1 WHERE id = ?').run(plan.id); testDb.prepare(` INSERT OR REPLACE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, 2030, 10, 0) `).run(user.id, plan.id); - toggleEntry(user.id, plan.id, '2031-02-01', 1, 'comp'); + svc.toggleEntry(user.id, plan.id, '2031-02-01', 1, 'comp'); - addYear(plan.id, 2031, undefined); + svc.addYear(plan.id, 2031, undefined); const row = testDb .prepare('SELECT carried_over FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = 2031') @@ -1025,14 +1005,14 @@ describe('usage over a shifted window (#737)', () => { it('VACAY-SVC-045x: deleteYear clears the entries of the period, spanning both calendar years', () => { const { user, plan } = setupUserWithPlan(); - updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 }); - addYear(plan.id, 2026, undefined); + svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 }); + svc.addYear(plan.id, 2026, undefined); - toggleEntry(user.id, plan.id, '2026-08-10', 1, 'vacation'); // inside - toggleEntry(user.id, plan.id, '2027-02-10', 1, 'vacation'); // inside, next calendar year - toggleEntry(user.id, plan.id, '2026-06-30', 1, 'vacation'); // previous period, must survive + svc.toggleEntry(user.id, plan.id, '2026-08-10', 1, 'vacation'); // inside + svc.toggleEntry(user.id, plan.id, '2027-02-10', 1, 'vacation'); // inside, next calendar year + svc.toggleEntry(user.id, plan.id, '2026-06-30', 1, 'vacation'); // previous period, must survive - deleteYear(plan.id, 2026, undefined); + svc.deleteYear(plan.id, 2026, undefined); const left = testDb .prepare('SELECT date FROM vacay_entries WHERE plan_id = ? ORDER BY date') @@ -1044,38 +1024,38 @@ describe('usage over a shifted window (#737)', () => { describe('getEntries over a window (#737)', () => { it('VACAY-SVC-045y: returns both calendar halves of a shifted period for the viewer', () => { const { user, plan } = setupUserWithPlan(); - updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 }); + svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 }); - toggleEntry(user.id, plan.id, '2026-08-10', 1, 'vacation'); - toggleEntry(user.id, plan.id, '2027-02-10', 1, 'vacation'); - toggleEntry(user.id, plan.id, '2026-06-30', 1, 'vacation'); // previous period + svc.toggleEntry(user.id, plan.id, '2026-08-10', 1, 'vacation'); + svc.toggleEntry(user.id, plan.id, '2027-02-10', 1, 'vacation'); + svc.toggleEntry(user.id, plan.id, '2026-06-30', 1, 'vacation'); // previous period - const result = getEntries(plan.id, '2026', user.id); + const result = svc.getEntries(plan.id, '2026', user.id); expect((result.entries as { date: string }[]).map(e => e.date).sort()).toEqual(['2026-08-10', '2027-02-10']); }); it('VACAY-SVC-045z: without a viewer it stays on the plain calendar year (MCP reads)', () => { const { user, plan } = setupUserWithPlan(); - updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 }); + svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 7 }); - toggleEntry(user.id, plan.id, '2026-08-10', 1, 'vacation'); - toggleEntry(user.id, plan.id, '2027-02-10', 1, 'vacation'); + svc.toggleEntry(user.id, plan.id, '2026-08-10', 1, 'vacation'); + svc.toggleEntry(user.id, plan.id, '2027-02-10', 1, 'vacation'); - const result = getEntries(plan.id, '2026'); + const result = svc.getEntries(plan.id, '2026'); expect((result.entries as { date: string }[]).map(e => e.date)).toEqual(['2026-08-10']); }); it('VACAY-SVC-045aa: a start day past the 1st still loads the whole first month, since the grid renders it', () => { const { user, plan } = setupUserWithPlan(); - updateUserYearSettings(user.id, { year_type: 'fiscal', year_start_month: 4, year_start_day: 6 }); + svc.updateYearSettings(user.id, { year_type: 'fiscal', year_start_month: 4, year_start_day: 6 }); - toggleEntry(user.id, plan.id, '2026-04-02', 1, 'vacation'); // rendered, but counted in the previous period + svc.toggleEntry(user.id, plan.id, '2026-04-02', 1, 'vacation'); // rendered, but counted in the previous period - expect((getEntries(plan.id, '2026', user.id).entries as unknown[])).toHaveLength(1); - expect(getStats(plan.id, 2026)[0].used).toBe(0); - expect(getStats(plan.id, 2025)[0].used).toBe(1); + expect((svc.getEntries(plan.id, '2026', user.id).entries as unknown[])).toHaveLength(1); + expect(svc.getStats(plan.id, 2026)[0].used).toBe(0); + expect(svc.getStats(plan.id, 2025)[0].used).toBe(1); }); }); @@ -1086,7 +1066,7 @@ describe('applyHolidayCalendars', () => { const { plan } = setupUserWithPlan(); // holidays_enabled defaults to 0 - await applyHolidayCalendars(plan.id); + await svc.applyHolidayCalendars(plan.id); expect(vi.mocked(fetch)).not.toHaveBeenCalled(); }); @@ -1097,7 +1077,7 @@ describe('applyHolidayCalendars', () => { // Enable holidays and add a calendar testDb.prepare('UPDATE vacay_plans SET holidays_enabled = 1 WHERE id = ?').run(plan.id); - addHolidayCalendar(plan.id, 'DE', null, undefined, 0, undefined); + svc.addHolidayCalendar(plan.id, 'DE', null, undefined, 0, undefined); // Add a vacay entry on the holiday date const holidayDate = `${yr}-01-01`; @@ -1111,7 +1091,7 @@ describe('applyHolidayCalendars', () => { json: async () => [{ date: holidayDate, global: true }], })); - await applyHolidayCalendars(plan.id); + await svc.applyHolidayCalendars(plan.id); const remaining = testDb .prepare('SELECT * FROM vacay_entries WHERE plan_id = ? AND date = ?') @@ -1127,7 +1107,7 @@ describe('shareCalendar', () => { const { user: owner } = setupUserWithPlan(); const { user: target } = createUser(testDb); - const result = shareCalendar(owner.id, owner.email, target.id); + const result = svc.shareCalendar(owner.id, owner.email, target.id); expect(result.error).toBeUndefined(); const row = testDb @@ -1139,7 +1119,7 @@ describe('shareCalendar', () => { it('VACAY-SVC-049: returns 400 when sharing with yourself', () => { const { user: owner } = setupUserWithPlan(); - const result = shareCalendar(owner.id, owner.email, owner.id); + const result = svc.shareCalendar(owner.id, owner.email, owner.id); expect(result).toEqual({ error: 'Cannot share with yourself', status: 400 }); }); @@ -1147,7 +1127,7 @@ describe('shareCalendar', () => { it('VACAY-SVC-050: returns 404 when the target user does not exist', () => { const { user: owner } = setupUserWithPlan(); - const result = shareCalendar(owner.id, owner.email, 99999); + const result = svc.shareCalendar(owner.id, owner.email, 99999); expect(result).toEqual({ error: 'User not found', status: 404 }); }); @@ -1155,9 +1135,9 @@ describe('shareCalendar', () => { it('VACAY-SVC-051: returns 400 when the share already exists', () => { const { user: owner } = setupUserWithPlan(); const { user: target } = createUser(testDb); - shareCalendar(owner.id, owner.email, target.id); + svc.shareCalendar(owner.id, owner.email, target.id); - const result = shareCalendar(owner.id, owner.email, target.id); + const result = svc.shareCalendar(owner.id, owner.email, target.id); expect(result).toEqual({ error: 'Already shared', status: 400 }); }); @@ -1167,7 +1147,7 @@ describe('shareCalendar', () => { const { user: member } = createUser(testDb); insertMember(plan.id, member.id, 'accepted'); - const result = shareCalendar(owner.id, owner.email, member.id); + const result = svc.shareCalendar(owner.id, owner.email, member.id); expect(result).toEqual({ error: 'User is already in your calendar', status: 400 }); }); @@ -1177,9 +1157,9 @@ describe('listShares', () => { it('VACAY-SVC-053: outgoing rows carry the target user info', () => { const { user: owner } = setupUserWithPlan(); const { user: target } = createUser(testDb); - shareCalendar(owner.id, owner.email, target.id); + svc.shareCalendar(owner.id, owner.email, target.id); - const result = listShares(owner.id); + const result = svc.listShares(owner.id); expect(result.outgoing).toHaveLength(1); expect(result.outgoing[0]).toMatchObject({ @@ -1192,11 +1172,11 @@ describe('listShares', () => { it('VACAY-SVC-054: incoming rows carry the owner info, their color and a boolean hidden flag', () => { const { user: owner, plan } = setupUserWithPlan(); - setUserColor(owner.id, plan.id, '#ef4444', undefined); + svc.setUserColor(owner.id, plan.id, '#ef4444', undefined); const { user: viewer } = setupUserWithPlan(); - shareCalendar(owner.id, owner.email, viewer.id); + svc.shareCalendar(owner.id, owner.email, viewer.id); - const result = listShares(viewer.id); + const result = svc.listShares(viewer.id); expect(result.outgoing).toEqual([]); expect(result.incoming).toHaveLength(1); @@ -1213,10 +1193,10 @@ describe('listShares', () => { const { user: viewer } = setupUserWithPlan(); // viewer's own color is #6366f1 const { user: owner1 } = setupUserWithPlan(); // default #6366f1 const { user: owner2 } = setupUserWithPlan(); // default #6366f1 - shareCalendar(owner1.id, owner1.email, viewer.id); - shareCalendar(owner2.id, owner2.email, viewer.id); + svc.shareCalendar(owner1.id, owner1.email, viewer.id); + svc.shareCalendar(owner2.id, owner2.email, viewer.id); - const { incoming } = listShares(viewer.id); + const { incoming } = svc.listShares(viewer.id); expect(incoming).toHaveLength(2); // Both collide with the viewer's own indigo, so each gets a distinct free preset @@ -1230,10 +1210,10 @@ describe('removeShare', () => { it('VACAY-SVC-056: the owner can revoke their share', () => { const { user: owner } = setupUserWithPlan(); const { user: viewer } = createUser(testDb); - shareCalendar(owner.id, owner.email, viewer.id); - const shareId = listShares(owner.id).outgoing[0].id as number; + svc.shareCalendar(owner.id, owner.email, viewer.id); + const shareId = svc.listShares(owner.id).outgoing[0].id as number; - expect(removeShare(shareId, owner.id)).toBe(true); + expect(svc.removeShare(shareId, owner.id)).toBe(true); const row = testDb.prepare('SELECT id FROM vacay_shares WHERE id = ?').get(shareId); expect(row).toBeUndefined(); }); @@ -1241,24 +1221,24 @@ describe('removeShare', () => { it('VACAY-SVC-057: the recipient can remove a share they received', () => { const { user: owner } = setupUserWithPlan(); const { user: viewer } = createUser(testDb); - shareCalendar(owner.id, owner.email, viewer.id); - const shareId = listShares(viewer.id).incoming[0].id; + svc.shareCalendar(owner.id, owner.email, viewer.id); + const shareId = svc.listShares(viewer.id).incoming[0].id; - expect(removeShare(shareId, viewer.id)).toBe(true); + expect(svc.removeShare(shareId, viewer.id)).toBe(true); }); it('VACAY-SVC-058: a third user cannot remove the share, unknown ids return false', () => { const { user: owner } = setupUserWithPlan(); const { user: viewer } = createUser(testDb); const { user: stranger } = createUser(testDb); - shareCalendar(owner.id, owner.email, viewer.id); - const shareId = listShares(owner.id).outgoing[0].id as number; + svc.shareCalendar(owner.id, owner.email, viewer.id); + const shareId = svc.listShares(owner.id).outgoing[0].id as number; - expect(removeShare(shareId, stranger.id)).toBe(false); + expect(svc.removeShare(shareId, stranger.id)).toBe(false); const row = testDb.prepare('SELECT id FROM vacay_shares WHERE id = ?').get(shareId); expect(row).toBeDefined(); - expect(removeShare(99999, owner.id)).toBe(false); + expect(svc.removeShare(99999, owner.id)).toBe(false); }); }); @@ -1266,15 +1246,15 @@ describe('setShareHidden', () => { it('VACAY-SVC-059: the recipient can hide and unhide the shared calendar', () => { const { user: owner } = setupUserWithPlan(); const { user: viewer } = createUser(testDb); - shareCalendar(owner.id, owner.email, viewer.id); - const shareId = listShares(viewer.id).incoming[0].id; + svc.shareCalendar(owner.id, owner.email, viewer.id); + const shareId = svc.listShares(viewer.id).incoming[0].id; - expect(setShareHidden(shareId, viewer.id, true)).toBe(true); + expect(svc.setShareHidden(shareId, viewer.id, true)).toBe(true); let row = testDb.prepare('SELECT hidden FROM vacay_shares WHERE id = ?').get(shareId) as { hidden: number }; expect(row.hidden).toBe(1); - expect(listShares(viewer.id).incoming[0].hidden).toBe(true); + expect(svc.listShares(viewer.id).incoming[0].hidden).toBe(true); - expect(setShareHidden(shareId, viewer.id, false)).toBe(true); + expect(svc.setShareHidden(shareId, viewer.id, false)).toBe(true); row = testDb.prepare('SELECT hidden FROM vacay_shares WHERE id = ?').get(shareId) as { hidden: number }; expect(row.hidden).toBe(0); }); @@ -1282,10 +1262,10 @@ describe('setShareHidden', () => { it('VACAY-SVC-060: the owner cannot toggle the recipient hidden flag', () => { const { user: owner } = setupUserWithPlan(); const { user: viewer } = createUser(testDb); - shareCalendar(owner.id, owner.email, viewer.id); - const shareId = listShares(owner.id).outgoing[0].id as number; + svc.shareCalendar(owner.id, owner.email, viewer.id); + const shareId = svc.listShares(owner.id).outgoing[0].id as number; - expect(setShareHidden(shareId, owner.id, true)).toBe(false); + expect(svc.setShareHidden(shareId, owner.id, true)).toBe(false); const row = testDb.prepare('SELECT hidden FROM vacay_shares WHERE id = ?').get(shareId) as { hidden: number }; expect(row.hidden).toBe(0); }); @@ -1297,10 +1277,10 @@ describe('getShareAvailableUsers', () => { const { user: member } = createUser(testDb); insertMember(plan.id, member.id, 'accepted'); const { user: shared } = createUser(testDb); - shareCalendar(owner.id, owner.email, shared.id); + svc.shareCalendar(owner.id, owner.email, shared.id); const { user: unrelated } = createUser(testDb); - const ids = (getShareAvailableUsers(owner.id) as { id: number }[]).map(u => u.id); + const ids = (svc.getShareAvailableUsers(owner.id) as { id: number }[]).map(u => u.id); expect(ids).toContain(unrelated.id); expect(ids).not.toContain(owner.id); @@ -1315,12 +1295,12 @@ describe('getSharedCalendars', () => { const { user: member } = createUser(testDb); insertMember(plan.id, member.id, 'accepted'); const { user: viewer } = setupUserWithPlan(); - toggleEntry(owner.id, plan.id, '2025-06-10', 1); - toggleEntry(owner.id, plan.id, '2025-06-11', 0.5); - toggleEntry(member.id, plan.id, '2025-06-12', 1); - shareCalendar(owner.id, owner.email, viewer.id); + svc.toggleEntry(owner.id, plan.id, '2025-06-10', 1); + svc.toggleEntry(owner.id, plan.id, '2025-06-11', 0.5); + svc.toggleEntry(member.id, plan.id, '2025-06-12', 1); + svc.shareCalendar(owner.id, owner.email, viewer.id); - const calendars = getSharedCalendars(viewer.id, '2025'); + const calendars = svc.getSharedCalendars(viewer.id, '2025'); expect(calendars).toHaveLength(1); expect(calendars[0].owner_id).toBe(owner.id); @@ -1336,10 +1316,10 @@ describe('getSharedCalendars', () => { const { user: owner, plan } = setupUserWithPlan(); const { user: viewer } = createUser(testDb); testDb.prepare('UPDATE vacay_plans SET company_holidays_enabled = 0 WHERE id = ?').run(plan.id); - toggleCompanyHoliday(plan.id, '2025-12-24', 'Christmas Eve', undefined); - shareCalendar(owner.id, owner.email, viewer.id); + svc.toggleCompanyHoliday(plan.id, '2025-12-24', 'Christmas Eve', undefined); + svc.shareCalendar(owner.id, owner.email, viewer.id); - const calendars = getSharedCalendars(viewer.id, '2025'); + const calendars = svc.getSharedCalendars(viewer.id, '2025'); expect(calendars[0].companyHolidays).toEqual([]); }); @@ -1348,10 +1328,10 @@ describe('getSharedCalendars', () => { const { user: owner, plan } = setupUserWithPlan(); const { user: viewer } = createUser(testDb); testDb.prepare('UPDATE vacay_plans SET company_holidays_enabled = 1 WHERE id = ?').run(plan.id); - toggleCompanyHoliday(plan.id, '2025-12-24', 'Christmas Eve', undefined); - shareCalendar(owner.id, owner.email, viewer.id); + svc.toggleCompanyHoliday(plan.id, '2025-12-24', 'Christmas Eve', undefined); + svc.shareCalendar(owner.id, owner.email, viewer.id); - const calendars = getSharedCalendars(viewer.id, '2025'); + const calendars = svc.getSharedCalendars(viewer.id, '2025'); expect(calendars[0].companyHolidays).toEqual([{ date: '2025-12-24' }]); }); @@ -1361,7 +1341,7 @@ describe('getSharedCalendars', () => { const { user: viewer } = createUser(testDb); testDb.prepare('INSERT INTO vacay_shares (owner_id, user_id) VALUES (?, ?)').run(owner.id, viewer.id); - const calendars = getSharedCalendars(viewer.id, '2025'); + const calendars = svc.getSharedCalendars(viewer.id, '2025'); expect(calendars).toHaveLength(1); expect(calendars[0].entries).toEqual([]); @@ -1373,15 +1353,29 @@ describe('getSharedCalendars', () => { it('VACAY-SVC-066: follows an owner fused into another plan', () => { const { user: host, plan: hostPlan } = setupUserWithPlan(); const { user: owner } = createUser(testDb); - getOwnPlan(owner.id); + svc.getOwnPlan(owner.id); insertMember(hostPlan.id, owner.id, 'accepted'); const { user: viewer } = createUser(testDb); - toggleEntry(owner.id, hostPlan.id, '2025-03-03', 1); - shareCalendar(owner.id, owner.email, viewer.id); + svc.toggleEntry(owner.id, hostPlan.id, '2025-03-03', 1); + svc.shareCalendar(owner.id, owner.email, viewer.id); - const calendars = getSharedCalendars(viewer.id, '2025'); + const calendars = svc.getSharedCalendars(viewer.id, '2025'); expect(calendars).toHaveLength(1); expect(calendars[0].entries).toEqual([{ date: '2025-03-03', fraction: 1, kind: 'vacation' }]); }); }); + +// ── vacay.bridge delegation ─────────────────────────────────────────────────── + +describe('vacay.bridge', () => { + it('VACAY-SVC-067: shiftOwnerEntriesForTripWindow shifts the owner entries through the bridge', () => { + const { user, plan } = setupUserWithPlan(); + svc.toggleEntry(user.id, plan.id, '2025-05-10', 1); + + shiftOwnerEntriesForTripWindow(user.id, '2025-05-08', '2025-05-12', '2025-05-15'); + + const dates = testDb.prepare('SELECT date FROM vacay_entries WHERE plan_id = ? ORDER BY date').all(plan.id); + expect(dates).toEqual([{ date: '2025-05-17' }]); + }); +}); diff --git a/server/tests/unit/plugins/plugin-host-deps.factory.test.ts b/server/tests/unit/plugins/plugin-host-deps.factory.test.ts index 64d261517..163b236a7 100644 --- a/server/tests/unit/plugins/plugin-host-deps.factory.test.ts +++ b/server/tests/unit/plugins/plugin-host-deps.factory.test.ts @@ -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, router: PluginCallRouter = stubRouter) => factory.create(id, granted, router);