mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
chore: move category backend to proper DI
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user