mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
chore: move todo backend to proper DI
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
}],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user