refactor(mcp): replace direct DB access with service layer calls

Replace all db.prepare() calls in mcp/index.ts, mcp/resources.ts, and
mcp/tools.ts with calls to the service layer. Add missing service functions:
- authService: isDemoUser, verifyMcpToken, verifyJwtToken
- adminService: isAddonEnabled
- atlasService: listVisitedCountries
- tripService: getTripSummary, listTrips with null archived param

Also fix getAssignmentWithPlace and formatAssignmentWithPlace to expose
place_id, assignment_time, and assignment_end_time at the top level, and
fix updateDay to correctly handle null title for clearing.

Add comprehensive unit and integration test suite for the MCP layer (821 tests all passing).
This commit is contained in:
jubnl
2026-04-04 18:12:14 +02:00
parent 1ea0eb9965
commit 1bddb3c588
24 changed files with 4006 additions and 613 deletions
+108 -5
View File
@@ -27,7 +27,7 @@ export { isOwner };
// ── Day generation ────────────────────────────────────────────────────────
export function generateDays(tripId: number | bigint | string, startDate: string | null, endDate: string | null) {
export function generateDays(tripId: number | bigint | string, startDate: string | null, endDate: string | null, maxDays?: number) {
const existing = db.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ?').all(tripId) as { id: number; day_number: number; date: string | null }[];
if (!startDate || !endDate) {
@@ -56,7 +56,7 @@ export function generateDays(tripId: number | bigint | string, startDate: string
const [ey, em, ed] = endDate.split('-').map(Number);
const startMs = Date.UTC(sy, sm - 1, sd);
const endMs = Date.UTC(ey, em - 1, ed);
const numDays = Math.min(Math.floor((endMs - startMs) / MS_PER_DAY) + 1, MAX_TRIP_DAYS);
const numDays = Math.min(Math.floor((endMs - startMs) / MS_PER_DAY) + 1, maxDays ?? MAX_TRIP_DAYS);
const targetDates: string[] = [];
for (let i = 0; i < numDays; i++) {
@@ -99,7 +99,15 @@ export function generateDays(tripId: number | bigint | string, startDate: string
// ── Trip CRUD ─────────────────────────────────────────────────────────────
export function listTrips(userId: number, archived: number) {
export function listTrips(userId: number, archived: number | null) {
if (archived === null) {
return db.prepare(`
${TRIP_SELECT}
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
WHERE (t.user_id = :userId OR m.user_id IS NOT NULL)
ORDER BY t.created_at DESC
`).all({ userId });
}
return db.prepare(`
${TRIP_SELECT}
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
@@ -117,7 +125,7 @@ interface CreateTripData {
reminder_days?: number;
}
export function createTrip(userId: number, data: CreateTripData) {
export function createTrip(userId: number, data: CreateTripData, maxDays?: number) {
const rd = data.reminder_days !== undefined
? (Number(data.reminder_days) >= 0 && Number(data.reminder_days) <= 30 ? Number(data.reminder_days) : 3)
: 3;
@@ -128,7 +136,7 @@ export function createTrip(userId: number, data: CreateTripData) {
`).run(userId, data.title, data.description || null, data.start_date || null, data.end_date || null, data.currency || 'EUR', rd);
const tripId = result.lastInsertRowid;
generateDays(tripId, data.start_date || null, data.end_date || null);
generateDays(tripId, data.start_date || null, data.end_date || null, maxDays);
const trip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId });
return { trip, tripId: Number(tripId), reminderDays: rd };
@@ -398,6 +406,101 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
return { ics, filename: `${safeFilename}.ics` };
}
// ── Trip summary (used by MCP get_trip_summary tool) ──────────────────────
export function getTripSummary(tripId: number) {
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Record<string, unknown> | undefined;
if (!trip) return null;
const owner = db.prepare('SELECT id, username, avatar FROM users WHERE id = ?').get(trip.user_id as number);
const members = db.prepare(`
SELECT u.id, u.username, u.avatar, tm.added_at
FROM trip_members tm JOIN users u ON tm.user_id = u.id
WHERE tm.trip_id = ?
`).all(tripId);
const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId) as (Record<string, unknown> & { id: number })[];
const dayIds = days.map(d => d.id);
const assignmentsByDay: Record<number, unknown[]> = {};
const dayNotesByDay: Record<number, unknown[]> = {};
if (dayIds.length > 0) {
const placeholders = dayIds.map(() => '?').join(',');
const assignments = db.prepare(`
SELECT da.id, da.day_id, da.order_index, da.notes as assignment_notes,
p.id as place_id, p.name, p.address, p.lat, p.lng,
COALESCE(da.assignment_time, p.place_time) as place_time,
c.name as category_name, c.icon as category_icon
FROM day_assignments da
JOIN places p ON da.place_id = p.id
LEFT JOIN categories c ON p.category_id = c.id
WHERE da.day_id IN (${placeholders})
ORDER BY da.order_index ASC
`).all(...dayIds) as (Record<string, unknown> & { day_id: number })[];
for (const a of assignments) {
if (!assignmentsByDay[a.day_id]) assignmentsByDay[a.day_id] = [];
assignmentsByDay[a.day_id].push(a);
}
const dayNotes = db.prepare(`
SELECT * FROM day_notes WHERE day_id IN (${placeholders}) ORDER BY sort_order ASC
`).all(...dayIds) as (Record<string, unknown> & { day_id: number })[];
for (const n of dayNotes) {
if (!dayNotesByDay[n.day_id]) dayNotesByDay[n.day_id] = [];
dayNotesByDay[n.day_id].push(n);
}
}
const daysWithAssignments = days.map(d => ({
...d,
assignments: assignmentsByDay[d.id] || [],
notes: dayNotesByDay[d.id] || [],
}));
const accommodations = db.prepare(`
SELECT da.*, p.name as place_name, ds.day_number as start_day_number, de.day_number as end_day_number
FROM day_accommodations da
JOIN places p ON da.place_id = p.id
LEFT JOIN days ds ON da.start_day_id = ds.id
LEFT JOIN days de ON da.end_day_id = de.id
WHERE da.trip_id = ?
ORDER BY ds.day_number ASC
`).all(tripId);
const budgetStats = db.prepare(`
SELECT COUNT(*) as item_count, COALESCE(SUM(total_price), 0) as total
FROM budget_items WHERE trip_id = ?
`).get(tripId) as { item_count: number; total: number };
const packingStats = db.prepare(`
SELECT COUNT(*) as total, SUM(CASE WHEN checked = 1 THEN 1 ELSE 0 END) as checked
FROM packing_items WHERE trip_id = ?
`).get(tripId) as { total: number; checked: number };
const reservations = db.prepare(`
SELECT r.*, d.day_number
FROM reservations r
LEFT JOIN days d ON r.day_id = d.id
WHERE r.trip_id = ?
ORDER BY r.reservation_time ASC, r.created_at ASC
`).all(tripId);
const collabNotes = db.prepare(
'SELECT * FROM collab_notes WHERE trip_id = ? ORDER BY pinned DESC, updated_at DESC'
).all(tripId);
return {
trip,
members: { owner, collaborators: members },
days: daysWithAssignments,
accommodations,
budget: { ...budgetStats, currency: trip.currency },
packing: packingStats,
reservations,
collab_notes: collabNotes,
};
}
// ── Custom error types ────────────────────────────────────────────────────
export class NotFoundError extends Error {