mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 13:51:45 +00:00
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:
@@ -463,6 +463,11 @@ export function deleteTemplateItem(itemId: string) {
|
||||
|
||||
// ── Addons ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function isAddonEnabled(addonId: string): boolean {
|
||||
const addon = db.prepare('SELECT enabled FROM addons WHERE id = ?').get(addonId) as { enabled: number } | undefined;
|
||||
return !!addon?.enabled;
|
||||
}
|
||||
|
||||
export function listAddons() {
|
||||
const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[];
|
||||
return addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') }));
|
||||
|
||||
@@ -35,8 +35,11 @@ export function getAssignmentWithPlace(assignmentId: number | bigint) {
|
||||
return {
|
||||
id: a.id,
|
||||
day_id: a.day_id,
|
||||
place_id: a.place_id,
|
||||
order_index: a.order_index,
|
||||
notes: a.notes,
|
||||
assignment_time: a.assignment_time ?? null,
|
||||
assignment_end_time: a.assignment_end_time ?? null,
|
||||
participants,
|
||||
created_at: a.created_at,
|
||||
place: {
|
||||
|
||||
@@ -327,6 +327,12 @@ export function getCountryPlaces(userId: number, code: string) {
|
||||
|
||||
// ── Mark / unmark country ───────────────────────────────────────────────────
|
||||
|
||||
export function listVisitedCountries(userId: number): { country_code: string; created_at: string }[] {
|
||||
return db.prepare(
|
||||
'SELECT country_code, created_at FROM visited_countries WHERE user_id = ? ORDER BY created_at DESC'
|
||||
).all(userId) as { country_code: string; created_at: string }[];
|
||||
}
|
||||
|
||||
export function markCountryVisited(userId: number, code: string): void {
|
||||
db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(userId, code);
|
||||
}
|
||||
|
||||
@@ -988,3 +988,38 @@ export function createResourceToken(userId: number, purpose?: string): { error?:
|
||||
if (!token) return { error: 'Service unavailable', status: 503 };
|
||||
return { token };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP auth helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isDemoUser(userId: number): boolean {
|
||||
if (process.env.DEMO_MODE !== 'true') return false;
|
||||
const user = db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined;
|
||||
return user?.email === 'demo@nomad.app';
|
||||
}
|
||||
|
||||
export function verifyMcpToken(rawToken: string): User | null {
|
||||
const hash = createHash('sha256').update(rawToken).digest('hex');
|
||||
const row = db.prepare(`
|
||||
SELECT u.id, u.username, u.email, u.role
|
||||
FROM mcp_tokens mt
|
||||
JOIN users u ON mt.user_id = u.id
|
||||
WHERE mt.token_hash = ?
|
||||
`).get(hash) as User | undefined;
|
||||
if (row) {
|
||||
db.prepare('UPDATE mcp_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?').run(hash);
|
||||
return row;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function verifyJwtToken(token: string): User | null {
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
|
||||
const user = db.prepare('SELECT id, username, email, role FROM users WHERE id = ?').get(decoded.id) as User | undefined;
|
||||
return user || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,10 +145,10 @@ export function getDay(id: string | number, tripId: string | number) {
|
||||
return db.prepare('SELECT * FROM days WHERE id = ? AND trip_id = ?').get(id, tripId) as Day | undefined;
|
||||
}
|
||||
|
||||
export function updateDay(id: string | number, current: Day, fields: { notes?: string; title?: string }) {
|
||||
export function updateDay(id: string | number, current: Day, fields: { notes?: string; title?: string | null }) {
|
||||
db.prepare('UPDATE days SET notes = ?, title = ? WHERE id = ?').run(
|
||||
fields.notes || null,
|
||||
fields.title !== undefined ? fields.title : current.title,
|
||||
'title' in fields ? (fields.title ?? null) : current.title,
|
||||
id
|
||||
);
|
||||
const updatedDay = db.prepare('SELECT * FROM days WHERE id = ?').get(id) as Day;
|
||||
|
||||
@@ -56,8 +56,11 @@ function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial<Tag>[], parti
|
||||
return {
|
||||
id: a.id,
|
||||
day_id: a.day_id,
|
||||
place_id: a.place_id,
|
||||
order_index: a.order_index,
|
||||
notes: a.notes,
|
||||
assignment_time: a.assignment_time ?? null,
|
||||
assignment_end_time: a.assignment_end_time ?? null,
|
||||
participants: participants || [],
|
||||
created_at: a.created_at,
|
||||
place: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user