chore: move category backend to proper DI

This commit is contained in:
jubnl
2026-07-26 12:36:30 +02:00
parent c8369bd14b
commit 2ce40724d7
16 changed files with 398 additions and 196 deletions
+3 -11
View File
@@ -9,7 +9,6 @@ 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 { listCategories } from '../services/categoryService';
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';
@@ -215,16 +214,9 @@ export function registerResources(server: McpServer, userId: number, scopes: str
}
);
// All place categories (global, no trip filter) — safe for any authenticated session
server.registerResource(
'categories',
'trek://categories',
{ description: 'All available place categories (id, name, color, icon) for use when creating places', mimeType: 'application/json' },
async (uri) => {
const categories = listCategories();
return jsonContent(uri.href, categories);
}
);
// The trek://categories resource moved to the DI-discovered
// src/nest/categories/categories.mcp.ts (@Resource, attached via the
// nest-mcp registry in registerTools).
// User's bucket list
if (isAddonEnabled(ADDON_IDS.ATLAS) && canRead(scopes, 'atlas')) server.registerResource(
+2 -15
View File
@@ -5,7 +5,6 @@ import { isDemoUser } from '../../services/authService';
import { deletePlacesMany, updatePlacesMany, importGoogleList, importNaverList, listPlaces, createPlace, updatePlace, deletePlace, ratePlace } from '../../services/placeService';
import { createAssignment, dayExists } from '../../services/assignmentService';
import { onPlaceDeleted, reconcileTripSkeletons } from '../../services/journeyService';
import { listCategories } from '../../services/categoryService';
import { searchPlaces } from '../../services/mapsService';
import {
safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
@@ -203,20 +202,8 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
}
);
// --- CATEGORIES ---
if (R) server.registerTool(
'list_categories',
{
description: 'List all available place categories with their id, name, icon and color. Use category_id when creating or updating places.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const categories = listCategories();
return ok({ categories });
}
);
// The list_categories tool moved to the DI-discovered
// src/nest/categories/categories.mcp.ts (@McpController).
// --- SEARCH ---
+8 -5
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 — see the
migration recipe below.
- **DI-native services (legacy `src/services/*` deleted):** tags, categories —
see the migration recipe below.
## Cross-cutting Foundation pieces
@@ -94,9 +94,12 @@ at the typed contract (Frontend Track).
## Migrating a legacy `src/services/*` service into its Nest module (recipe)
Pilot: **tags** (`services/tagService.ts` → `nest/tags/tags.service.ts` +
`nest/tags/tags.bridge.ts`). Repeat these steps per service (next up:
categoryService, then todoService). This is a **pure relocation** — byte-identical
SQL, statuses, bodies, and error strings.
`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
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`.
1. **Move the SQL** into `<domain>.service.ts` as methods over an injected
`DatabaseService` (`this.db.all<T>/get<T>/run/prepare/transaction`; strict
@@ -0,0 +1,21 @@
import type { Category } from '@trek/shared';
import { db } from '../../db/database';
import { DatabaseService } from '../database/database.service';
import { CategoriesService } from './categories.service';
/**
* Non-Nest entry point for the categories domain — for code running OUTSIDE
* the Nest container (currently only the plugin RPC host; the MCP tool and
* resource moved to the DI-discovered categories.mcp.ts). Exports only the
* legacy services/categoryService names still consumed outside the container,
* 1:1, so repointing a consumer is an import-path-only diff. Inside the
* container, inject CategoriesService instead.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton (same pattern as tags.bridge.ts).
*/
const categories = new CategoriesService(new DatabaseService(db));
export function listCategories(): Category[] {
return categories.list();
}
@@ -0,0 +1,48 @@
import {
McpController, Tool, Resource, type McpContext,
TOOL_ANNOTATIONS_READONLY, ok,
} from '@trek/nest-mcp';
import { CategoriesService } from './categories.service';
/**
* Categories MCP surface — ported 1:1 from the legacy registrars: the
* list_categories tool from src/mcp/tools/places.ts (identical name,
* description, annotations and places-read gating via the declarative access
* marker + trekMcpAccessPolicy) and the trek://categories resource from
* src/mcp/resources.ts (first production @Resource; intentionally ungated —
* the legacy registration was unconditional, safe for any authenticated
* session — and the payload reproduces the legacy jsonContent shape verbatim).
*/
@McpController()
export class CategoriesMcp {
constructor(private readonly categories: CategoriesService) {}
@Tool({
name: 'list_categories',
description: 'List all available place categories with their id, name, icon and color. Use category_id when creating or updating places.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'places', mode: 'read' },
})
async listCategories(_args: Record<string, never>, _ctx: McpContext) {
const categories = this.categories.list();
return ok({ categories });
}
@Resource({
name: 'categories',
uri: 'trek://categories',
description: 'All available place categories (id, name, color, icon) for use when creating places',
mimeType: 'application/json',
})
async categoriesResource(uri: URL, _ctx: McpContext) {
const categories = this.categories.list();
return {
contents: [{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify(categories, null, 2),
}],
};
}
}
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { CategoriesController } from './categories.controller';
import { CategoriesMcp } from './categories.mcp';
import { CategoriesService } from './categories.service';
/** Categories domain (L4 leaf module). Registered in AppModule. */
@Module({
controllers: [CategoriesController],
providers: [CategoriesService],
providers: [CategoriesService, CategoriesMcp],
})
export class CategoriesModule {}
@@ -1,37 +1,57 @@
import { Injectable } from '@nestjs/common';
import type { Category } from '@trek/shared';
import {
listCategories,
getCategoryById,
createCategory,
updateCategory,
deleteCategory,
} from '../../services/categoryService';
import { DatabaseService } from '../database/database.service';
/**
* Thin Nest wrapper around the existing category service. The SQL and the
* default colour/icon fallbacks stay in categoryService, so behaviour is
* unchanged.
* Categories domain service — owns the category SQL (moved 1:1 from the legacy
* services/categoryService.ts: identical statements, the #6366f1/📍 defaults
* and the COALESCE update semantics). Non-Nest consumers (plugin RPC host) go
* through categories.bridge.ts instead of importing this class directly.
*/
@Injectable()
export class CategoriesService {
constructor(private readonly db: DatabaseService) {}
list(): Category[] {
return listCategories() as Category[];
return this.db.all<Category>('SELECT * FROM categories ORDER BY name ASC');
}
getById(id: string | number): Category | undefined {
return getCategoryById(id) as Category | undefined;
return this.db.get<Category>('SELECT * FROM categories WHERE id = ?', id);
}
create(userId: number, name: string, color?: string, icon?: string): Category {
return createCategory(userId, name, color, icon) as Category;
const result = this.db.run(
'INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)',
name,
color || '#6366f1',
icon || '📍',
userId,
);
return this.db.get<Category>(
'SELECT * FROM categories WHERE id = ?',
result.lastInsertRowid,
) as Category;
}
update(id: string | number, name?: string, color?: string, icon?: string): Category {
return updateCategory(id, name, color, icon) as Category;
this.db.run(
`
UPDATE categories SET
name = COALESCE(?, name),
color = COALESCE(?, color),
icon = COALESCE(?, icon)
WHERE id = ?
`,
name || null,
color || null,
icon || null,
id,
);
return this.db.get<Category>('SELECT * FROM categories WHERE id = ?', id) as Category;
}
remove(id: string | number): void {
deleteCategory(id);
this.db.run('DELETE FROM categories WHERE id = ?', id);
}
}
@@ -6,7 +6,7 @@ import { listBudgetItems } from '../../../services/budgetService';
import { listItems as listPackingItemsSvc, createItem as createPackingItemSvc, updateItem as updatePackingItemSvc, deleteItem as deletePackingItemSvc, listBags, createBag as createBagSvc, updateBag as updateBagSvc, deleteBag as deleteBagSvc, setBagMembers } from '../../../services/packingService';
import { isUpdateConflict } from '../../../services/conflictResult';
import { getWeather } from '../../../services/weatherService';
import { listCategories } from '../../../services/categoryService';
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 { listFiles, createFile, createFileLink, getFileById, updateFile, softDeleteFile, findForeignLinkTarget, resolveFilePath, BLOCKED_EXTENSIONS, filesDir } from '../../../services/fileService';
-31
View File
@@ -1,31 +0,0 @@
import { db } from '../db/database';
export function listCategories() {
return db.prepare('SELECT * FROM categories ORDER BY name ASC').all();
}
export function createCategory(userId: number, name: string, color?: string, icon?: string) {
const result = db.prepare(
'INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)'
).run(name, color || '#6366f1', icon || '\uD83D\uDCCD', userId);
return db.prepare('SELECT * FROM categories WHERE id = ?').get(result.lastInsertRowid);
}
export function getCategoryById(categoryId: number | string) {
return db.prepare('SELECT * FROM categories WHERE id = ?').get(categoryId);
}
export function updateCategory(categoryId: number | string, name?: string, color?: string, icon?: string) {
db.prepare(`
UPDATE categories SET
name = COALESCE(?, name),
color = COALESCE(?, color),
icon = COALESCE(?, icon)
WHERE id = ?
`).run(name || null, color || null, icon || null, categoryId);
return db.prepare('SELECT * FROM categories WHERE id = ?').get(categoryId);
}
export function deleteCategory(categoryId: number | string) {
db.prepare('DELETE FROM categories WHERE id = ?').run(categoryId);
}