chore: move todo backend to proper DI

This commit is contained in:
jubnl
2026-07-26 14:09:54 +02:00
parent b5ebffa115
commit 79946cc711
21 changed files with 772 additions and 456 deletions
+3 -13
View File
@@ -8,7 +8,6 @@ import { listItems as listPackingItems, listBags } from '../services/packingServ
import { listReservations } from '../services/reservationService';
import { listNotes as listDayNotes } from '../services/dayNoteService';
import { listNotes as listCollabNotes, listPolls, listMessages } from '../services/collabService';
import { listItems as listTodoItems } from '../services/todoService';
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';
@@ -201,18 +200,9 @@ export function registerResources(server: McpServer, userId: number, scopes: str
}
);
// Trip to-do list
if (isAddonEnabled(ADDON_IDS.PACKING) && canRead(scopes, 'todos')) server.registerResource(
'trip-todos',
new ResourceTemplate('trek://trips/{tripId}/todos', { list: undefined }),
{ description: 'To-do items for a trip, ordered by position', mimeType: 'application/json' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const items = listTodoItems(id);
return jsonContent(uri.href, items);
}
);
// The trek://trips/{tripId}/todos resource moved to the DI-discovered
// src/nest/todo/todo.mcp.ts (@ResourceTemplate, attached via the nest-mcp
// registry in registerTools).
// The trek://categories resource moved to the DI-discovered
// src/nest/categories/categories.mcp.ts (@Resource, attached via the
+2 -2
View File
@@ -1,5 +1,4 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { registerTodoTools } from './tools/todos';
import { registerAssignmentTools } from './tools/assignments';
import { registerJourneyTools } from './tools/journey';
import { registerReservationTools } from './tools/reservations';
@@ -52,7 +51,8 @@ export function registerTools(server: McpServer, userId: number, scopes: string[
registerVacayTools(server, userId, scopes);
registerTodoTools(server, userId, scopes);
// The todo tools moved to the DI-discovered src/nest/todo/todo.mcp.ts
// (@McpController, attached via the nest-mcp registry below).
registerMcpPrompts(server, userId, isStaticToken);
-199
View File
@@ -1,199 +0,0 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { canAccessTrip } from '../../db/database';
import { isDemoUser } from '../../services/authService';
import {
listItems as listTodoItems, createItem as createTodoItem, updateItem as updateTodoItem,
deleteItem as deleteTodoItem, reorderItems as reorderTodoItems,
getCategoryAssignees as getTodoCategoryAssignees, updateCategoryAssignees as updateTodoCategoryAssignees,
} from '../../services/todoService';
import {
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, noAccess, ok, hasTripPermission, permissionDenied,
} from './_shared';
import { canRead, canWrite } from '../scopes';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
export function registerTodoTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'todos');
const W = canWrite(scopes, 'todos');
if (!isAddonEnabled(ADDON_IDS.PACKING)) return;
// --- TODOS ---
if (R) server.registerTool(
'list_todos',
{
description: 'List all to-do items for a trip, ordered by position.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const items = listTodoItems(tripId);
return ok({ items });
}
);
if (W) server.registerTool(
'create_todo',
{
description: 'Create a new to-do item for a trip.',
inputSchema: {
tripId: z.number().int().positive(),
name: z.string().min(1).max(500).describe('To-do item name'),
category: z.string().max(100).optional().describe('Category (e.g. "Logistics", "Booking")'),
due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Due date (YYYY-MM-DD)'),
description: z.string().max(2000).optional().describe('Additional description'),
assigned_user_id: z.number().int().positive().optional().describe('User ID to assign this task to'),
priority: z.number().int().min(0).max(3).optional().describe('Priority: 0=none, 1=low, 2=medium, 3=high'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, name, category, due_date, description, assigned_user_id, priority }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
const item = createTodoItem(tripId, { name, category, due_date, description, assigned_user_id, priority });
safeBroadcast(tripId, 'todo:created', { item });
return ok({ item });
}
);
if (W) server.registerTool(
'update_todo',
{
description: 'Update an existing to-do item. Only provided fields are changed; omitted fields stay as-is. Pass null to clear a nullable field.',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
name: z.string().min(1).max(500).optional(),
category: z.string().max(100).optional(),
due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional().describe('Set to null to clear the due date'),
description: z.string().max(2000).nullable().optional().describe('Set to null to clear'),
assigned_user_id: z.number().int().positive().nullable().optional().describe('Set to null to unassign'),
priority: z.number().int().min(0).max(3).nullable().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, itemId, name, category, due_date, description, assigned_user_id, priority }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
// Build bodyKeys to signal which nullable fields were explicitly provided
const bodyKeys: string[] = [];
if (due_date !== undefined) bodyKeys.push('due_date');
if (description !== undefined) bodyKeys.push('description');
if (assigned_user_id !== undefined) bodyKeys.push('assigned_user_id');
if (priority !== undefined) bodyKeys.push('priority');
const item = updateTodoItem(tripId, itemId, { name, category, due_date, description, assigned_user_id, priority }, bodyKeys);
if (!item) return { content: [{ type: 'text' as const, text: 'To-do item not found.' }], isError: true };
safeBroadcast(tripId, 'todo:updated', { item });
return ok({ item });
}
);
if (W) server.registerTool(
'toggle_todo',
{
description: 'Mark a to-do item as checked (done) or unchecked.',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
checked: z.boolean().describe('True to mark done, false to uncheck'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, itemId, checked }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
const item = updateTodoItem(tripId, itemId, { checked: checked ? 1 : 0 }, []);
if (!item) return { content: [{ type: 'text' as const, text: 'To-do item not found.' }], isError: true };
safeBroadcast(tripId, 'todo:updated', { item });
return ok({ item });
}
);
if (W) server.registerTool(
'delete_todo',
{
description: 'Delete a to-do item.',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, itemId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
const deleted = deleteTodoItem(tripId, itemId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'To-do item not found.' }], isError: true };
safeBroadcast(tripId, 'todo:deleted', { itemId });
return ok({ success: true });
}
);
if (W) server.registerTool(
'reorder_todos',
{
description: 'Reorder to-do items within a trip by providing a new ordered list of item IDs.',
inputSchema: {
tripId: z.number().int().positive(),
orderedIds: z.array(z.number().int().positive()).min(1).describe('All item IDs in the desired order'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, orderedIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
reorderTodoItems(tripId, orderedIds);
return ok({ success: true });
}
);
if (R) server.registerTool(
'get_todo_category_assignees',
{
description: 'Get the default assignees configured per to-do category for a trip.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const assignees = getTodoCategoryAssignees(tripId);
return ok({ assignees });
}
);
if (W) server.registerTool(
'set_todo_category_assignees',
{
description: 'Set the default assignees for a to-do category on a trip. Pass an empty array to clear.',
inputSchema: {
tripId: z.number().int().positive(),
categoryName: z.string().min(1).max(100).describe('Category name'),
userIds: z.array(z.number().int().positive()).describe('User IDs to assign as defaults for this category'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, categoryName, userIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, userId)) return permissionDenied();
const assignees = updateTodoCategoryAssignees(tripId, categoryName, userIds);
safeBroadcast(tripId, 'todo:assignees', { category: categoryName, assignees });
return ok({ assignees });
}
);
}
+1 -1
View File
@@ -17,7 +17,7 @@ import { ADDON_IDS } from '../../addons';
import { countMessages, listPolls } from '../../services/collabService';
import {
listItems as listTodoItems,
} from '../../services/todoService';
} from '../../nest/todo/todo.bridge';
import { rebaseTripCurrency } from '../../services/budgetService';
import {
safeBroadcast, MAX_MCP_TRIP_DAYS,
+7 -4
View File
@@ -26,8 +26,8 @@ mount to Nest and leaves the sibling trip routes (days, places, ...) on Express.
- **Phase 1 (leaf):** weather, airports, config (public), system-notices, maps,
categories, tags, notifications, atlas.
- **Phase 2 (trip sub-domains):** vacay (addon), packing, todo.
- **DI-native services (legacy `src/services/*` deleted):** tags, categories —
see the migration recipe below.
- **DI-native services (legacy `src/services/*` deleted):** tags, categories,
todo — see the migration recipe below.
## Cross-cutting Foundation pieces
@@ -95,8 +95,11 @@ at the typed contract (Frontend Track).
Pilot: **tags** (`services/tagService.ts` → `nest/tags/tags.service.ts` +
`nest/tags/tags.bridge.ts`); categories followed the same shape (and piloted the
first `@Resource` in `categories.mcp.ts`). Repeat these steps per service (next
up: todoService). This is a **pure relocation** — byte-identical
first `@Resource` in `categories.mcp.ts`); todo followed too (and piloted the
first `@ResourceTemplate` plus the `when` addon gate in `todo.mcp.ts`, and the
first in-container consumer wiring: `TripsService` injects `TodoService` via
`exports: [TodoService]` instead of using the bridge). Repeat these steps per
service (next up: packingService). This is a **pure relocation** — byte-identical
SQL, statuses, bodies, and error strings. The long-run plan for the last
non-Nest consumer (the plugin RPC host, today served via the bridges) is in
`src/nest/plugins/DI-MIGRATION.md`.
@@ -8,7 +8,7 @@ import { isUpdateConflict } from '../../../services/conflictResult';
import { getWeather } from '../../../services/weatherService';
import { listCategories } from '../../categories/categories.bridge';
import { listTags, createTag, updateTag, deleteTag, getTagByIdAndUser } from '../../tags/tags.bridge';
import { listItems as listTodosSvc, createItem as createTodoSvc, updateItem as updateTodoSvc, deleteItem as deleteTodoSvc } from '../../../services/todoService';
import { listItems as listTodosSvc, createItem as createTodoSvc, updateItem as updateTodoSvc, deleteItem as deleteTodoSvc } from '../../todo/todo.bridge';
import { listFiles, createFile, createFileLink, getFileById, updateFile, softDeleteFile, findForeignLinkTarget, resolveFilePath, BLOCKED_EXTENSIONS, filesDir } from '../../../services/fileService';
import { createNote as createCollabNoteSvc, createPoll as createCollabPollSvc, votePoll as voteCollabPollSvc, createMessage as createCollabMessageSvc, listNotes as listCollabNotesSvc, listPolls as listCollabPollsSvc, listMessages as listCollabMessagesSvc } from '../../../services/collabService';
import { getRates as getExchangeRates } from '../../../services/exchangeRateService';
+38
View File
@@ -0,0 +1,38 @@
import { db } from '../../db/database';
import { DatabaseService } from '../database/database.service';
import { TodoService } from './todo.service';
/**
* Non-Nest entry point for the todo domain — for code running OUTSIDE the
* Nest container (the plugin RPC host and the legacy get_trip_summary
* registrar in src/mcp/tools/trips.ts; the todo MCP tools and resource moved
* to the DI-discovered todo.mcp.ts). Exports only the legacy
* services/todoService names still consumed outside the container, 1:1, so
* repointing a consumer is an import-path-only diff. Inside the container,
* inject TodoService instead.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton (same pattern as categories.bridge.ts).
*/
const todos = new TodoService(new DatabaseService(db));
export function listItems(tripId: string | number) {
return todos.listItems(tripId);
}
export function createItem(tripId: string | number, data: Parameters<TodoService['createItem']>[1]) {
return todos.createItem(tripId, data);
}
export function updateItem(
tripId: string | number,
id: string | number,
data: Parameters<TodoService['updateItem']>[2],
bodyKeys: string[]
) {
return todos.updateItem(tripId, id, data, bodyKeys);
}
export function deleteItem(tripId: string | number, id: string | number): boolean {
return todos.deleteItem(tripId, id);
}
+246
View File
@@ -0,0 +1,246 @@
import {
McpController, Tool, 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 } from '../../services/authService';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared';
import { TodoService } from './todo.service';
/** Legacy registrar gate: the whole todo surface rides the packing addon. */
const packingAddonOn = () => isAddonEnabled(ADDON_IDS.PACKING);
function parseId(value: string | string[]): number | null {
const n = Number(Array.isArray(value) ? value[0] : value);
return Number.isInteger(n) && n > 0 ? n : null;
}
/**
* Todo MCP surface — ported 1:1 from the legacy registrars: the eight tools
* from src/mcp/tools/todos.ts and the trek://trips/{tripId}/todos resource
* from src/mcp/resources.ts (identical names, descriptions, schemas,
* annotations, error/payload shapes and broadcasts). The registration-time
* gates map to `when` (the whole-registrar packing-addon early return) plus
* the declarative todos read/write access markers (the legacy `if (R)` /
* `if (W)` checks, resolved by trekMcpAccessPolicy).
*/
@McpController()
export class TodoMcp {
constructor(private readonly todos: TodoService) {}
@Tool({
name: 'list_todos',
description: 'List all to-do items for a trip, ordered by position.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
when: packingAddonOn,
access: { group: 'todos', mode: 'read' },
})
async listTodos({ tripId }: { tripId: number }, ctx: McpContext) {
if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess();
const items = this.todos.listItems(tripId);
return ok({ items });
}
@Tool({
name: 'create_todo',
description: 'Create a new to-do item for a trip.',
inputSchema: {
tripId: z.number().int().positive(),
name: z.string().min(1).max(500).describe('To-do item name'),
category: z.string().max(100).optional().describe('Category (e.g. "Logistics", "Booking")'),
due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Due date (YYYY-MM-DD)'),
description: z.string().max(2000).optional().describe('Additional description'),
assigned_user_id: z.number().int().positive().optional().describe('User ID to assign this task to'),
priority: z.number().int().min(0).max(3).optional().describe('Priority: 0=none, 1=low, 2=medium, 3=high'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: packingAddonOn,
access: { group: 'todos', mode: 'write' },
})
async createTodo(
{ tripId, name, category, due_date, description, assigned_user_id, priority }: {
tripId: number; name: string; category?: string; due_date?: string; description?: string; assigned_user_id?: number; priority?: number;
},
ctx: McpContext,
) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
const item = this.todos.createItem(tripId, { name, category, due_date, description, assigned_user_id, priority });
safeBroadcast(tripId, 'todo:created', { item });
return ok({ item });
}
@Tool({
name: 'update_todo',
description: 'Update an existing to-do item. Only provided fields are changed; omitted fields stay as-is. Pass null to clear a nullable field.',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
name: z.string().min(1).max(500).optional(),
category: z.string().max(100).optional(),
due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional().describe('Set to null to clear the due date'),
description: z.string().max(2000).nullable().optional().describe('Set to null to clear'),
assigned_user_id: z.number().int().positive().nullable().optional().describe('Set to null to unassign'),
priority: z.number().int().min(0).max(3).nullable().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
when: packingAddonOn,
access: { group: 'todos', mode: 'write' },
})
async updateTodo(
{ tripId, itemId, name, category, due_date, description, assigned_user_id, priority }: {
tripId: number; itemId: number; name?: string; category?: string; due_date?: string | null; description?: string | null; assigned_user_id?: number | null; priority?: number | null;
},
ctx: McpContext,
) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
// Build bodyKeys to signal which nullable fields were explicitly provided
const bodyKeys: string[] = [];
if (due_date !== undefined) bodyKeys.push('due_date');
if (description !== undefined) bodyKeys.push('description');
if (assigned_user_id !== undefined) bodyKeys.push('assigned_user_id');
if (priority !== undefined) bodyKeys.push('priority');
const item = this.todos.updateItem(tripId, itemId, { name, category, due_date, description, assigned_user_id, priority }, bodyKeys);
if (!item) return errorResult('To-do item not found.');
safeBroadcast(tripId, 'todo:updated', { item });
return ok({ item });
}
@Tool({
name: 'toggle_todo',
description: 'Mark a to-do item as checked (done) or unchecked.',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
checked: z.boolean().describe('True to mark done, false to uncheck'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
when: packingAddonOn,
access: { group: 'todos', mode: 'write' },
})
async toggleTodo({ tripId, itemId, checked }: { tripId: number; itemId: number; checked: boolean }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
const item = this.todos.updateItem(tripId, itemId, { checked: checked ? 1 : 0 }, []);
if (!item) return errorResult('To-do item not found.');
safeBroadcast(tripId, 'todo:updated', { item });
return ok({ item });
}
@Tool({
name: 'delete_todo',
description: 'Delete a to-do item.',
inputSchema: {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
when: packingAddonOn,
access: { group: 'todos', mode: 'write' },
})
async deleteTodo({ tripId, itemId }: { tripId: number; itemId: number }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
const deleted = this.todos.deleteItem(tripId, itemId);
if (!deleted) return errorResult('To-do item not found.');
safeBroadcast(tripId, 'todo:deleted', { itemId });
return ok({ success: true });
}
@Tool({
name: 'reorder_todos',
description: 'Reorder to-do items within a trip by providing a new ordered list of item IDs.',
inputSchema: {
tripId: z.number().int().positive(),
orderedIds: z.array(z.number().int().positive()).min(1).describe('All item IDs in the desired order'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
when: packingAddonOn,
access: { group: 'todos', mode: 'write' },
})
async reorderTodos({ tripId, orderedIds }: { tripId: number; orderedIds: number[] }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
this.todos.reorderItems(tripId, orderedIds);
return ok({ success: true });
}
@Tool({
name: 'get_todo_category_assignees',
description: 'Get the default assignees configured per to-do category for a trip.',
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
when: packingAddonOn,
access: { group: 'todos', mode: 'read' },
})
async getTodoCategoryAssignees({ tripId }: { tripId: number }, ctx: McpContext) {
if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess();
const assignees = this.todos.getCategoryAssignees(tripId);
return ok({ assignees });
}
@Tool({
name: 'set_todo_category_assignees',
description: 'Set the default assignees for a to-do category on a trip. Pass an empty array to clear.',
inputSchema: {
tripId: z.number().int().positive(),
categoryName: z.string().min(1).max(100).describe('Category name'),
userIds: z.array(z.number().int().positive()).describe('User IDs to assign as defaults for this category'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
when: packingAddonOn,
access: { group: 'todos', mode: 'write' },
})
async setTodoCategoryAssignees({ tripId, categoryName, userIds }: { tripId: number; categoryName: string; userIds: number[] }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess();
if (!hasTripPermission('packing_edit', tripId, ctx.userId)) return permissionDenied();
const assignees = this.todos.updateCategoryAssignees(tripId, categoryName, userIds);
safeBroadcast(tripId, 'todo:assignees', { category: categoryName, assignees });
return ok({ assignees });
}
@ResourceTemplate({
name: 'trip-todos',
uriTemplate: 'trek://trips/{tripId}/todos',
description: 'To-do items for a trip, ordered by position',
mimeType: 'application/json',
when: packingAddonOn,
access: { group: 'todos', mode: 'read' },
})
async tripTodosResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) {
const id = parseId(tripId);
if (id === null || !this.todos.verifyTripAccess(id, ctx.userId)) {
return {
contents: [{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify({ error: 'Trip not found or access denied' }),
}],
};
}
const items = this.todos.listItems(id);
return {
contents: [{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify(items, null, 2),
}],
};
}
}
+5 -2
View File
@@ -1,10 +1,13 @@
import { Module } from '@nestjs/common';
import { TodoController } from './todo.controller';
import { TodoMcp } from './todo.mcp';
import { TodoService } from './todo.service';
/** To-do domain (S3 — Phase 2 trip sub-domain). Registered in AppModule. */
/** To-do domain (S3 — Phase 2 trip sub-domain). Registered in AppModule.
* Exports TodoService for in-container consumers (TripsService bundle). */
@Module({
controllers: [TodoController],
providers: [TodoService],
providers: [TodoService, TodoMcp],
exports: [TodoService],
})
export class TodoModule {}
+110 -21
View File
@@ -1,20 +1,27 @@
import { Injectable } from '@nestjs/common';
import { broadcast } from '../../websocket';
import { checkPermission } from '../../services/permissions';
import { verifyTripAccess } from '../../services/tripAccess';
import type { User } from '../../types';
import * as svc from '../../services/todoService';
import { DatabaseService } from '../database/database.service';
type Trip = NonNullable<ReturnType<typeof svc.verifyTripAccess>>;
type Trip = NonNullable<ReturnType<typeof verifyTripAccess>>;
/**
* Thin Nest wrapper around the existing todo service. Trip-access, the
* 'packing_edit' permission (shared with packing), the SQL and the WebSocket
* broadcasts all reuse the legacy code unchanged.
* Todo domain service — owns the todo SQL (moved 1:1 from the legacy
* services/todoService.ts: identical statements, the `||` falsy-coercion
* defaults, the bodyKeys sentinel protocol on update and the post-write
* re-selects). Trip access, the 'packing_edit' permission (shared with
* packing) and the WebSocket broadcast keep their legacy call paths.
* Non-Nest consumers (plugin RPC host, the legacy MCP trips registrar) go
* through todo.bridge.ts instead of importing this class directly.
*/
@Injectable()
export class TodoService {
verifyTripAccess(tripId: string, userId: number) {
return svc.verifyTripAccess(tripId, userId);
constructor(private readonly db: DatabaseService) {}
verifyTripAccess(tripId: string | number, userId: number) {
return verifyTripAccess(tripId, userId);
}
canEdit(trip: Trip, user: User): boolean {
@@ -25,31 +32,113 @@ export class TodoService {
broadcast(tripId, event, payload, socketId);
}
listItems(tripId: string) {
return svc.listItems(tripId);
listItems(tripId: string | number) {
return this.db.all(
'SELECT * FROM todo_items WHERE trip_id = ? ORDER BY sort_order ASC, created_at ASC',
tripId
);
}
createItem(tripId: string, data: Parameters<typeof svc.createItem>[1]) {
return svc.createItem(tripId, data);
createItem(tripId: string | number, data: {
name: string; category?: string; due_date?: string; description?: string; assigned_user_id?: number; priority?: number;
}) {
const maxOrder = this.db.get<{ max: number | null }>('SELECT MAX(sort_order) as max FROM todo_items WHERE trip_id = ?', tripId)!;
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const result = this.db.run(
'INSERT INTO todo_items (trip_id, name, checked, category, sort_order, due_date, description, assigned_user_id, priority) VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?)',
tripId, data.name, data.category || null, sortOrder,
data.due_date || null, data.description || null, data.assigned_user_id || null, data.priority || 0
);
return this.db.get('SELECT * FROM todo_items WHERE id = ?', result.lastInsertRowid);
}
updateItem(tripId: string, id: string, data: Parameters<typeof svc.updateItem>[2], changedKeys: string[]) {
return svc.updateItem(tripId, id, data, changedKeys);
updateItem(
tripId: string | number,
id: string | number,
data: { name?: string; checked?: number; category?: string; due_date?: string | null; description?: string | null; assigned_user_id?: number | null; priority?: number | null },
bodyKeys: string[]
) {
const item = this.db.get('SELECT * FROM todo_items WHERE id = ? AND trip_id = ?', id, tripId);
if (!item) return null;
this.db.run(`
UPDATE todo_items SET
name = COALESCE(?, name),
checked = CASE WHEN ? IS NOT NULL THEN ? ELSE checked END,
category = COALESCE(?, category),
due_date = CASE WHEN ? THEN ? ELSE due_date END,
description = CASE WHEN ? THEN ? ELSE description END,
assigned_user_id = CASE WHEN ? THEN ? ELSE assigned_user_id END,
priority = CASE WHEN ? THEN ? ELSE priority END
WHERE id = ?
`,
data.name || null,
data.checked !== undefined ? 1 : null,
data.checked ? 1 : 0,
data.category || null,
bodyKeys.includes('due_date') ? 1 : 0,
data.due_date ?? null,
bodyKeys.includes('description') ? 1 : 0,
data.description ?? null,
bodyKeys.includes('assigned_user_id') ? 1 : 0,
data.assigned_user_id ?? null,
bodyKeys.includes('priority') ? 1 : 0,
data.priority ?? 0,
id
);
return this.db.get('SELECT * FROM todo_items WHERE id = ?', id);
}
deleteItem(tripId: string, id: string): boolean {
return svc.deleteItem(tripId, id);
deleteItem(tripId: string | number, id: string | number): boolean {
const item = this.db.get('SELECT id FROM todo_items WHERE id = ? AND trip_id = ?', id, tripId);
if (!item) return false;
this.db.run('DELETE FROM todo_items WHERE id = ?', id);
return true;
}
reorderItems(tripId: string, orderedIds: Parameters<typeof svc.reorderItems>[1]): void {
svc.reorderItems(tripId, orderedIds);
reorderItems(tripId: string | number, orderedIds: number[]): void {
const update = this.db.prepare('UPDATE todo_items SET sort_order = ? WHERE id = ? AND trip_id = ?');
this.db.transaction(() => {
orderedIds.forEach((id, index) => {
update.run(index, id, tripId);
});
});
}
getCategoryAssignees(tripId: string) {
return svc.getCategoryAssignees(tripId);
getCategoryAssignees(tripId: string | number) {
const rows = this.db.all<{ category_name: string; user_id: number; username: string; avatar: string | null }>(`
SELECT tca.category_name, tca.user_id, u.username, u.avatar
FROM todo_category_assignees tca
JOIN users u ON tca.user_id = u.id
WHERE tca.trip_id = ?
`, tripId);
const assignees: Record<string, { user_id: number; username: string; avatar: string | null }[]> = {};
for (const row of rows) {
if (!assignees[row.category_name]) assignees[row.category_name] = [];
assignees[row.category_name].push({ user_id: row.user_id, username: row.username, avatar: row.avatar });
}
return assignees;
}
updateCategoryAssignees(tripId: string, category: string, userIds: number[]) {
return svc.updateCategoryAssignees(tripId, category, userIds);
updateCategoryAssignees(tripId: string | number, categoryName: string, userIds: number[] | undefined) {
this.db.run('DELETE FROM todo_category_assignees WHERE trip_id = ? AND category_name = ?', tripId, categoryName);
if (Array.isArray(userIds) && userIds.length > 0) {
const insert = this.db.prepare('INSERT OR IGNORE INTO todo_category_assignees (trip_id, category_name, user_id) VALUES (?, ?, ?)');
for (const uid of userIds) insert.run(tripId, categoryName, uid);
}
return this.db.all(`
SELECT tca.user_id, u.username, u.avatar
FROM todo_category_assignees tca
JOIN users u ON tca.user_id = u.id
WHERE tca.trip_id = ? AND tca.category_name = ?
`, tripId, categoryName);
}
}
+2
View File
@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { TodoModule } from '../todo/todo.module';
import { TripsController } from './trips.controller';
import { TripsService } from './trips.service';
/** Trips aggregate root (C1 — Phase 3). Uses exact strangler prefixes so it does
* not capture the nested sub-domain mounts (collab, files, ...). */
@Module({
imports: [TodoModule],
controllers: [TripsController],
providers: [TripsService],
})
+6 -3
View File
@@ -7,7 +7,7 @@ import * as tripSvc from '../../services/tripService';
import { listDays, listAccommodations } from '../../services/dayService';
import { listPlaces } from '../../services/placeService';
import { listItems as listPackingItems } from '../../services/packingService';
import { listItems as listTodoItems } from '../../services/todoService';
import { TodoService } from '../todo/todo.service';
import { listBudgetItems, rebaseTripCurrency } from '../../services/budgetService';
import { listReservations } from '../../services/reservationService';
import { listFiles } from '../../services/fileService';
@@ -22,7 +22,10 @@ import { searchUnsplashPhotos, getUnsplashKey } from '../../services/unsplashSer
*/
@Injectable()
export class TripsService {
constructor(private readonly dbs: DatabaseService) {}
constructor(
private readonly dbs: DatabaseService,
private readonly todo: TodoService,
) {}
private get db() {
return this.dbs.connection;
@@ -134,7 +137,7 @@ export class TripsService {
days,
places: listPlaces(String(tripId), {}),
packingItems: listPackingItems(tripId),
todoItems: listTodoItems(tripId),
todoItems: this.todo.listItems(tripId),
budgetItems: listBudgetItems(tripId),
reservations: listReservations(tripId),
files: listFiles(tripId, false),
-120
View File
@@ -1,120 +0,0 @@
import { db } from '../db/database';
export { verifyTripAccess } from './tripAccess';
// ── Items ──────────────────────────────────────────────────────────────────
export function listItems(tripId: string | number) {
return db.prepare(
'SELECT * FROM todo_items WHERE trip_id = ? ORDER BY sort_order ASC, created_at ASC'
).all(tripId);
}
export function createItem(tripId: string | number, data: {
name: string; category?: string; due_date?: string; description?: string; assigned_user_id?: number; priority?: number;
}) {
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM todo_items WHERE trip_id = ?').get(tripId) as { max: number | null };
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const result = db.prepare(
'INSERT INTO todo_items (trip_id, name, checked, category, sort_order, due_date, description, assigned_user_id, priority) VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?)'
).run(
tripId, data.name, data.category || null, sortOrder,
data.due_date || null, data.description || null, data.assigned_user_id || null, data.priority || 0
);
return db.prepare('SELECT * FROM todo_items WHERE id = ?').get(result.lastInsertRowid);
}
export function updateItem(
tripId: string | number,
id: string | number,
data: { name?: string; checked?: number; category?: string; due_date?: string | null; description?: string | null; assigned_user_id?: number | null; priority?: number | null },
bodyKeys: string[]
) {
const item = db.prepare('SELECT * FROM todo_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return null;
db.prepare(`
UPDATE todo_items SET
name = COALESCE(?, name),
checked = CASE WHEN ? IS NOT NULL THEN ? ELSE checked END,
category = COALESCE(?, category),
due_date = CASE WHEN ? THEN ? ELSE due_date END,
description = CASE WHEN ? THEN ? ELSE description END,
assigned_user_id = CASE WHEN ? THEN ? ELSE assigned_user_id END,
priority = CASE WHEN ? THEN ? ELSE priority END
WHERE id = ?
`).run(
data.name || null,
data.checked !== undefined ? 1 : null,
data.checked ? 1 : 0,
data.category || null,
bodyKeys.includes('due_date') ? 1 : 0,
data.due_date ?? null,
bodyKeys.includes('description') ? 1 : 0,
data.description ?? null,
bodyKeys.includes('assigned_user_id') ? 1 : 0,
data.assigned_user_id ?? null,
bodyKeys.includes('priority') ? 1 : 0,
data.priority ?? 0,
id
);
return db.prepare('SELECT * FROM todo_items WHERE id = ?').get(id);
}
export function deleteItem(tripId: string | number, id: string | number) {
const item = db.prepare('SELECT id FROM todo_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return false;
db.prepare('DELETE FROM todo_items WHERE id = ?').run(id);
return true;
}
// ── Category Assignees ─────────────────────────────────────────────────────
export function getCategoryAssignees(tripId: string | number) {
const rows = db.prepare(`
SELECT tca.category_name, tca.user_id, u.username, u.avatar
FROM todo_category_assignees tca
JOIN users u ON tca.user_id = u.id
WHERE tca.trip_id = ?
`).all(tripId);
const assignees: Record<string, { user_id: number; username: string; avatar: string | null }[]> = {};
for (const row of rows as any[]) {
if (!assignees[row.category_name]) assignees[row.category_name] = [];
assignees[row.category_name].push({ user_id: row.user_id, username: row.username, avatar: row.avatar });
}
return assignees;
}
export function updateCategoryAssignees(tripId: string | number, categoryName: string, userIds: number[] | undefined) {
db.prepare('DELETE FROM todo_category_assignees WHERE trip_id = ? AND category_name = ?').run(tripId, categoryName);
if (Array.isArray(userIds) && userIds.length > 0) {
const insert = db.prepare('INSERT OR IGNORE INTO todo_category_assignees (trip_id, category_name, user_id) VALUES (?, ?, ?)');
for (const uid of userIds) insert.run(tripId, categoryName, uid);
}
return db.prepare(`
SELECT tca.user_id, u.username, u.avatar
FROM todo_category_assignees tca
JOIN users u ON tca.user_id = u.id
WHERE tca.trip_id = ? AND tca.category_name = ?
`).all(tripId, categoryName);
}
// ── Reorder ────────────────────────────────────────────────────────────────
export function reorderItems(tripId: string | number, orderedIds: number[]) {
const update = db.prepare('UPDATE todo_items SET sort_order = ? WHERE id = ? AND trip_id = ?');
const updateMany = db.transaction((ids: number[]) => {
ids.forEach((id, index) => {
update.run(index, id, tripId);
});
});
updateMany(orderedIds);
}